Add social preview card rendering and deployment

- Introduced `OgCard` React component as the default Open Graph preview card for social sharing (1200x630).
- Added `BrandFonts` module to load web fonts for accurate rendering.
- Integrated `OgCard` into the media creator pipeline as a `<Still>` with a new `still:og` script.
- Updated upload script to version the output and ensure compatibility with caching policies.
- Included new card meta tags across web pages, defaulting to this design for links without a custom preview.
- Extended theme and README to document font usage, card details, and deployment workflow.
This commit is contained in:
MartinBraquet
2026-07-24 18:34:18 +02:00
parent 3a38e8affb
commit de99d2decc
11 changed files with 449 additions and 13 deletions

View File

@@ -26,4 +26,26 @@ export const SENTRY_DSN =
export const PNG_LOGO = 'https://www.compassmeet.com/icons/icon-512x512.png'
/**
* The default social preview card — what WhatsApp, X, Slack, LinkedIn etc. show for a shared
* compassmeet.com link. 1200×630, the 1.91:1 `summary_large_image` slot.
*
* Rendered from media-creator (`npm run still:og`), stored in Cloudflare R2, and pulled into
* web/public/images at build time by web/scripts/fetch-media.mjs — so it is served same-origin.
*
* The filename is versioned deliberately: WhatsApp and X cache a preview image *by URL* and
* effectively never revalidate it. A redesign has to ship as `-v2`, otherwise every link already
* shared keeps showing the old card forever.
*
* Absolute and hardcoded to prod (like PNG_LOGO above) because crawlers resolve it from wherever
* the page is served — a preview deployment linking to its own ephemeral URL would rot.
*/
export const OG_CARD = {
url: 'https://www.compassmeet.com/images/og-card-v1.jpg',
type: 'image/jpeg',
width: '1200',
height: '630',
alt: "Compass — Don't Swipe. Search. Find your people based on your values and interests.",
} as const
// console.log('IS_LOCAL_ANDROID', IS_LOCAL_ANDROID)

View File

