feat(hero): shader hero backgrounds + site-wide Open Graph images - #44
Merged
Conversation
Shared field group (media vs. shader discriminator) for Pages/BlogPosts' hero backgrounds. shaderPresets.ts stays import-light (no ogl, no shader components) so it's safe to import from the field config module, which payload generate:types runs under plain Node. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Drawer-based picker with 4 live-rendering shader preset cards, modeled on src/fields/Icon/Field.tsx's structure (useField/useModal/useDrawerSlug + Drawer, pick-and-close). Dynamically imported with ssr:false throughout since the shader components touch window outside any effect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
FieldComponent.tsx never checked readOnly, unlike its structural precedent (src/fields/Icon/Field.tsx), which disables the trigger button and omits the drawer entirely in read-only contexts (versions/ diff views, access-controlled fields, locked documents). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
HeroMedia now takes the whole hero.background group value instead of a pre-normalized media array, so it can branch between media (image/video/ carousel) and a live shader preset. Callers still passing the old media prop will fail typecheck until Tasks 5-6 update them - expected, not a regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…d prop A plan gap found during Task 4's review — this standalone caller (driven by SiteSettings' errorHero global field, not Pages/BlogPosts) was missed by every task's file list. No shader option added here, per the spec's non-goal restricting shader support to Pages/BlogPosts — just updates the call site to HeroMedia's new background prop shape. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Pages now uses the shared HeroBackgroundField, enabling shader backgrounds. hero.media -> hero.background.media; listViewThumbnailPath and the frontend page renderer updated to match. seedPages writes the new shape (media-only for now — Task 13 adds shader variety). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
BlogPosts now uses the shared HeroBackgroundField (single-image, not a carousel — hasManyMedia: false), enabling shader backgrounds. Updates the post detail page's HeroMedia call, PostCard's list-view thumbnail (shader posts get a static gradient fallback instead of an empty card), and seedPosts' write shape. Also updates TrendingBlogPostsBlock's Renderer, which read the old heroImage field directly, and regenerates src/types/payload.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…ject media
hero.background.media is hasMany:false for BlogPosts (unlike Pages,
which is hasMany:true and correctly indexes .media.0) — the '.0' suffix
meant CellWithThumbnail's lodash get() always resolved to undefined,
leaving every BlogPost row's admin thumbnail blank regardless of
whether the post had a hero image.
BlogPosts' media field is also a polymorphic upload relation
(relationTo: [MediaImages, MediaVideos]), so Payload/resolveRelations
represent it as a { relationTo, value } wrapper even when hasMany is
false — the actual media document (with .url/.thumbnailURL) lives at
.value, not on the wrapper itself. Verified empirically against a
seeded post's resolved document: hero.background.media resolves to
undefined via CellWithThumbnail's get() logic, while
hero.background.media.value resolves to the real thumbnail URL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…edia relation Same latent bug as BlogPosts' (fixed in 0ba8a9f), found while re-reviewing that fix: Pages' hero.background.media is hasMany:true, so the array's first entry (.media.0) is itself a {relationTo, value} polymorphic upload wrapper, not the resolved media document. The .0 index alone never reached .url/.thumbnailURL, leaving every Page row's admin thumbnail blank. Fixed to .media.0.value, verified against a real seeded page: the old path resolves to undefined, the new path resolves to the real thumbnail URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Replaces the unfinished placeholder (hardcoded fake copy, stock photo, next/og) with real site name/description via the same cached fetchers generateMeta already uses, rendered through takumi-js/response — a drop-in ImageResponse replacement with native Tailwind v4 support. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Media-backed heroes render the real image; shader-backed heroes render a static CSS gradient approximation (Takumi/Satori cannot execute WebGL). Reuses [slug]/page.tsx's own cached query rather than a second fetch path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Same media/shader-aware rendering as Pages' OG image. JSON-LD's image field now points at this route uniformly, working for both media- and shader-backed posts instead of trying to resolve a raw asset URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Simple title-only design — BlogTopics has no hero.background field. Guards RESERVED_TOPIC_SLUGS the same way the sibling page.tsx does. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
This route had no metadata at all before — no title, no OG image. Simple, non-hero design matching BlogTopics' approach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
~1 in 4 seeded documents gets a shader background instead of media, deterministic per document (seeded by slug), so the new hero.background shader path is exercised in fixture data without dominating it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
… lookup in BlogListPage Two findings from the final whole-branch review, both in seams no per-task review could see: - generateMeta.ts's openGraph metadata block was left commented out despite the spec explicitly requiring it be completed (relying on Next's auto-discovery of each route's opengraph-image.tsx, per the spec's own guidance, rather than resolving images explicitly). Removed the dead getImageURL reference alongside it. - BlogListPage.tsx's PostCard read SHADER_PRESET_MAP[shaderKey].gradient with no guard, the only one of four SHADER_PRESET_MAP consumers without one -- and the one with the widest blast radius, since an unrecognized key would throw during a server-rendered /blog list page render rather than degrading one card or one admin panel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related features:
A. Shader hero backgrounds. Content editors can now choose a live WebGL shader (DarkVeil, FaultyTerminal, GradientBlinds, Grainient) as an alternative to uploaded media for the hero background on
PagesandBlogPosts, picked via a visual admin drawer showing 4 live-animating previews.HeroBackgroundField()Payload field (backgroundType: 'media' | 'shader'discriminator).readOnlyhandling.HeroMediagained a shader-rendering branch (dynamically imported,ssr: false— the shader components touchwindowoutside any effect).Pages'hero.mediaandBlogPosts' flatheroImageboth migrated tohero.background, with every dependent call site updated (list-view thumbnails, card thumbnails, JSON-LD, seed generators).seedPages/seedPostsnow occasionally (~25%, deterministic) pick a shader background.B. Site-wide Open Graph images. Investigating "what OG image does a shader-backed post get" surfaced that OG images weren't actually wired up for any page before this — the existing root
opengraph-image.tsxwas unfinished placeholder scaffold, andgenerateMeta'sopenGraphblock was entirely commented out. This PR:next/ogfortakumi-js/response(native Tailwind v4 support, real CSS gradients, faster).Pages/BlogPosts(hero-aware — real image or a static gradient approximation for shader-backed heroes),BlogTopics,/blog+ pagination, andResumeDocuments(which had no metadata at all before this).generateMeta'sopenGraphblock (title/description/siteName, relying on Next's auto-discovery of each route'sopengraph-image.tsx).Process
Built via Subagent-Driven Development: 14 planned tasks, each implemented and reviewed independently, followed by a final whole-branch review. Real bugs caught and fixed along the way:
link()-adjacent field-shape mismatch is not part of this branch, but two genuine admin-thumbnail regressions were:listViewThumbnailPathon bothPagesandBlogPostsneeded a.valuesuffix to correctly resolve through Payload's polymorphic upload-relation wrapping — traced through the real runtime code, not guessed.generateMeta'sopenGraphblock still being commented out (a spec requirement that fell into a gap between tasks) and one unguarded shader-preset lookup in the blog list page (the only one of four consumers without a guard, in a server component with the widest blast radius).Verification
pnpm typecheck— cleanpnpm vitest run— 82 files, 686 tests passingpnpm build— succeeds, 49 pages generateKnown follow-up (out of scope here, flagged for a separate PR)
app/(frontend)/blog/post/[slug]/page.tsxcomputesblogPostingSchema/breadcrumbSchemaJSON-LD but never renders them into a<script>tag — a genuine pre-existing bug, not introduced by this branch. Fixing it properly also requires correctingpostUrl(currently points at/posts/<slug>, but the real route is/blog/post/<slug>) — rendering the schema as-is first would publish worse structured data than not rendering it at all, so this needs its own change with its own verification (Rich Results Test), not a tail-end addition here.Non-goals (per the design spec)
No per-prop shader customization in the admin, no
StaticMeshGradientsupport (non-functional in this repo), no shader support forHeroMediacallers outsidePages/BlogPosts, nohero.backgroundfield added toBlogTopics/ResumeDocuments//blog.🤖 Generated with Claude Code