From de99d2deccad4d54419eb5fc73716b095d32ccad Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 24 Jul 2026 18:34:18 +0200 Subject: [PATCH] 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 `` 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. --- common/src/hosting/constants.ts | 22 ++ media-creator/README.md | 50 +++- media-creator/package.json | 1 + media-creator/scripts/upload-media.sh | 5 + media-creator/src/Root.tsx | 7 +- media-creator/src/components/BrandFonts.ts | 34 +++ media-creator/src/scenes/OgCard.tsx | 264 +++++++++++++++++++++ media-creator/src/theme.ts | 17 ++ web/components/SEO.tsx | 31 ++- web/pages/_app.tsx | 27 ++- web/scripts/fetch-media.mjs | 4 + 11 files changed, 449 insertions(+), 13 deletions(-) create mode 100644 media-creator/src/components/BrandFonts.ts create mode 100644 media-creator/src/scenes/OgCard.tsx diff --git a/common/src/hosting/constants.ts b/common/src/hosting/constants.ts index 156573cc..05ac3b15 100644 --- a/common/src/hosting/constants.ts +++ b/common/src/hosting/constants.ts @@ -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) diff --git a/media-creator/README.md b/media-creator/README.md index df21306c..4c7d0127 100644 --- a/media-creator/README.md +++ b/media-creator/README.md @@ -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 ``, not a +``: 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 diff --git a/media-creator/package.json b/media-creator/package.json index 0dc047e4..74eeec81 100644 --- a/media-creator/package.json +++ b/media-creator/package.json @@ -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", diff --git a/media-creator/scripts/upload-media.sh b/media-creator/scripts/upload-media.sh index 5bba55b1..4973829f 100755 --- a/media-creator/scripts/upload-media.sh +++ b/media-creator/scripts/upload-media.sh @@ -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." diff --git a/media-creator/src/Root.tsx b/media-creator/src/Root.tsx index 3536b819..763b9e07 100644 --- a/media-creator/src/Root.tsx +++ b/media-creator/src/Root.tsx @@ -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 */} + ) } diff --git a/media-creator/src/components/BrandFonts.ts b/media-creator/src/components/BrandFonts.ts new file mode 100644 index 00000000..b0cb0480 --- /dev/null +++ b/media-creator/src/components/BrandFonts.ts @@ -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)) diff --git a/media-creator/src/scenes/OgCard.tsx b/media-creator/src/scenes/OgCard.tsx new file mode 100644 index 00000000..e0be86f0 --- /dev/null +++ b/media-creator/src/scenes/OgCard.tsx @@ -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( + ` + + + `, +)}` + +// A hairline amber rule, top and bottom — the same device the per-profile card uses. +const Rule: React.FC<{edge: 'top' | 'bottom'}> = ({edge}) => ( +
+) + +// 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 ( +
+ {/* Warm glow, so the dial sits in light rather than on top of the canvas */} +
+ +
+
+ + {/* 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 ( +
+ ) + })} + +
+ +
+
+ ) +} + +export const OgCard: React.FC = () => { + return ( + + {/* Warm light from the upper right, where the dial sits */} + + {/* A cool counterweight from the logo's blue, low enough to read as shadow, not as color */} + + + + + +
+
+ {EYEBROW} +
+ +
+
{HEADLINE_TOP}
+
{HEADLINE_BOTTOM}
+
+ +
+ {SUBHEAD} +
+
+ +
+
+
+
+
+
+ {DOMAIN} +
+
+
+ {FOOTNOTE} +
+
+
+ + + + {/* Barely-there vignette: keeps the corners from glowing brighter than the headline */} + + + + + + ) +} diff --git a/media-creator/src/theme.ts b/media-creator/src/theme.ts index 6db006cb..8dd27dd6 100644 --- a/media-creator/src/theme.ts +++ b/media-creator/src/theme.ts @@ -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', // h1–h6 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; diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 74ef6108..ef742d3a 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -9,8 +9,21 @@ export function SEO

>(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

>(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.*/} {url && } - {imageUrl && } + {imageUrl && ( + <> + + + {/*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.*/} + + + + + + )} {/*Twitter/X tags — separate!*/} diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 7cbe5ee7..bd449783 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -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) { - {/*OG tags (WhatsApp, Facebook, etc.)*/} + {/*OG tags (WhatsApp, Facebook, etc.). These are the site-wide default: any page that + renders 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.*/} + - - + + + + + + {/*Twitter/X tags — separate!*/} - - + {/*`summary_large_image`, not `summary`: the latter renders the small square thumbnail, + which is what a 512×512 app icon used to force here.*/} + + + {/**/} - {/**/} - {/**/}