@@ -99,6 +99,50 @@ canvas. Formats are defined in `src/theme.ts` (`FORMATS`) and registered in `src
> Don't post the 9:16 story as a feed post — Instagram crops it to ~4:5. Use `IntroPost` for the feed.
## Stills
### Social preview card (`OgCard`)
The default link preview — what WhatsApp, X, Slack, LinkedIn and friends show for a shared
compassmeet.com link. 1200×630, the 1.91:1 `summary_large_image` slot. A `<Still>`, not a
`<Composition>`: there is no timeline.
```bash
npm run still:og # -> out/compass-og-card.jpg (~80 KB, well under WhatsApp's ceiling)
```
It covers every page that does not set its own image; pages that do (a profile) get the separate
per-profile card generated at runtime by `web/pages/api/og/profile.tsx`. The two deliberately share
a visual language — warm cream canvas, amber rules, serif voice — so a shared profile and a shared
home link look like they come from the same place.
Publishing it is the usual R2 loop, plus one extra rule:
```bash
npm run still:og
npm run upload:media # uploads it as images/og-card-v1.jpg alongside the clips
# then re-deploy: the web build pulls it into web/public/images
```
> **The filename is versioned, and a redesign must bump the version.** WhatsApp and X cache a
> preview image _by URL_ and effectively never revalidate it. Overwriting `og-card-v1.jpg` leaves
> everyone who already shared a link looking at the old card indefinitely. To ship a new design,
> bump `-v1` to `-v2` in all three places at once: `scripts/upload-media.sh`,
> `web/scripts/fetch-media.mjs`, and `OG_CARD` in `common/src/hosting/constants.ts` (which is what
> the meta tags in `web/pages/_app.tsx` read).
Copy on the card is the home page's own wording (`web/components/home/home.tsx`), so the preview
cannot promise something the landing page does not say. If the home headline changes, re-render.
#### Fonts
This is the one scene that uses the real web faces — Newsreader for the headline, DM Sans for the
eyebrow, Cormorant Garamond for the wordmark — rather than the system stack the videos use. The
woff2 files are vendored in `public/fonts/` (both families are SIL OFL) and loaded by
`src/components/BrandFonts.ts`, which holds the render open until they are ready. Vendoring rather
than fetching from Google keeps the render reproducible offline; a missing network would otherwise
silently produce a card in Georgia.
## Structure
```
@@ -106,12 +150,14 @@ media-creator/
├── package.json standalone deps (npm)
├── remotion.config.ts render settings (codec, quality)
├── public/logo.svg Compass logo (copied from web/public/favicon.svg)
├── public/fonts/ vendored web faces, used by the OG card only
└── src/
├── index.ts registerRoot
├── Root.tsx composition registry
├── theme.ts brand colors / fonts / output FORMATS
├── components/ Background, Logo, reusable animations
── scenes/Intro.tsx the intro video (shared by both formats)
├── components/ Background, Logo, BrandFonts, reusable animations
── scenes/Intro.tsx the intro video (shared by both formats)
└── scenes/OgCard.tsx the default social preview card (a still)
```
## Adding another video

View File

@@ -23,6 +23,7 @@
"render:tour:post": "remotion render ProfileTourPost out/compass-profile-tour-post.mp4",
"still:alert": "remotion still SearchAlertLight out/compass-alert-poster-light.png --frame=0",
"still:alert:dark": "remotion still SearchAlertDark out/compass-alert-poster-dark.png --frame=0",
"still:og": "remotion still OgCard out/compass-og-card.jpg --image-format=jpeg --jpeg-quality=92",
"still:search": "remotion still SearchDemoLight out/compass-search-poster-light.png --frame=0",
"still:search:dark": "remotion still SearchDemoDark out/compass-search-poster-dark.png --frame=0",
"studio": "remotion studio",

View File

@@ -103,6 +103,11 @@ upload "$WEB_IMAGES/search-alert-poster-dark.jpg" "images/search-alert-poster-da
for theme in light dark; do
upload "$WEB_IMAGES/vote-tally-${theme}.png" "images/vote-tally-${theme}.png" "image/png"
done
# The default social preview card (`npm run still:og`). Uploaded under a versioned key: WhatsApp and
# X cache a preview image by URL and effectively never revalidate, so a redesign must go up as
# og-card-v2.jpg — bumped here, in web/scripts/fetch-media.mjs and in OG_CARD
# (common/hosting/constants.ts) together — rather than overwrite this one.
upload "$OUT_DIR/compass-og-card.jpg" "images/og-card-v1.jpg" "image/jpeg"
echo
echo "Done. The next Vercel build pulls these into web/public/videos via MEDIA_SOURCE_BASE_URL."

View File

@@ -1,6 +1,7 @@
import {Composition} from 'remotion'
import {Composition, Still} from 'remotion'
import {FORMATS} from './theme'
import {Intro, INTRO_DURATION} from './scenes/Intro'
import {OgCard} from './scenes/OgCard'
import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour'
import {SearchDemo, SearchDemoProps, calculateSearchDemoMetadata} from './scenes/SearchDemo'
import {SearchAlert, SearchAlertProps, calculateSearchAlertMetadata} from './scenes/SearchAlert'
@@ -95,6 +96,10 @@ export const RemotionRoot: React.FC = () => {
defaultProps={{manifest: null, theme: 'dark'} as SearchAlertProps}
calculateMetadata={calculateSearchAlertMetadata}
/>
{/* The default social preview card. A Still, not a Composition — there is no timeline, and
`remotion still` is the only way it is ever rendered:
npm run still:og -> out/compass-og-card.jpg */}
<Still id="OgCard" component={OgCard} width={FORMATS.og.width} height={FORMATS.og.height} />
</>
)
}

View File

@@ -0,0 +1,34 @@
// Loads the real Compass web faces so a render looks like the site rather than like the local
// system-font stack.
//
// The woff2 files are vendored under public/fonts (both families are SIL OFL) rather than fetched
// from Google at render time — theme.ts's rule is that a render must be reproducible offline, and a
// missing network would otherwise silently fall back to Georgia mid-render.
//
// Importing this module registers the faces and holds the render open until the browser reports
// them ready. Only scenes that need them should import it; the video scenes deliberately do not,
// so their renders stay font-fetch free.
import {cancelRender, continueRender, delayRender, staticFile} from 'remotion'
// Variable fonts: one file per family covers every weight, hence the `100 900` range.
const FACES: {family: string; file: string; style?: string}[] = [
{family: 'Newsreader', file: 'fonts/Newsreader-latin.woff2'},
{family: 'Newsreader', file: 'fonts/Newsreader-Italic-latin.woff2', style: 'italic'},
{family: 'DM Sans', file: 'fonts/DMSans-latin.woff2'},
{family: 'Cormorant Garamond', file: 'fonts/CormorantGaramond-500-latin.woff2'},
]
const handle = delayRender('Loading Compass brand fonts')
Promise.all(
FACES.map(async ({family, file, style}) => {
const face = new FontFace(family, `url(${staticFile(file)}) format('woff2')`, {
weight: '100 900',
style: style ?? 'normal',
})
document.fonts.add(await face.load())
}),
)
.then(() => continueRender(handle))
// Falling back silently would ship a card in the wrong typeface, which is worse than a red render.
.catch((err) => cancelRender(err))

View File

@@ -0,0 +1,264 @@
import React from 'react'
import {AbsoluteFill, Img, staticFile} from 'remotion'
import '../components/BrandFonts'
import {colors, fonts} from '../theme'
// The default social preview card — what WhatsApp, X, Slack, LinkedIn etc. show for a shared
// compassmeet.com link. 1200×630 (the 1.91:1 `summary_large_image` slot).
//
// Rendered as a still, uploaded to R2, and pulled into web/public/images at build time; the meta
// tags live in web/pages/_app.tsx. See the README for the full loop.
//
// Design notes, since a preview card is read in under a second at thumbnail size:
// - It deliberately shares the amber rules, warm cream canvas and serif voice of the per-profile
// card (web/pages/api/og/profile.tsx) so a shared profile and a shared home link look related.
// - No faces, no photo grid, no stacked cards — those read as a swiping app at a glance, which is
// the opposite of the pitch.
// - Copy is the home page's own (web/components/home/home.tsx), so the card cannot promise
// something the landing page does not say.
const EYEBROW = 'FREE DIRECTORY · NO ADS · NO ALGORITHMS'
const HEADLINE_TOP = "Don't Swipe."
const HEADLINE_BOTTOM = 'Search.'
const SUBHEAD = 'Find your people based on your values and interests.'
const DOMAIN = 'compassmeet.com'
const FOOTNOTE = 'Non-profit · Open source'
// Paper grain. Inline SVG turbulence rather than an image file: it costs no asset, and at 3.5%
// opacity it is what keeps the flat cream from looking like a screenshot of a blank div.
const GRAIN = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" /></filter>
<rect width="200" height="200" filter="url(#n)" />
</svg>`,
)}`
// A hairline amber rule, top and bottom — the same device the per-profile card uses.
const Rule: React.FC<{edge: 'top' | 'bottom'}> = ({edge}) => (
<div
style={{
position: 'absolute',
[edge]: 0,
left: 0,
right: 0,
height: 6,
background: `linear-gradient(90deg, ${colors.amberDeep} 0%, ${colors.amber} 45%, ${colors.amberBright} 100%)`,
}}
/>
)
// The compass rose inside concentric rings and bearing ticks: the brand mark doubling as the one
// piece of imagery, and a nod to orientation rather than romance.
const RoseDial: React.FC = () => {
const outer = 440
const middle = 372
const disc = 300
const ticks = 16
const ring = (size: number, color: string): React.CSSProperties => ({
position: 'absolute',
width: size,
height: size,
borderRadius: '50%',
border: `1px solid ${color}`,
})
return (
<div
style={{
position: 'absolute',
top: 56,
right: 44,
width: outer,
height: outer,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Warm glow, so the dial sits in light rather than on top of the canvas */}
<div
style={{
position: 'absolute',
width: outer + 160,
height: outer + 160,
borderRadius: '50%',
background: `radial-gradient(circle, ${colors.amberPale} 0%, rgba(243,228,206,0) 68%)`,
}}
/>
<div style={ring(outer, colors.canvas200)} />
<div style={ring(middle, colors.amberPale)} />
{/* Bearing ticks around the outer ring, N/E/S/W longer than the rest */}
{Array.from({length: ticks}).map((_, i) => {
const cardinal = i % 4 === 0
return (
<div
key={i}
style={{
position: 'absolute',
width: 1.5,
height: cardinal ? 18 : 9,
background: cardinal ? colors.amber : colors.canvas200,
transform: `rotate(${(360 / ticks) * i}deg) translateY(${-outer / 2 + 1}px)`,
transformOrigin: 'center center',
}}
/>
)
})}
<div
style={{
// Positioned on purpose: every sibling above is either absolute or transformed, which
// puts them in the positioned-painting layer. A static disc would paint underneath them
// and the glow would wash the logo out to a ghost.
position: 'relative',
width: disc,
height: disc,
borderRadius: '50%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: `0 24px 60px ${colors.amberDeep}22, 0 0 0 8px ${colors.creamGlow}`,
}}
>
<Img src={staticFile('logo.svg')} style={{width: disc, height: disc}} />
</div>
</div>
)
}
export const OgCard: React.FC = () => {
return (
<AbsoluteFill
style={{
background: `linear-gradient(145deg, ${colors.creamGlow} 0%, ${colors.cream} 52%, ${colors.canvas100} 100%)`,
}}
>
{/* Warm light from the upper right, where the dial sits */}
<AbsoluteFill
style={{
background: `radial-gradient(70% 90% at 82% 18%, ${colors.amberPale}88 0%, rgba(243,228,206,0) 70%)`,
}}
/>
{/* A cool counterweight from the logo's blue, low enough to read as shadow, not as color */}
<AbsoluteFill
style={{
background: `radial-gradient(55% 60% at 6% 96%, ${colors.logoBlue}14 0%, rgba(29,56,75,0) 72%)`,
}}
/>
<RoseDial />
<AbsoluteFill
style={{
padding: '74px 72px 64px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<div style={{display: 'flex', flexDirection: 'column', maxWidth: 660}}>
<div
style={{
fontFamily: fonts.body,
fontSize: 17,
fontWeight: 600,
letterSpacing: 2.6,
color: colors.amberEmber,
}}
>
{EYEBROW}
</div>
<div
style={{
fontFamily: fonts.heading,
fontSize: 92,
fontWeight: 600,
lineHeight: 1.03,
letterSpacing: -1.5,
color: colors.ink,
marginTop: 26,
}}
>
<div>{HEADLINE_TOP}</div>
<div style={{color: colors.amberDeep}}>{HEADLINE_BOTTOM}</div>
</div>
<div
style={{
fontFamily: fonts.heading,
fontSize: 33,
fontWeight: 400,
lineHeight: 1.38,
color: colors.ink600,
marginTop: 28,
// Tuned so the line breaks after "based on" into two balanced lines rather than
// leaving "interests." alone on the second.
maxWidth: 470,
}}
>
{SUBHEAD}
</div>
</div>
<div style={{display: 'flex', flexDirection: 'column'}}>
<div style={{height: 1, background: colors.canvas200, marginBottom: 22}} />
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{display: 'flex', alignItems: 'center', gap: 14}}>
<div style={{width: 9, height: 9, borderRadius: '50%', background: colors.amber}} />
<div
style={{
fontFamily: fonts.wordmark,
fontSize: 32,
fontWeight: 500,
letterSpacing: '0.03em',
color: colors.ink,
}}
>
{DOMAIN}
</div>
</div>
<div
style={{
fontFamily: fonts.body,
fontSize: 20,
fontWeight: 500,
color: colors.ink500,
}}
>
{FOOTNOTE}
</div>
</div>
</div>
</AbsoluteFill>
<AbsoluteFill
style={{
backgroundImage: `url("${GRAIN}")`,
opacity: 0.035,
mixBlendMode: 'multiply',
}}
/>
{/* Barely-there vignette: keeps the corners from glowing brighter than the headline */}
<AbsoluteFill
style={{
background:
'radial-gradient(85% 85% at 50% 45%, rgba(0,0,0,0) 58%, rgba(30,26,20,0.07) 100%)',
}}
/>
<Rule edge="top" />
<Rule edge="bottom" />
</AbsoluteFill>
)
}

View File

@@ -17,20 +17,37 @@ export const colors = {
// Accents pulled straight from the compass logo
logoBlue: '#1D384B',
logoRed: '#E94734',
// Light-surface ramp, matching the per-profile OG card (web/pages/api/og/profile.tsx)
canvas100: '#EDE8E0', // canvas-100 — page background
canvas200: '#E8D5BC', // canvas-200 — tag pills / warm dividers
ink600: '#786C5C', // ink-600 — secondary text
ink500: '#8C8070', // ink-500 — tertiary text
amberPale: '#F3E4CE', // primary-100
amberEmber: '#855022', // primary-700 — text-safe amber on cream
} as const
// System-font stack only — no network fetch at render time, so renders are reproducible offline.
export const fonts = {
display: '"Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
// The real web faces (see web/pages/_document.tsx and tailwind.config.js). Only usable in a
// scene that imports ./components/BrandFonts — that module vendors the woff2 files from
// public/fonts and blocks the render until they are ready. Without it these fall back.
heading: '"Newsreader", Georgia, serif', // h1h6 on the site
body: '"DM Sans", "Segoe UI", sans-serif', // body copy
wordmark: '"Cormorant Garamond", Georgia, serif', // the `.logo` wordmark
} as const
// Output formats. Same scenes, different canvas — layouts adapt via useVideoConfig().
// - post: 4:5 portrait, the tallest ratio Instagram allows in-feed (primary).
// - story: 9:16 vertical, full-screen Stories / Reels (secondary).
// - og: 1.91:1, the OpenGraph / Twitter `summary_large_image` card (a still, not a video).
export const FORMATS = {
post: {width: 1080, height: 1350, fps: 30},
story: {width: 1080, height: 1920, fps: 30},
og: {width: 1200, height: 630, fps: 30},
} as const
// Design reference height (the story format). Scenes are tuned against this;

View File

@@ -9,8 +9,21 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
url?: string
ogProps?: {props: P; endpoint: string}
image?: string
/** Only needed for an image that is not a 1200×630 PNG card — see the note on the tags below. */
imageType?: string
imageWidth?: string
imageHeight?: string
imageAlt?: string
}) {
const {title, description, url, image, ogProps} = props
const {
// The /api/og/* routes answer with PNG (that is what @vercel/og's ImageResponse emits) at the
// 1.91:1 card size, and that is the only image any caller passes today.
imageType = 'image/png',
imageWidth = '1200',
imageHeight = '630',
imageAlt,
} = props
const imageUrl =
image ?? (ogProps && buildOgUrl(removeUndefinedProps(ogProps.props) as any, ogProps.endpoint))
@@ -33,11 +46,25 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
/>
)}
{/*OG tags (WhatsApp, Facebook, etc.)*/}
{/*OG tags (WhatsApp, Facebook, etc.). Everything here overrides the site-wide defaults in
_app.tsx via the shared `key`s.*/}
<meta property="og:title" content={fullTitle} key="og-title" />
<meta property="og:description" content={description} key="og-description" />
{url && <meta property="og:url" content={absUrl} key="og-url" />}
{imageUrl && <meta property="og:image" content={imageUrl} key="og-image" />}
{imageUrl && (
<>
<meta property="og:image" content={imageUrl} key="og-image" />
<meta property="og:image:secure_url" content={imageUrl} key="og-image-secure-url" />
{/*Every image reaching this component is a 1200×630 card, so the dimensions are safe to
state — and stating them is what makes WhatsApp reliably pick the large layout. The
type has to be restated as well: _app.tsx's default says JPEG (its card is a .jpg),
and without this the two would disagree on pages that override the image.*/}
<meta property="og:image:type" content={imageType} key="og-image-type" />
<meta property="og:image:width" content={imageWidth} key="og-image-width" />
<meta property="og:image:height" content={imageHeight} key="og-image-height" />
<meta property="og:image:alt" content={imageAlt ?? title} key="og-image-alt" />
</>
)}
{/*Twitter/X tags — separate!*/}
<meta name="twitter:title" content={fullTitle} key="twitter-title" />

View File

@@ -8,7 +8,7 @@ import {StatusBar} from '@capacitor/status-bar'
import * as Sentry from '@sentry/node'
import clsx from 'clsx'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {IS_VERCEL, PNG_LOGO} from 'common/hosting/constants'
import {IS_VERCEL, OG_CARD} from 'common/hosting/constants'
import {debug} from 'common/logger'
import {isUrl} from 'common/parsing'
import type {AppProps} from 'next/app'
@@ -227,23 +227,34 @@ function MyApp(props: AppProps<PageProps>) {
<meta name="description" content={description} key="description" />
{/*OG tags (WhatsApp, Facebook, etc.)*/}
{/*OG tags (WhatsApp, Facebook, etc.). These are the site-wide default: any page that
renders <SEO> overrides them by `key`, and pages that don't (the home page among them)
are previewed with the card below.
Declaring width/height matters — several clients, WhatsApp in particular, are far more
reliable about rendering the large layout when they don't have to fetch the image to
learn its shape.*/}
<meta property="og:site_name" content="Compass" />
<meta property="og:type" content="website" key="og-type" />
<meta property="og:title" content={title} key="og-title" />
<meta property="og:description" content={description} key="og-description" />
<meta property="og:url" content={DEPLOYED_WEB_URL} key="og-url" />
<meta property="og:image" content={PNG_LOGO} key="og-image" />
<meta property="og:image:type" content="image/png" key="og-image-type" />
<meta property="og:image" content={OG_CARD.url} key="og-image" />
<meta property="og:image:secure_url" content={OG_CARD.url} key="og-image-secure-url" />
<meta property="og:image:type" content={OG_CARD.type} key="og-image-type" />
<meta property="og:image:width" content={OG_CARD.width} key="og-image-width" />
<meta property="og:image:height" content={OG_CARD.height} key="og-image-height" />
<meta property="og:image:alt" content={OG_CARD.alt} key="og-image-alt" />
{/*Twitter/X tags — separate!*/}
<meta name="twitter:title" content={title} key="twitter-title" />
<meta name="twitter:description" content={description} key="twitter-description" />
<meta name="twitter:card" content="summary" key="twitter-card" />
<meta name="twitter:image" content={PNG_LOGO} key="twitter-image" />
{/*`summary_large_image`, not `summary`: the latter renders the small square thumbnail,
which is what a 512×512 app icon used to force here.*/}
<meta name="twitter:card" content="summary_large_image" key="twitter-card" />
<meta name="twitter:image" content={OG_CARD.url} key="twitter-image" />
<meta name="twitter:image:alt" content={OG_CARD.alt} key="twitter-image-alt" />
{/*<meta name="twitter:site" content="@compassmeet"/>*/}
{/*<meta property="og:image:width" content="192" />*/}
{/*<meta property="og:image:height" content="192" />*/}
<meta
name="viewport"

View File

@@ -47,6 +47,10 @@ const ASSETS = [
{key: 'images/search-alert-poster-dark.jpg', dest: 'images/search-alert-poster-dark.jpg'},
{key: 'images/vote-tally-light.png', dest: 'images/vote-tally-light.png'},
{key: 'images/vote-tally-dark.png', dest: 'images/vote-tally-dark.png'},
// The default social preview card (see OG_CARD in common/hosting/constants.ts). The filename is
// versioned because WhatsApp and X cache a preview by URL and never revalidate it — a redesign
// ships as -v2 on both sides, it never overwrites -v1.
{key: 'images/og-card-v1.jpg', dest: 'images/og-card-v1.jpg'},
]
/**