diff --git a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
index a2fca0b2e2..6cebe119f6 100644
--- a/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
+++ b/apps/docs/app/[lang]/blog/[[...slug]]/page.tsx
@@ -4,7 +4,7 @@ import { blog } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
-import { absoluteUrl } from '@/lib/site';
+import { absoluteUrl, HERO_COVER } from '@/lib/site';
import {
compact,
JsonLd,
@@ -78,7 +78,7 @@ function postGraph(url: string, data: BlogPostData): JsonLdNode[] {
url: canonical,
mainEntityOfPage: canonical,
inLanguage: 'en',
- image: absoluteUrl(BLOG_CARD.url),
+ image: absoluteUrl(HERO_COVER.url),
datePublished: isoDate(data.date),
dateModified: sitemapLastModified(url),
keywords: data.tags,
@@ -251,29 +251,25 @@ export async function generateStaticParams() {
}));
}
+const BLOG_INDEX_TITLE = 'Blog';
+const BLOG_INDEX_DESCRIPTION =
+ 'Insights, updates, and best practices from the ObjectStack team.';
+
/**
- * The blog's social card.
+ * Metadata for the blog index and for every post.
+ *
+ * The social card in both branches is the shared hero cover, `HERO_COVER` from
+ * `lib/site.ts` — the same image the homepage presents itself with, and the same
+ * one `postGraph()` above puts in the `BlogPosting` node. It used to be a literal
+ * spelled here and again on the homepage; one declaration now, so a re-encode
+ * cannot leave half the site pointing at a file that no longer exists.
*
* ⚠️ The card generator at `app/og/docs/[...slug]/route.tsx` is docs-only: it
* renders from `source` (the `content/docs` loader) and has no branch for `blog`,
* so there is no per-post card to reference and this card is shared by the index
* and every post. Giving posts their own generated cards is a separate decision,
* not a gap in this wiring — it would mean a second `app/og/**` route.
- *
- * ⚠️ Same path as `apps/docs/app/[lang]/page.tsx`'s `HOME_CARD`; that file
- * carries the note on why it is spelled twice and what must change together.
*/
-const BLOG_CARD = {
- url: '/hero-cover-dark.png',
- width: 2400,
- height: 1200,
- alt: 'ObjectStack — the metadata framework for AI-written apps',
-};
-
-const BLOG_INDEX_TITLE = 'Blog';
-const BLOG_INDEX_DESCRIPTION =
- 'Insights, updates, and best practices from the ObjectStack team.';
-
export async function generateMetadata({
params,
}: {
@@ -296,13 +292,13 @@ export async function generateMetadata({
title: BLOG_INDEX_TITLE,
description: BLOG_INDEX_DESCRIPTION,
url: canonical,
- images: [BLOG_CARD],
+ images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: BLOG_INDEX_TITLE,
description: BLOG_INDEX_DESCRIPTION,
- images: [BLOG_CARD.url],
+ images: [HERO_COVER.url],
},
};
}
@@ -324,13 +320,13 @@ export async function generateMetadata({
title: page.data.title,
description: page.data.description,
url: canonical,
- images: [BLOG_CARD],
+ images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: page.data.title,
description: page.data.description,
- images: [BLOG_CARD.url],
+ images: [HERO_COVER.url],
},
};
}
diff --git a/apps/docs/app/[lang]/page.tsx b/apps/docs/app/[lang]/page.tsx
index 44c8d4d93a..6027180249 100644
--- a/apps/docs/app/[lang]/page.tsx
+++ b/apps/docs/app/[lang]/page.tsx
@@ -4,7 +4,7 @@ import { ArrowRight, Check } from 'lucide-react';
import { Bricolage_Grotesque, IBM_Plex_Mono } from 'next/font/google';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions, gitConfig } from '@/lib/layout.shared';
-import { absoluteUrl } from '@/lib/site';
+import { absoluteUrl, HERO_COVER } from '@/lib/site';
import {
APACHE_2_0_URL,
GITHUB_REPO_URL,
@@ -36,7 +36,8 @@ const HOME_DESCRIPTION =
'ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.';
/**
- * The homepage's social card.
+ * The homepage's metadata. Its social card is the shared hero cover,
+ * `HERO_COVER` from `lib/site.ts` — the same image the video poster below uses.
*
* ⚠️ Deliberately NOT the docs card generator. `app/og/docs/[...slug]/route.tsx`
* renders from a `source.getPage()` result, and the homepage has no MDX file
@@ -44,22 +45,13 @@ const HOME_DESCRIPTION =
* which is already shipped and already the video poster on this page, so the
* shared card costs no extra bytes and no extra route.
*
- * ⚠️ `apps/docs/app/[lang]/blog/[[...slug]]/page.tsx` spells this same path for
- * the blog's card. Two spellings rather than one shared constant because
- * `lib/site.ts` is the origin's home, not the asset manifest's; if a third page
- * ever needs it, hoist it there. Anything that renames, re-encodes or deletes
- * `public/hero-cover-dark.png` must update BOTH — an `og:image` that 404s is
- * worse than none, because crawlers then scrape whatever else the page offers.
+ * The path used to be spelled as a literal here, again in the blog's card, and a
+ * third time as the `poster` further down this file — with a note asking whoever
+ * added a third consumer to hoist it. That happened; it lives in `lib/site.ts`
+ * now, and the reasons a re-encode has to be careful live with it.
*
* Left site-relative: `metadataBase` in `app/layout.tsx` absolutises it.
*/
-const HOME_CARD = {
- url: '/hero-cover-dark.png',
- width: 2400,
- height: 1200,
- alt: 'ObjectStack — the metadata framework for AI-written apps',
-};
-
export const metadata: Metadata = {
title: HOME_TITLE,
description: HOME_DESCRIPTION,
@@ -77,13 +69,13 @@ export const metadata: Metadata = {
// Same absolute URL as the canonical link — see the docs route for why the
// two must not drift.
url: absoluteUrl('/'),
- images: [HOME_CARD],
+ images: [HERO_COVER],
},
twitter: {
card: 'summary_large_image',
title: HOME_TITLE,
description: HOME_DESCRIPTION,
- images: [HOME_CARD.url],
+ images: [HERO_COVER.url],
},
};
@@ -104,7 +96,7 @@ export const metadata: Metadata = {
*
* Every field is drawn from something already on this page or in `lib/`: the
* title and description are the same constants `metadata` uses, the image is
- * `HOME_CARD`, the licence is the repo's own, and the two `sameAs` links are the
+ * `HERO_COVER`, the licence is the repo's own, and the two `sameAs` links are the
* GitHub organisation and the YouTube channel this page links to in its hero.
*/
function homeGraph(): JsonLdNode[] {
@@ -119,7 +111,7 @@ function homeGraph(): JsonLdNode[] {
// itself one way to a crawler and another way to a social card.
headline: HOME_TITLE,
description: HOME_DESCRIPTION,
- image: absoluteUrl(HOME_CARD.url),
+ image: absoluteUrl(HERO_COVER.url),
codeRepository: GITHUB_REPO_URL,
programmingLanguage: 'TypeScript',
runtimePlatform: 'Node.js',
@@ -279,7 +271,7 @@ export default function HomePage() {
`: nothing is requested from YouTube until the
* viewer actually clicks play, so the homepage costs no third-party frame,
* script or cookie on load. The cover is the same one the README links from
- * (`docs/screenshots/hero-cover-dark.png`, copied into `public/`), which is 2:1
- * rather than the video's 16:9 — hence the fixed 2:1 frame with the player
- * centred inside it at its own aspect ratio. Cropping the cover to 16:9 would
- * cut through the logo on the left edge, and letting the frame change shape on
- * click would shove the rest of the page down mid-interaction.
+ * (`docs/screenshots/hero-cover-dark.png`, re-encoded to WebP into `public/` —
+ * `HERO_COVER` in `lib/site.ts` owns the path and the re-encode recipe), which
+ * is 2:1 rather than the video's 16:9 — hence the fixed 2:1 frame with the
+ * player centred inside it at its own aspect ratio. Cropping the cover to 16:9
+ * would cut through the logo on the left edge, and letting the frame change
+ * shape on click would shove the rest of the page down mid-interaction.
*/
export function YouTubeEmbed({ videoId, title, poster }: YouTubeEmbedProps) {
const [playing, setPlaying] = useState(false);
diff --git a/apps/docs/lib/site.ts b/apps/docs/lib/site.ts
index 6b55ff4672..f90e05b85e 100644
--- a/apps/docs/lib/site.ts
+++ b/apps/docs/lib/site.ts
@@ -1,5 +1,6 @@
/**
- * Canonical identity of the documentation site.
+ * Canonical identity of the documentation site — the origin it names itself by,
+ * and the shared cover asset it presents itself with.
*
* The origin is a maintainer ruling, not configuration. Every absolute URL this
* site emits — sitemap entries, the `Sitemap:` line in `robots.txt`, and (as the
@@ -36,3 +37,51 @@ export function absoluteUrl(path: string): string {
return new URL(path, SITE_ORIGIN).toString();
}
+
+/**
+ * The hero cover — the one image this site presents itself with.
+ *
+ * Three consumers, one declaration: the homepage's click-to-play video poster
+ * (`app/[lang]/page.tsx`), the homepage's Open Graph / Twitter card and JSON-LD
+ * `image` (same file), and the blog's card for the index and every post
+ * (`app/[lang]/blog/[[...slug]]/page.tsx`). Before this constant the path was
+ * spelled as a literal in all three, and two of them do not read as image
+ * references at all — they read as SEO config — so a re-encode had to find them
+ * by memory. It is declared here, next to `SITE_ORIGIN`, because the cover is
+ * part of the site's identity in the same way the origin is: one asset, named
+ * once, that every self-description reads.
+ *
+ * ⚠️ `width` / `height` are emitted as `og:image:width` / `og:image:height`. A
+ * declared size that disagrees with the bytes is worse than no declaration — a
+ * crawler reserves the wrong box and some renderers reject the card outright —
+ * so anything that changes the asset's pixel dimensions must change these in the
+ * same commit. They are correct only because the re-encode below deliberately
+ * kept 2400x1200.
+ *
+ * ## Provenance — how to re-encode it
+ *
+ * The master is `docs/screenshots/hero-cover-dark.png` (2400x1200, 406,703 B),
+ * which the README embeds directly from the repo and which is NOT served by this
+ * site. This file is a lossy WebP derived from that master, and the derivation is
+ * the whole reason it is small enough to sit above the fold:
+ *
+ * sharp('docs/screenshots/hero-cover-dark.png')
+ * .webp({ quality: 80, effort: 6 }) // sharp 0.35.3 / libwebp 1.6.0
+ * .toFile('apps/docs/public/hero-cover-dark.webp')
+ *
+ * 83,272 B — 20.5% of the PNG. Re-encode from the master, never from this file:
+ * a lossy re-encode of a lossy source compounds. And do not "restore" the PNG
+ * alongside it — a `public/` holding both is how the next re-encode picks the
+ * wrong one.
+ *
+ * Format is a constraint, not a preference. It is a plain `
` with no
+ * `` fallback and it is also the og:image, so it must be a format every
+ * browser AND every unfurler decodes. WebP is; AVIF is measurably better per byte
+ * on this image and was rejected because it is not.
+ */
+export const HERO_COVER = {
+ url: '/hero-cover-dark.webp',
+ width: 2400,
+ height: 1200,
+ alt: 'ObjectStack — the metadata framework for AI-written apps',
+} as const;
diff --git a/apps/docs/public/hero-cover-dark.png b/apps/docs/public/hero-cover-dark.png
deleted file mode 100644
index d8322c8392..0000000000
Binary files a/apps/docs/public/hero-cover-dark.png and /dev/null differ
diff --git a/apps/docs/public/hero-cover-dark.webp b/apps/docs/public/hero-cover-dark.webp
new file mode 100644
index 0000000000..7d9ae2f406
Binary files /dev/null and b/apps/docs/public/hero-cover-dark.webp differ