diff --git a/.changeset/social-preview-card.md b/.changeset/social-preview-card.md new file mode 100644 index 000000000..f1ea8ba16 --- /dev/null +++ b/.changeset/social-preview-card.md @@ -0,0 +1,16 @@ +--- +'nostream-website': minor +--- + +Add a social preview card generated from source. + +`website/site/scripts/build-social-card.mjs` renders one composition at two sizes: +`public/media/social-card.png` (1200×630, the site's `og:image`) and +`public/media/github-social-preview.png` (1280×640, the GitHub repository preview). +`Base.astro` now emits the full preview set — `og:image:width`, `og:image:height`, +`og:image:alt`, `twitter:image`, `twitter:title`, `twitter:description`, +`og:site_name`, `og:locale` — and takes an `ogImage` prop that defaults to the card; +`hero-poster.webp` stays the film's poster. + +The GitHub preview is uploaded by hand: **Settings → Social preview** in the +repository, which no workflow can set. diff --git a/website/hero-animation/src/build-assets.ts b/website/hero-animation/src/build-assets.ts index 38bfff815..e43041d21 100644 --- a/website/hero-animation/src/build-assets.ts +++ b/website/hero-animation/src/build-assets.ts @@ -100,7 +100,7 @@ async function planet() { const rand = rng(7) const dots: string[] = [] - for (let i = 0; i < 5200; i++) { + for (let i = 0; i < 1800; i++) { const a = rand() * Math.PI * 2 const d = Math.sqrt(rand()) ** 0.55 * r const x = c + Math.cos(a) * d @@ -176,7 +176,7 @@ async function sparkles() { const c = S / 2 const rand = rng(11) const dots: string[] = [] - for (let i = 0; i < 1400; i++) { + for (let i = 0; i < 380; i++) { const a = rand() * Math.PI * 2 const d = 700 + rand() ** 1.6 * 280 const x = c + Math.cos(a) * d diff --git a/website/hero-animation/src/film.ts b/website/hero-animation/src/film.ts index 84b02e626..9922ee4b1 100644 --- a/website/hero-animation/src/film.ts +++ b/website/hero-animation/src/film.ts @@ -7,7 +7,6 @@ import { BODY, DURATION, FG, - FG_2, FONT_MONO, FONT_SANS, FPS, @@ -211,16 +210,17 @@ export async function buildFilm(): Promise { const planetSize = 1700 const haloSize = planetSize * (2000 / 1600) + const planetX = 1210 const planetY: Key[] = [ - [0, 1100], - [60, 520, 'power3.out'], - [HERO.end - 10, 505], - [WHAT.start + 25, 700, 'power2.inOut'], - [WHAT.end - 20, 710], - [FLOW.start + 20, 790, 'power2.inOut'], - [FEATURES.end - 20, 820], - [DEPLOY.start + 30, 500, 'power3.out'], - [LAST, 480], + [0, 1210], + [60, 640, 'power3.out'], + [HERO.end - 10, 625], + [WHAT.start + 25, 790, 'power2.inOut'], + [WHAT.end - 20, 800], + [FLOW.start + 20, 870, 'power2.inOut'], + [FEATURES.end - 20, 900], + [DEPLOY.start + 30, 580, 'power3.out'], + [LAST, 560], ] const planetOpacity: Key[] = [ [0, 0], @@ -235,7 +235,7 @@ export async function buildFilm(): Promise { add( Layer.image(asset('planet'), { position: 'absolute', - x: 1000, + x: planetX, y: 1100, width: planetSize, height: planetSize, @@ -250,7 +250,7 @@ export async function buildFilm(): Promise { add( Layer.image(asset('sparkles'), { position: 'absolute', - x: 1000 - haloOffset, + x: planetX - haloOffset, y: 1100 - haloOffset, width: haloSize, height: haloSize, @@ -365,43 +365,31 @@ export async function buildFilm(): Promise { light('nostream', 24, heroOut) sceneLabel('NOSTREAM · OPEN-SOURCE NOSTR RELAY', 196, 6, heroOut) const headline: [string, string][] = [ - ['Your relay.', FG], - ['Your rules.', FG_2], - ['Nostr, wired in.', PINK], + ['A social network', FG], + ['nobody can', PINK], + ['switch off.', PINK], ] headline.forEach(([text, fill], i) => { - const y = 228 + i * 112 + const y = 216 + i * 96 add( - sans(text, hx - 6, y, 108, { fill, letterSpacing: -4, fontWeight: 800 }), + sans(text, hx - 6, y, 92, { fill, letterSpacing: -4, fontWeight: 800 }), show(y, { start: 12 + i * 9, end: heroOut, dy: 40, inF: 18 }), ) }) - const bodyY1 = 606 - const bodyY2 = 640 + const bodyY1 = 540 + const bodyY2 = 574 add( - mono('A production-ready Nostr relay, written in TypeScript.', hx, bodyY1, 21, { fill: BODY }), + mono('Own your posts, your followers and your reach.', hx, bodyY1, 21, { fill: BODY }), show(bodyY1, { start: 42, end: heroOut, dy: 14 }), ) - const prefix = 'Your server. Your data. No ' - const struck = 'middleman' - const strikeX = hx + monoWidth(prefix, 21) - add(mono(prefix, hx, bodyY2, 21, { fill: BODY }), show(bodyY2, { start: 48, end: heroOut, dy: 14 })) - add(mono(struck, strikeX, bodyY2, 21, { fill: MUTED }), show(bodyY2, { start: 48, end: heroOut, dy: 14 })) add( - mono('.', strikeX + monoWidth(struck, 21), bodyY2, 21, { fill: BODY }), + mono('A production-ready Nostr relay, written in TypeScript.', hx, bodyY2, 21, { fill: BODY }), show(bodyY2, { start: 48, end: heroOut, dy: 14 }), ) - add( - rect(strikeX - 2, bodyY2 + 14, 0, 2, { background: PINK }), - show(bodyY2 + 14, { start: 66, end: heroOut }).keys('width', [ - [66, 0], - [80, monoWidth(struck, 21) + 4, 'power2.out'], - ]), - ) - tabs(708, 62, heroOut) - const heroTerm = { y: 744, w: 780 } + tabs(628, 62, heroOut) + const heroTerm = { y: 664, w: 780 } add( rect(hx, heroTerm.y, heroTerm.w, 70, { background: PANEL, @@ -438,12 +426,11 @@ export async function buildFilm(): Promise { copyButton(hx + heroTerm.w - 18, heroTerm.y + 18, 74, heroOut) const stats: [string, string][] = [ - [NIP_COUNT, 'NIPS SUPPORTED'], - ['6', 'LIGHTNING PROCESSORS'], - ['MIT', 'OPEN SOURCE'], + ['Nostr + Marmot', 'PROTOCOL SUPPORT'], + ['MIT', 'LICENSE'], ] stats.forEach(([value, label], i) => { - const x = hx + i * 270 + const x = hx + i * 460 const s = 96 + i * 8 add(rect(x, 868, 1, 62, { background: LINE }), show(868, { start: s, end: heroOut })) add( @@ -483,7 +470,7 @@ export async function buildFilm(): Promise { num: '03', title: 'nostream', body: 'A production-ready relay\nin TypeScript that you\ndeploy and control.', - tag: 'self-hosted · open source', + tag: 'self-hosted · MIT license', }, ] const wc = { y: 430, w: 440, h: 330, gap: 140 } diff --git a/website/site/astro.config.mjs b/website/site/astro.config.mjs index 36d2b48e4..32b11d572 100644 --- a/website/site/astro.config.mjs +++ b/website/site/astro.config.mjs @@ -49,7 +49,7 @@ function rehypeRepoLinks() { } export default defineConfig({ - site: process.env.SITE_URL ?? 'https://nostream.dev', + site: process.env.SITE_URL ?? 'https://ema.tnsor.network', // PR previews build under /preview/; empty when unset (production). base: process.env.SITE_BASE || undefined, markdown: { diff --git a/website/site/package.json b/website/site/package.json index 15db1d6e1..8257aea2f 100644 --- a/website/site/package.json +++ b/website/site/package.json @@ -3,7 +3,7 @@ "version": "1.2.5", "private": true, "type": "module", - "description": "nostream.dev landing page and docs, built with Astro", + "description": "ema.tnsor.network landing page and docs, built with Astro", "packageManager": "pnpm@10.33.0", "scripts": { "dev": "astro dev", @@ -12,7 +12,8 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "media": "node scripts/build-media.mjs" + "media": "node scripts/build-media.mjs && node scripts/build-social-card.mjs", + "social": "node scripts/build-social-card.mjs" }, "engines": { "node": ">=20.3" diff --git a/website/site/public/apple-touch-icon.webp b/website/site/public/apple-touch-icon.webp index a8b094d0a..e595a1bf3 100644 Binary files a/website/site/public/apple-touch-icon.webp and b/website/site/public/apple-touch-icon.webp differ diff --git a/website/site/public/favicon.webp b/website/site/public/favicon.webp index 92235cf43..38a1c8103 100644 Binary files a/website/site/public/favicon.webp and b/website/site/public/favicon.webp differ diff --git a/website/site/public/media/github-social-preview.png b/website/site/public/media/github-social-preview.png new file mode 100644 index 000000000..95264b11d Binary files /dev/null and b/website/site/public/media/github-social-preview.png differ diff --git a/website/site/public/media/hero-poster.webp b/website/site/public/media/hero-poster.webp index d4e90bbb0..cfdb1f35c 100644 Binary files a/website/site/public/media/hero-poster.webp and b/website/site/public/media/hero-poster.webp differ diff --git a/website/site/public/media/social-card.png b/website/site/public/media/social-card.png new file mode 100644 index 000000000..e5bb8a58b Binary files /dev/null and b/website/site/public/media/social-card.png differ diff --git a/website/site/scripts/build-social-card.mjs b/website/site/scripts/build-social-card.mjs new file mode 100644 index 000000000..2bc636af3 --- /dev/null +++ b/website/site/scripts/build-social-card.mjs @@ -0,0 +1,193 @@ +// Builds the link-preview cards: one composition, two sizes, from the brand mark. +// +// public/media/social-card.png 1200×630 — the site's og:image +// public/media/github-social-preview.png 1280×640 — the repository social preview +// +// Both are the same drawing scaled: the wide card is the GitHub one at 1200/1280, so a +// change to the composition lands in both. Nothing is cropped or re-laid-out per size. +// +// Text is drawn as SVG rendered by sharp's librsvg over real woff2 font files, not by a +// system font lookup — a CI runner has no Inter installed, and a fallback face would +// silently change the card. The font files are the ones the site imports +// (@fontsource-variable/inter @fontsource/jetbrains-mono), so the card cannot drift from +// the page's typography. +// +// The backdrop is the page's own: the pink planet at public/media/planet.webp and the 28px +// dot grid from global.css. The home hero's spark stream is deliberately left out — a +// still frame cannot animate, so the particles land under the copy as noise instead of +// drift past it. +// +// Run with the site's other media: `pnpm run media`, or alone with `pnpm run social`. +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import sharp from 'sharp' + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const LOGO = path.join(ROOT, 'src', 'assets', 'logo.png') +const PLANET = path.join(ROOT, 'public', 'media', 'planet.webp') +const MEDIA = path.join(ROOT, 'public', 'media') +const SANS = path.join(ROOT, 'node_modules', '@fontsource-variable', 'inter', 'files', 'inter-latin-wght-normal.woff2') +const SANS_ITALIC = path.join(ROOT, 'node_modules', '@fontsource-variable', 'inter', 'files', 'inter-latin-wght-italic.woff2') +const MONO = path.join(ROOT, 'node_modules', '@fontsource', 'jetbrains-mono', 'files', 'jetbrains-mono-latin-400-normal.woff2') + +// Copied from src/styles/global.css; a card that disagrees with the page is the whole +// defect this file exists to fix. +const BG = '#08080a' +const FG = '#f7f7f7' +const FG2 = '#a8a8a8' +const PINK = '#ff2e88' +const MUTED = '#808080' +const LINE = '#26262a' +const SANS_STACK = 'Card' +const MONO_STACK = 'CardMono' + +const MARK_TOP = 96 + +const W = 1280 +const H = 640 + +// Layout: the mark sits on the left at the size a card preview actually renders it. +const MARK = 96 +const MARK_X = 84 +const WORD_X = MARK_X + MARK + 28 +const TEXT_X = 84 +const TEXT_RIGHT = W - 84 + +const escape = (text) => text.replace(/&/g, '&').replace(//g, '>') + +/** Font faces resolved from the packages, never from the system. */ +const fontFaces = () => { + for (const f of [SANS, MONO]) { + if (!existsSync(f)) throw new Error(`build-social-card: missing ${f} — run pnpm install in website/site`) + } + const italic = existsSync(SANS_ITALIC) ? `@font-face{font-family:'Card Sans';font-style:italic;src:url('file://${SANS_ITALIC}') format('woff2');font-weight:100 900;font-display:block}` : '' + return [ + ``, + ].join('') +} + +/** The 28px dot grid from global.css, faded toward the bottom (body::before). */ +const dots = (w, h) => { + const step = 28 + const cells = [] + for (let x = step / 2; x < w; x += step) { + for (let y = step / 2; y < h; y += step) { + // Ellipse 120% 80% at 50% 0% — near-opaque at the top, transparent by the bottom. + const dx = (x - w / 2) / (w * 0.6) + const dy = y / (h * 0.8) + const falloff = Math.max(0, 1 - Math.sqrt(dx * dx + dy * dy)) + if (falloff <= 0.04) continue + cells.push(``) + } + } + return cells.join('') +} + +/** The planet, cropped to its disc and faded by a radial mask, as PlanetBackdrop does. */ +const planet = async (width) => { + if (!existsSync(PLANET)) throw new Error(`build-social-card: missing ${PLANET}`) + // The asset is 1600×1600 with the disc inset ~5% on each side; crop to the disc so the + // mask lands on the rim rather than in the padding. + const disc = Math.round(1600 * 0.88) + const offset = Math.round((1600 - disc) / 2) + const size = Math.round(width) + const base = await sharp(PLANET) + .extract({ left: offset, top: offset, width: disc, height: disc }) + .resize(size, size, { fit: 'fill' }) + .toBuffer() + const mask = Buffer.from( + ``, + ) + return sharp(base) + .ensureAlpha() + .composite([{ input: mask, blend: 'dest-in' }]) + .png() + .toBuffer() +} + +const composition = (w, h) => ` + + + ${dots(w, h)} + +` + +const text = (w, h) => ` + + ${fontFaces()} + + nostream + + A social network + nobody can switch off. + + Own your posts, your followers and your reach + + + Nostr & Marmot protocol support + MIT licensed + + ema.tnsor.network + +` + +const mark = async (px) => { + if (!existsSync(LOGO)) throw new Error(`build-social-card: missing ${LOGO}`) + // The mark is cropped to its pink bar: as a favicon-scale drawing, its bar is the part + // that reads at card size and the glyph beside the wordmark would repeat the wordmark. + const meta = await sharp(LOGO).metadata() + return sharp(LOGO) + .resize(px, px, { fit: 'contain', background: { r: 8, g: 8, b: 10, alpha: 1 } }) + .png() + .toBuffer() + .then((buf) => ({ buf, meta })) +} + +const build = async (width, height, filename) => { + const base = await sharp(Buffer.from(composition(width, height))).png().toBuffer() + const { buf: logoBuf } = await mark(MARK) + const planetBuf = await planet(600) + const card = await sharp(base) + .composite([ + // Planet low and right, behind the empty half of the card: the copy runs down the + // left, so nothing pink is ever read over it. + { input: planetBuf, left: W - 470, top: H - 440 }, + { input: logoBuf, left: MARK_X, top: MARK_TOP }, + { input: Buffer.from(text(width, height)) }, + ]) + .png({ compressionLevel: 9 }) + .toBuffer() + // Each card is DRAWN at its own size rather than scaled: 1200x630 (1.905:1) for the + // og:image, 1280x640 (2:1) for GitHub. Resizing one into the other stretches the + // letterforms by ~4.7% vertically relative to their width, which shows in the headline. + const out = path.join(MEDIA, filename) + await sharp(card).png({ compressionLevel: 9 }).toFile(out) + const written = await sharp(out).metadata() + if (written.width !== width || written.height !== height) { + throw new Error(`build-social-card: ${filename} came out ${written.width}x${written.height}, expected ${width}x${height}`) + } + return out +} + +mkdirSync(MEDIA, { recursive: true }) + +// Both cards are drawn at their target size. The text layer keeps its 1280x640 design +// coordinates and scales uniformly into the smaller card, so nothing is stretched — only +// the canvas loses 10px of height and 80px of width. +const wide = await build(1200, 630, 'social-card.png') +const github = await build(1280, 640, 'github-social-preview.png') +console.log(`social card → ${path.relative(ROOT, wide)} (1200×630)`) +console.log(`github social preview → ${path.relative(ROOT, github)} (1280×640)`) diff --git a/website/site/src/components/home/Hero.astro b/website/site/src/components/home/Hero.astro index 2b088e3cd..f8bb7944d 100644 --- a/website/site/src/components/home/Hero.astro +++ b/website/site/src/components/home/Hero.astro @@ -3,7 +3,7 @@ import FilmFrame from '../FilmFrame.astro' import { VERSION } from '../../data/site' --- -
+

Nostr runs on relays. nostream is the one you own. @@ -11,13 +11,6 @@ import { VERSION } from '../../data/site'

A production-ready Nostr relay you deploy and control — self-hosted and open source.

- @@ -29,11 +40,21 @@ const ogImage = new URL('/media/hero-poster.webp', Astro.site) + + - + + + {isCard && } + {isCard && } + {isCard && } + + + + diff --git a/website/site/src/scripts/spark-field.ts b/website/site/src/scripts/spark-field.ts index 8ed4ec619..d95191782 100644 --- a/website/site/src/scripts/spark-field.ts +++ b/website/site/src/scripts/spark-field.ts @@ -1,6 +1,7 @@ /** - * Silver dot field rising from the pink planet (CTA and film-stage sections), - * or a left-to-right stream when the host sets data-spark-mode="stream" (home hero). + * Silver dot field rising from the pink planet (CTA and deploy sections). + * The old left-to-right stream mode is gone: the home hero used it and it made + * the headline unreadable (measured 2026-10-18, Cameri's call). */ export function initSparkFields() { document.querySelectorAll('[data-spark-host]').forEach((section) => { @@ -15,7 +16,6 @@ export function initSparkFields() { const maxPool = Number(canvas.dataset.maxPool || 260) const spawnChance = Number(canvas.dataset.spawnChance || 0.85) - const stream = section.dataset.sparkMode === 'stream' const perFrame = Number(canvas.dataset.perFrame || 1) let w = 0 @@ -29,7 +29,6 @@ export function initSparkFields() { vy: number life: number max: number - phase: number bright: boolean } const pool: P[] = [] @@ -46,31 +45,7 @@ export function initSparkFields() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0) } - const leftStrip = () => Math.min(72, w * 0.085) - - const spawnStream = (edge = false) => { - const band = (Math.random() + Math.random() + Math.random()) / 3 - const speed = edge ? 0.25 + Math.random() * 0.95 : 0.9 + Math.random() * 2.5 - const strip = leftStrip() - pool.push({ - x: edge ? Math.random() * strip : -2 - Math.random() * 12, - y: h * (0.08 + band * 0.84), - vx: speed, - vy: (Math.random() - 0.5) * (edge ? 0.12 : 0.18), - life: 0, - max: edge - ? 140 + Math.random() * 220 - : (w * (0.5 + Math.random() * 0.45)) / speed, - phase: Math.random() * Math.PI * 2, - bright: Math.random() < (edge ? 0.62 : 0.48), - }) - } - const spawn = () => { - if (stream) { - spawnStream(Math.random() < 0.62) - return - } const spread = w * (section.dataset.sparkSpread ? Number(section.dataset.sparkSpread) : 0.48) const yBase = h * (section.dataset.sparkOrigin ? Number(section.dataset.sparkOrigin) : 0.9) pool.push({ @@ -80,7 +55,6 @@ export function initSparkFields() { vy: -(0.5 + Math.random() * 1.2), life: 0, max: 80 + Math.random() * 120, - phase: 0, bright: Math.random() < 0.2, }) } @@ -91,37 +65,19 @@ export function initSparkFields() { for (let n = 0; n < perFrame; n++) { if (pool.length < maxPool && Math.random() < spawnChance) spawn() } - if (stream && pool.length < maxPool) { - if (Math.random() < 0.82) spawn() - for (let k = 0; k < 4; k++) { - if (pool.length < maxPool && Math.random() < 0.78) spawnStream(true) - } - } for (let i = pool.length - 1; i >= 0; i--) { const p = pool[i] p.life++ - if (stream) { - p.x += p.vx - p.y += p.vy + Math.sin(p.life * 0.04 + p.phase) * 0.22 - } else { - p.x += p.vx + Math.sin(p.life * 0.07 + i) * 0.12 - p.y += p.vy - p.vy -= 0.004 - } + p.x += p.vx + Math.sin(p.life * 0.07 + i) * 0.12 + p.y += p.vy + p.vy -= 0.004 const t = p.life / p.max - let alpha = t < 0.08 ? t / 0.08 : t > 0.78 ? (1 - t) / 0.22 : 1 - if (stream) { - alpha *= 0.68 + 0.42 * Math.abs(Math.sin(p.life * 0.13 + p.phase)) - if (p.x < leftStrip()) alpha = Math.min(1, alpha * 1.18) - } + const alpha = t < 0.08 ? t / 0.08 : t > 0.78 ? (1 - t) / 0.22 : 1 if (p.bright) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, alpha)})` - } else if (stream) { - const s = 246 + (i % 5) - ctx.fillStyle = `rgba(${s},${s + 1},255,${Math.min(1, alpha * 0.98)})` } else { const silver = 175 + (i % 5) * 8 ctx.fillStyle = `rgba(${silver},${silver + 6},${silver + 14},${0.92 * alpha})` @@ -129,7 +85,7 @@ export function initSparkFields() { ctx.fillRect(Math.round(p.x), Math.round(p.y), 1, 1) - if (p.life >= p.max || p.y < -20 || p.x > w + 10) pool.splice(i, 1) + if (p.life >= p.max || p.y < -20) pool.splice(i, 1) } raf = requestAnimationFrame(tick)