Skip to content

feat(hero): shader hero backgrounds + site-wide Open Graph images - #44

Merged
danielheene merged 18 commits into
developfrom
worktree-shader-hero-backgrounds
Aug 22, 2026
Merged

feat(hero): shader hero backgrounds + site-wide Open Graph images#44
danielheene merged 18 commits into
developfrom
worktree-shader-hero-backgrounds

Conversation

@danielheene

Copy link
Copy Markdown
Owner

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 Pages and BlogPosts, picked via a visual admin drawer showing 4 live-animating previews.

  • New shared HeroBackgroundField() Payload field (backgroundType: 'media' | 'shader' discriminator).
  • Custom admin field component: click-to-open drawer, 4 live shader previews, pick-and-close — modeled on this repo's existing icon-picker pattern, including readOnly handling.
  • HeroMedia gained a shader-rendering branch (dynamically imported, ssr: false — the shader components touch window outside any effect).
  • Pages' hero.media and BlogPosts' flat heroImage both migrated to hero.background, with every dependent call site updated (list-view thumbnails, card thumbnails, JSON-LD, seed generators).
  • seedPages/seedPosts now 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.tsx was unfinished placeholder scaffold, and generateMeta's openGraph block was entirely commented out. This PR:

  • Swaps next/og for takumi-js/response (native Tailwind v4 support, real CSS gradients, faster).
  • Adds real, per-document OG images for every route: Pages/BlogPosts (hero-aware — real image or a static gradient approximation for shader-backed heroes), BlogTopics, /blog + pagination, and ResumeDocuments (which had no metadata at all before this).
  • Completes generateMeta's openGraph block (title/description/siteName, relying on Next's auto-discovery of each route's opengraph-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:

  • A link()-adjacent field-shape mismatch is not part of this branch, but two genuine admin-thumbnail regressions were: listViewThumbnailPath on both Pages and BlogPosts needed a .value suffix to correctly resolve through Payload's polymorphic upload-relation wrapping — traced through the real runtime code, not guessed.
  • The admin shader picker completely failed to render in the browser during final verification — root cause was a generated import-map file never being regenerated after the component was added. Caught only because that verification step used a real browser, not just typecheck/tests/build.
  • The final whole-branch review caught generateMeta's openGraph block 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 — clean
  • pnpm vitest run — 82 files, 686 tests passing
  • pnpm build — succeeds, 49 pages generate
  • Manual end-to-end verification with a real browser: admin shader picker drawer opens with 4 visibly-animating previews, selection persists through publish+reload, frontend hero reflects the change and animates live; all OG image routes fetch and return valid 1200×630 PNGs.

Known follow-up (out of scope here, flagged for a separate PR)

app/(frontend)/blog/post/[slug]/page.tsx computes blogPostingSchema/breadcrumbSchema JSON-LD but never renders them into a <script> tag — a genuine pre-existing bug, not introduced by this branch. Fixing it properly also requires correcting postUrl (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 StaticMeshGradient support (non-functional in this repo), no shader support for HeroMedia callers outside Pages/BlogPosts, no hero.background field added to BlogTopics/ResumeDocuments//blog.

🤖 Generated with Claude Code

danielheene and others added 18 commits August 21, 2026 13:00
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
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
@danielheene
danielheene marked this pull request as ready for review August 22, 2026 22:35
@danielheene
danielheene merged commit 9c618f3 into develop Aug 22, 2026
4 of 5 checks passed
@danielheene
danielheene deleted the worktree-shader-hero-backgrounds branch August 22, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant