From ff5e76331ed9d1af669f87f0489f9d2fca574f06 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:00:13 +0200 Subject: [PATCH 01/18] feat(hero): add HeroBackgroundField and shader preset registry 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- src/components/HeroMedia/shaderPresets.ts | 50 ++++++++++++ src/fields/HeroBackground/index.test.ts | 99 +++++++++++++++++++++++ src/fields/HeroBackground/index.ts | 99 +++++++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 src/components/HeroMedia/shaderPresets.ts create mode 100644 src/fields/HeroBackground/index.test.ts create mode 100644 src/fields/HeroBackground/index.ts diff --git a/src/components/HeroMedia/shaderPresets.ts b/src/components/HeroMedia/shaderPresets.ts new file mode 100644 index 0000000..89917e6 --- /dev/null +++ b/src/components/HeroMedia/shaderPresets.ts @@ -0,0 +1,50 @@ +export type ShaderPresetKey = 'darkveil' | 'faulty-terminal' | 'gradient-blinds' | 'grainient' + +export type ShaderPreset = { + key: ShaderPresetKey + label: string + /** + * A static CSS gradient approximating this preset's real on-screen look, + * for contexts that cannot run WebGL: OG images (Takumi/next-og cannot + * execute canvas/WebGL) and the blog list view's card thumbnails. + * + * DarkVeil has no color props of its own (its look comes from an + * intrinsic CPPN pattern generator) — its gradient here is a hand-picked + * pair evoking its actual dark, moody on-screen appearance, not derived + * from any prop value. The other three presets' gradients are derived + * from their real preset color props (see Task 3/4's live-component + * prop bundles) — kept as plain literal strings here, not computed, so + * this file has zero dependency on the shader components themselves. + */ + gradient: string +} + +export const SHADER_PRESETS: ShaderPreset[] = [ + { + key: 'darkveil', + label: 'Dark Veil', + gradient: 'linear-gradient(135deg, #0a0a12 0%, #1a1030 100%)', + }, + { + key: 'faulty-terminal', + label: 'Faulty Terminal', + gradient: 'linear-gradient(135deg, #001a0a 0%, #003d1a 100%)', + }, + { + key: 'gradient-blinds', + label: 'Gradient Blinds', + gradient: 'linear-gradient(135deg, #ff9ffc 0%, #5227ff 100%)', + }, + { + key: 'grainient', + label: 'Grainient', + gradient: 'linear-gradient(135deg, #ff9ffc 0%, #5227ff 50%, #b497cf 100%)', + }, +] + +export const SHADER_PRESET_MAP: Record = Object.fromEntries( + SHADER_PRESETS.map((preset) => [ + preset.key, + preset, + ]), +) as Record diff --git a/src/fields/HeroBackground/index.test.ts b/src/fields/HeroBackground/index.test.ts new file mode 100644 index 0000000..ed11836 --- /dev/null +++ b/src/fields/HeroBackground/index.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { HeroBackgroundField } from './index' + +describe('HeroBackgroundField', () => { + it('builds a group field with the given name', () => { + const field = HeroBackgroundField({ + name: 'background', + }) + + expect(field.type).toBe('group') + expect(field.name).toBe('background') + }) + + it('includes a backgroundType select defaulting to media', () => { + const field = HeroBackgroundField({ + name: 'background', + }) + const backgroundType = field.fields.find( + ( + f, + ): f is Extract< + typeof f, + { + type: 'select' + } + > => 'name' in f && f.name === 'backgroundType', + ) + + expect(backgroundType).toBeDefined() + expect(backgroundType?.type).toBe('select') + expect(backgroundType?.defaultValue).toBe('media') + }) + + it('includes a media upload field allowing images and videos', () => { + const field = HeroBackgroundField({ + name: 'background', + }) + const media = field.fields.find( + ( + f, + ): f is Extract< + typeof f, + { + type: 'upload' + } + > => 'name' in f && f.name === 'media', + ) + + expect(media).toBeDefined() + expect(media?.type).toBe('upload') + expect(media?.relationTo).toEqual([ + 'images', + 'videos', + ]) + expect(media?.hasMany).toBe(true) + }) + + it('includes a shader select field registered with the custom admin component', () => { + const field = HeroBackgroundField({ + name: 'background', + }) + const shader = field.fields.find( + ( + f, + ): f is Extract< + typeof f, + { + type: 'select' + } + > => 'name' in f && f.name === 'shader', + ) + + expect(shader).toBeDefined() + expect(shader?.type).toBe('select') + expect(shader?.admin?.components?.Field).toMatchObject({ + path: '@/fields/HeroBackground/Components/FieldComponent', + }) + }) + + it('accepts a hasMany override for the media field (BlogPosts stays single)', () => { + const field = HeroBackgroundField({ + name: 'background', + hasManyMedia: false, + }) + const media = field.fields.find( + ( + f, + ): f is Extract< + typeof f, + { + type: 'upload' + } + > => 'name' in f && f.name === 'media', + ) + + expect(media?.hasMany).toBe(false) + }) +}) diff --git a/src/fields/HeroBackground/index.ts b/src/fields/HeroBackground/index.ts new file mode 100644 index 0000000..518d436 --- /dev/null +++ b/src/fields/HeroBackground/index.ts @@ -0,0 +1,99 @@ +import type { NamedGroupField } from 'payload' + +import { SHADER_PRESETS } from '@/components/HeroMedia/shaderPresets' +import { CollectionSlug } from '@/types/collections' + +type HeroBackgroundFieldOptions = { + name: string + /** Pages' hero.media is a carousel (hasMany); BlogPosts' stays single. Defaults to true (Pages' existing behavior). */ + hasManyMedia?: boolean +} + +/** + * A hero background choosable as either uploaded media (image/video, + * optionally a carousel) or a curated WebGL shader preset — an alternative + * for `Pages`/`BlogPosts`' full-bleed hero. + * + * `shader`'s admin control is a custom field component (see + * `./Components/FieldComponent`) — a drawer of live-rendering preset cards, + * not Payload's default select dropdown. This field-config module must + * never import that component directly (only its path string, via + * `admin.components.Field`) — `payload generate:types` runs under plain + * Node, and a transitive `@payloadcms/ui` import fails there on + * bundler-only `.css` imports. See `src/fields/Icon/index.ts` for the + * identical established pattern. + */ +export const HeroBackgroundField = ({ + name, + hasManyMedia = true, +}: HeroBackgroundFieldOptions): NamedGroupField => ({ + name, + type: 'group', + label: false, + admin: { + disableListColumn: true, + disableListFilter: true, + disableGroupBy: true, + }, + fields: [ + { + name: 'backgroundType', + type: 'select', + defaultValue: 'media', + options: [ + { + label: 'Uploaded Media', + value: 'media', + }, + { + label: 'Shader', + value: 'shader', + }, + ], + admin: { + disableListColumn: true, + disableListFilter: true, + disableGroupBy: true, + }, + }, + { + name: 'media', + type: 'upload', + relationTo: [ + CollectionSlug.MediaImages, + CollectionSlug.MediaVideos, + ], + hasMany: hasManyMedia, + displayPreview: true, + label: false, + admin: { + condition: (_, siblingData) => siblingData?.backgroundType !== 'shader', + description: hasManyMedia + ? 'Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.' + : 'Fills the first screen.', + disableListColumn: true, + disableListFilter: true, + disableGroupBy: true, + }, + }, + { + name: 'shader', + type: 'select', + options: SHADER_PRESETS.map((preset) => ({ + label: preset.label, + value: preset.key, + })), + admin: { + condition: (_, siblingData) => siblingData?.backgroundType === 'shader', + disableListColumn: true, + disableListFilter: true, + disableGroupBy: true, + components: { + Field: { + path: '@/fields/HeroBackground/Components/FieldComponent', + }, + }, + }, + }, + ], +}) From b1019c976fba73eb33e2abe5f3adc8ed925e70ff Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:07:13 +0200 Subject: [PATCH 02/18] feat(hero): add the shader picker drawer admin field component 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- .../Components/FieldComponent.tsx | 70 ++++++++++++++++ .../Components/ShaderPickerDrawer.tsx | 60 +++++++++++++ .../Components/ShaderPreviewCanvas.tsx | 30 +++++++ .../Components/shaderComponents.ts | 84 +++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 src/fields/HeroBackground/Components/FieldComponent.tsx create mode 100644 src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx create mode 100644 src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx create mode 100644 src/fields/HeroBackground/Components/shaderComponents.ts diff --git a/src/fields/HeroBackground/Components/FieldComponent.tsx b/src/fields/HeroBackground/Components/FieldComponent.tsx new file mode 100644 index 0000000..57f9a5d --- /dev/null +++ b/src/fields/HeroBackground/Components/FieldComponent.tsx @@ -0,0 +1,70 @@ +'use client' + +import dynamic from 'next/dynamic' +import type { SelectFieldClientProps } from 'payload' +import { Button, FieldError, FieldLabel, useDrawerSlug, useField, useModal } from '@payloadcms/ui' + +import { SHADER_PRESET_MAP, type ShaderPresetKey } from '@/components/HeroMedia/shaderPresets' + +import { SHADER_COMPONENTS } from './shaderComponents' + +const ShaderPickerDrawer = dynamic(() => import('./ShaderPickerDrawer'), { + ssr: false, +}) +const ShaderPreviewCanvas = dynamic(() => import('./ShaderPreviewCanvas'), { + ssr: false, +}) + +/** + * Payload admin control for `HeroBackgroundField`'s `shader` sub-field. + * Shows the current selection (label + a small live preview) and a button + * to open the full picker drawer. + * + * Structurally modeled on `src/fields/Icon/Field.tsx` — `useField` for the + * value, `useModal`/`useDrawerSlug` to open a `Drawer`, pick-and-close. + */ +const HeroBackgroundShaderField = ({ path, field }: SelectFieldClientProps) => { + const { value, setValue, showError, errorMessage } = useField({ + path, + }) + const { openModal } = useModal() + const drawerSlug = useDrawerSlug(`hero-shader-picker-${path}`) + + const selectedPreset = value ? SHADER_PRESET_MAP[value] : undefined + + return ( +
+ + +
+ {selectedPreset ? ( +
+ +
+ ) : ( +
+ None +
+ )} + + +
+ + setValue(key)} /> + + +
+ ) +} + +export default HeroBackgroundShaderField diff --git a/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx b/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx new file mode 100644 index 0000000..0ed719e --- /dev/null +++ b/src/fields/HeroBackground/Components/ShaderPickerDrawer.tsx @@ -0,0 +1,60 @@ +'use client' + +import dynamic from 'next/dynamic' +import { Drawer, useModal } from '@payloadcms/ui' + +import { SHADER_PRESETS, type ShaderPresetKey } from '@/components/HeroMedia/shaderPresets' + +import { SHADER_COMPONENTS } from './shaderComponents' + +const ShaderPreviewCanvas = dynamic(() => import('./ShaderPreviewCanvas'), { + ssr: false, +}) + +export interface ShaderPickerDrawerProps { + slug: string + value?: ShaderPresetKey + onSelectAction: (key: ShaderPresetKey) => void +} + +/** + * Grid of preset cards inside Payload's Drawer, each rendering its shader + * live — not a static screenshot. Click a card to select it and close the + * drawer, matching Payload's own block-picker's pick-and-close flow. + * + * Running up to 4 live WebGL contexts at once here is intentional (per the + * design's confirmed choice) — dynamic-imported with `ssr: false` since the + * shader components touch `window` outside of any effect. + */ +export const ShaderPickerDrawer = ({ slug, value, onSelectAction }: ShaderPickerDrawerProps) => { + const { closeModal } = useModal() + + return ( + +
+ {SHADER_PRESETS.map((preset) => ( + + ))} +
+
+ ) +} + +export default ShaderPickerDrawer diff --git a/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx b/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx new file mode 100644 index 0000000..3bb1dea --- /dev/null +++ b/src/fields/HeroBackground/Components/ShaderPreviewCanvas.tsx @@ -0,0 +1,30 @@ +'use client' + +import type { ShaderComponentEntry } from './shaderComponents' + +export type ShaderPreviewCanvasProps = { + entry: ShaderComponentEntry + className?: string +} + +/** + * Renders one shader preset live, sized to fill its container. Used both by + * the picker drawer's grid (many small instances at once) and the field's + * own "currently selected" summary (one instance). + * + * The shader components read `window` at default-parameter-evaluation time, + * not just inside effects — this file must only ever be reached through a + * `next/dynamic(..., { ssr: false })` boundary (see FieldComponent.tsx), + * never imported statically into anything that could render on the server. + */ +export const ShaderPreviewCanvas = ({ entry, className }: ShaderPreviewCanvasProps) => { + const { Component, props } = entry + + return ( +
+ +
+ ) +} + +export default ShaderPreviewCanvas diff --git a/src/fields/HeroBackground/Components/shaderComponents.ts b/src/fields/HeroBackground/Components/shaderComponents.ts new file mode 100644 index 0000000..b61fa8f --- /dev/null +++ b/src/fields/HeroBackground/Components/shaderComponents.ts @@ -0,0 +1,84 @@ +import type { ShaderPresetKey } from '@/components/HeroMedia/shaderPresets' +import DarkVeil, { type DarkVeilProps } from '@/components/Shaders/DarkVeil' +import FaultyTerminal, { type FaultyTerminalProps } from '@/components/Shaders/FaultyTerminal' +import GradientBlinds, { type GradientBlindsProps } from '@/components/Shaders/GradientBlinds' +import Grainient, { type GrainientProps } from '@/components/Shaders/Grainient' + +export type ShaderComponentEntry = + | { + key: 'darkveil' + Component: typeof DarkVeil + props: DarkVeilProps + } + | { + key: 'faulty-terminal' + Component: typeof FaultyTerminal + props: FaultyTerminalProps + } + | { + key: 'gradient-blinds' + Component: typeof GradientBlinds + props: GradientBlindsProps + } + | { + key: 'grainient' + Component: typeof Grainient + props: GrainientProps + } + +/** + * One curated, fixed prop bundle per shader — no per-prop admin tuning. + * Values chosen to look good as a full-bleed hero background at rest (slow + * motion, low noise) rather than each shader's own demo/showcase defaults. + */ +export const SHADER_COMPONENTS: Record = { + darkveil: { + key: 'darkveil', + Component: DarkVeil, + props: { + hueShift: 0, + noiseIntensity: 0.02, + scanlineIntensity: 0, + speed: 0.3, + warpAmount: 0.1, + }, + }, + 'faulty-terminal': { + key: 'faulty-terminal', + Component: FaultyTerminal, + props: { + tint: '#3dff8f', + scanlineIntensity: 0.2, + glitchAmount: 0.5, + flickerAmount: 0.3, + brightness: 0.6, + mouseReact: false, + pageLoadAnimation: false, + }, + }, + 'gradient-blinds': { + key: 'gradient-blinds', + Component: GradientBlinds, + props: { + gradientColors: [ + '#FF9FFC', + '#5227FF', + ], + angle: 15, + noise: 0.15, + blindCount: 12, + mouseDampening: 0.15, + }, + }, + grainient: { + key: 'grainient', + Component: Grainient, + props: { + color1: '#FF9FFC', + color2: '#5227FF', + color3: '#B497CF', + timeSpeed: 0.15, + grainAmount: 0.06, + }, + }, +} From 9799074cc5c12e017b30f3bb6ef139b3131c6591 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:11:18 +0200 Subject: [PATCH 03/18] fix(hero): disable the shader picker when the field is read-only 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- .../HeroBackground/Components/FieldComponent.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/fields/HeroBackground/Components/FieldComponent.tsx b/src/fields/HeroBackground/Components/FieldComponent.tsx index 57f9a5d..fae2f0f 100644 --- a/src/fields/HeroBackground/Components/FieldComponent.tsx +++ b/src/fields/HeroBackground/Components/FieldComponent.tsx @@ -23,7 +23,7 @@ const ShaderPreviewCanvas = dynamic(() => import('./ShaderPreviewCanvas'), { * Structurally modeled on `src/fields/Icon/Field.tsx` — `useField` for the * value, `useModal`/`useDrawerSlug` to open a `Drawer`, pick-and-close. */ -const HeroBackgroundShaderField = ({ path, field }: SelectFieldClientProps) => { +const HeroBackgroundShaderField = ({ path, field, readOnly }: SelectFieldClientProps) => { const { value, setValue, showError, errorMessage } = useField({ path, }) @@ -54,13 +54,20 @@ const HeroBackgroundShaderField = ({ path, field }: SelectFieldClientProps) => { type="button" buttonStyle="secondary" size="small" + disabled={readOnly} onClick={() => openModal(drawerSlug)} > {selectedPreset ? `Change (${selectedPreset.label})` : 'Choose a shader…'} - setValue(key)} /> + {!readOnly && ( + setValue(key)} + /> + )} From 30c0ca0ccf53200159c5c92fe074833d2472a202 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:15:52 +0200 Subject: [PATCH 04/18] feat(hero): add shader-aware rendering to HeroMedia 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- .../{HeroMedia.test.ts => HeroMedia.test.tsx} | 63 ++++++++++++- src/components/HeroMedia/HeroMedia.tsx | 49 ++++++++-- .../HeroMedia/ShaderHeroBackground.tsx | 92 +++++++++++++++++++ 3 files changed, 194 insertions(+), 10 deletions(-) rename src/components/HeroMedia/{HeroMedia.test.ts => HeroMedia.test.tsx} (69%) create mode 100644 src/components/HeroMedia/ShaderHeroBackground.tsx diff --git a/src/components/HeroMedia/HeroMedia.test.ts b/src/components/HeroMedia/HeroMedia.test.tsx similarity index 69% rename from src/components/HeroMedia/HeroMedia.test.ts rename to src/components/HeroMedia/HeroMedia.test.tsx index 89682ac..d3af64d 100644 --- a/src/components/HeroMedia/HeroMedia.test.ts +++ b/src/components/HeroMedia/HeroMedia.test.tsx @@ -1,6 +1,14 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' -import { toItems } from './HeroMedia' +import { HeroMedia, toItems } from './HeroMedia' + +vi.mock('./ShaderHeroBackground', () => ({ + ShaderHeroBackground: ({ presetKey }: { presetKey: string }) => ( +
+ ), +})) const image = { relationTo: 'images', @@ -206,3 +214,54 @@ describe('toItems', () => { }) }) }) + +describe('HeroMedia', () => { + it('renders nothing visual when backgroundType is media with no media', () => { + render( + , + ) + + expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument() + }) + + it('renders the shader background when backgroundType is shader', () => { + render( + , + ) + + const shaderEl = screen.getByTestId('shader-hero-background') + expect(shaderEl).toBeInTheDocument() + expect(shaderEl).toHaveAttribute('data-preset', 'darkveil') + }) + + it('does not render a shader background when backgroundType is shader but no shader is selected', () => { + render( + , + ) + + expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument() + }) + + it('falls back to media rendering when background is legacy/undefined (pre-migration content)', () => { + // Documents saved before this field existed have no `background` key at + // all — only the old flat `media` shape. HeroMedia must not crash. + render() + + expect(screen.queryByTestId('shader-hero-background')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/HeroMedia/HeroMedia.tsx b/src/components/HeroMedia/HeroMedia.tsx index aaadc10..bd4312b 100644 --- a/src/components/HeroMedia/HeroMedia.tsx +++ b/src/components/HeroMedia/HeroMedia.tsx @@ -6,10 +6,12 @@ import type { MediaImage, MediaVideo } from '@/types/payload' import { HeroCarousel } from './HeroCarousel' import type { HeroMediaItem } from './HeroSlide' +import { ShaderHeroBackground } from './ShaderHeroBackground' +import type { ShaderPresetKey } from './shaderPresets' export interface HeroMediaProps { - /** Polymorphic upload references straight from Payload. */ - media: unknown + /** The whole `hero.background` group value: `{ backgroundType, media, shader }`. */ + background: unknown /** Falls back to this when an asset carries no alt text of its own. */ fallbackAlt?: string /** @@ -102,21 +104,50 @@ export const toItems = (media: unknown, fallbackAlt: string): HeroMediaItem[] => }) } +/** Narrows an unknown `background` group value to its recognized shape. */ +const parseBackground = ( + background: unknown, +): { + backgroundType: 'media' | 'shader' + media: unknown + shader: ShaderPresetKey | undefined +} => { + if (typeof background !== 'object' || background === null) { + return { + backgroundType: 'media', + media: undefined, + shader: undefined, + } + } + + const record = background as Record + const backgroundType = record.backgroundType === 'shader' ? 'shader' : 'media' + + return { + backgroundType, + media: record.media, + shader: typeof record.shader === 'string' ? (record.shader as ShaderPresetKey) : undefined, + } +} + /** - * Full-bleed hero visual: one asset, or a cross-fading carousel of several, - * under the brand duotone treatment. + * Full-bleed hero visual: one asset, a cross-fading carousel of several, or + * a live shader background, under the brand duotone treatment. * * `children` render above the visual — that is where the headline, topics and * meta go. */ export const HeroMedia = ({ - media, + background, fallbackAlt = '', align = 'bottom', className, children, }: HeroMediaProps) => { - const items = toItems(media, fallbackAlt) + const { backgroundType, media, shader } = parseBackground(background) + const items = backgroundType === 'media' ? toItems(media, fallbackAlt) : [] + const hasShader = backgroundType === 'shader' && Boolean(shader) + const hasVisual = items.length > 0 || hasShader return (
- {items.length > 0 && ( + {hasVisual && ( - {items.length === 1 && items[0].kind === 'image' ? ( + {hasShader && shader ? ( + + ) : items.length === 1 && items[0].kind === 'image' ? ( > = { + darkveil: dynamic(() => import('@/components/Shaders/DarkVeil'), { + ssr: false, + }), + 'faulty-terminal': dynamic(() => import('@/components/Shaders/FaultyTerminal'), { + ssr: false, + }), + 'gradient-blinds': dynamic(() => import('@/components/Shaders/GradientBlinds'), { + ssr: false, + }), + grainient: dynamic(() => import('@/components/Shaders/Grainient'), { + ssr: false, + }), +} + +/** + * The same curated per-preset prop bundles as the admin picker's + * `SHADER_COMPONENTS` (`src/fields/HeroBackground/Components/shaderComponents.ts`) + * — intentionally duplicated rather than shared, since that file lives on + * the admin-only client bundle and this one lives on the public frontend + * bundle; sharing it would pull admin-picker code into the public bundle + * for no benefit. Keep the two in sync by hand if a preset's tuning changes. + */ +const SHADER_PROPS: Record> = { + darkveil: { + hueShift: 0, + noiseIntensity: 0.02, + scanlineIntensity: 0, + speed: 0.3, + warpAmount: 0.1, + }, + 'faulty-terminal': { + tint: '#3dff8f', + scanlineIntensity: 0.2, + glitchAmount: 0.5, + flickerAmount: 0.3, + brightness: 0.6, + mouseReact: false, + pageLoadAnimation: false, + }, + 'gradient-blinds': { + gradientColors: [ + '#FF9FFC', + '#5227FF', + ], + angle: 15, + noise: 0.15, + blindCount: 12, + mouseDampening: 0.15, + }, + grainient: { + color1: '#FF9FFC', + color2: '#5227FF', + color3: '#B497CF', + timeSpeed: 0.15, + grainAmount: 0.06, + }, +} + +export interface ShaderHeroBackgroundProps { + presetKey: ShaderPresetKey + className?: string +} + +/** + * Renders the given shader preset full-bleed, live. Dynamically imported + * (`ssr: false`) since the shader components read `window` at + * default-parameter-evaluation time, not just inside effects — a static + * import here would crash server rendering. + */ +export const ShaderHeroBackground = ({ presetKey, className }: ShaderHeroBackgroundProps) => { + const Shader = SHADER_COMPONENTS[presetKey] + + return ( +
+ +
+ ) +} + +export default ShaderHeroBackground From 68a3ed68169383c5abd614d5b7d727f311f03c0f Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:18:55 +0200 Subject: [PATCH 05/18] fix(hero): update not-found.tsx's HeroMedia call to the new background prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- app/(frontend)/not-found.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/(frontend)/not-found.tsx b/app/(frontend)/not-found.tsx index 9469f54..9000696 100644 --- a/app/(frontend)/not-found.tsx +++ b/app/(frontend)/not-found.tsx @@ -11,7 +11,14 @@ export default async function NotFound() { } = await fetchSiteSettingsCached() return ( - +

404

From 66adbc488933071abf5f61e5576b475d4e77dc37 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:28:27 +0200 Subject: [PATCH 06/18] feat(hero): migrate Pages' hero.media to hero.background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- app/(frontend)/[slug]/page.tsx | 2 +- src/collections/Pages/index.ts | 24 ++++--------------- src/lib/seed/pages.test.ts | 32 +++++++++++++++----------- src/lib/seed/pages.ts | 17 ++++++++------ src/types/payload.ts | 42 +++++++++++++++++++++------------- 5 files changed, 61 insertions(+), 56 deletions(-) diff --git a/app/(frontend)/[slug]/page.tsx b/app/(frontend)/[slug]/page.tsx index f541e5b..1e9c51f 100644 --- a/app/(frontend)/[slug]/page.tsx +++ b/app/(frontend)/[slug]/page.tsx @@ -65,7 +65,7 @@ export default async function Page({ params }: PageProps) { {title && (
diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 20ad850..574c741 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -7,6 +7,7 @@ import { authenticatedOrPublished } from '@/access/authenticatedOrPublished' import { BLOCK_SLUGS } from '@/blocks' import { revalidatePage } from '@/collections/Pages/hooks/revalidatePage' import { GeneratorFlagsField } from '@/fields/GeneratorFlags' +import { HeroBackgroundField } from '@/fields/HeroBackground' import { MetaField } from '@/fields/Meta' import { ProtectedField } from '@/fields/Protected' import { RichTextField } from '@/fields/RichText' @@ -77,7 +78,7 @@ export const Pages: CollectionConfig = { fields: [ /* -------------- Main Content -------------- */ TitleField({ - listViewThumbnailPath: 'hero.media.0', + listViewThumbnailPath: 'hero.background.media.0', }), /* -------------- Sidebar Content -------------- */ @@ -124,24 +125,9 @@ export const Pages: CollectionConfig = { label: 'Hero', name: 'hero', fields: [ - { - name: 'media', - type: 'upload', - relationTo: [ - CollectionSlug.MediaImages, - CollectionSlug.MediaVideos, - ], - hasMany: true, - displayPreview: true, - label: false, - admin: { - description: - 'Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own.', - disableListColumn: true, - disableListFilter: true, - disableGroupBy: true, - }, - }, + HeroBackgroundField({ + name: 'background', + }), { name: 'contentType', type: 'select', diff --git a/src/lib/seed/pages.test.ts b/src/lib/seed/pages.test.ts index 5c1a2aa..9aa4702 100644 --- a/src/lib/seed/pages.test.ts +++ b/src/lib/seed/pages.test.ts @@ -174,12 +174,14 @@ describe('cleanPages', () => { { id: 'page-1', hero: { - media: [ - { - relationTo: 'images', - value: 'image-1', - }, - ], + background: { + media: [ + { + relationTo: 'images', + value: 'image-1', + }, + ], + }, }, }, ], @@ -248,12 +250,14 @@ describe('cleanPages', () => { { id: 'page-1', hero: { - media: [ - { - relationTo: 'images', - value: 'image-1', - }, - ], + background: { + media: [ + { + relationTo: 'images', + value: 'image-1', + }, + ], + }, }, }, ], @@ -292,7 +296,9 @@ describe('cleanPages', () => { { id: 'page-1', hero: { - media: [], + background: { + media: [], + }, }, }, ], diff --git a/src/lib/seed/pages.ts b/src/lib/seed/pages.ts index c605e0a..5c21b88 100644 --- a/src/lib/seed/pages.ts +++ b/src/lib/seed/pages.ts @@ -329,12 +329,15 @@ export const seedPages = async ( ], hero: { contentType: 'title', - media: [ - { - relationTo: 'images', - value: imageId, - }, - ], + background: { + backgroundType: 'media', + media: [ + { + relationTo: 'images', + value: imageId, + }, + ], + }, }, content: pageBlocks(slug), }, @@ -381,7 +384,7 @@ export const cleanPages = async ( const mediaIds = new Set() for (const page of pages) { - for (const entry of page.hero?.media ?? []) { + for (const entry of page.hero?.background?.media ?? []) { if (entry.relationTo === 'images') { mediaIds.add(typeof entry.value === 'string' ? entry.value : String(entry.value.id)) } diff --git a/src/types/payload.ts b/src/types/payload.ts index a3ff080..db69ede 100644 --- a/src/types/payload.ts +++ b/src/types/payload.ts @@ -283,21 +283,25 @@ export interface Page { slug: string; layout?: PageLayout; hero?: { - /** - * Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own. - */ - media?: - | ( - | { - relationTo: 'images'; - value: string | MediaImage; - } - | { - relationTo: 'videos'; - value: string | MediaVideo; - } - )[] - | null; + background?: { + backgroundType?: ('media' | 'shader') | null; + /** + * Fills the first screen. Two or more entries become a cross-fading carousel; a single entry renders on its own. + */ + media?: + | ( + | { + relationTo: 'images'; + value: string | MediaImage; + } + | { + relationTo: 'videos'; + value: string | MediaVideo; + } + )[] + | null; + shader?: ('darkveil' | 'faulty-terminal' | 'gradient-blinds' | 'grainient') | null; + }; contentType?: ('title' | 'custom') | null; content?: { root: { @@ -1726,7 +1730,13 @@ export interface PagesSelect { hero?: | T | { - media?: T; + background?: + | T + | { + backgroundType?: T; + media?: T; + shader?: T; + }; contentType?: T; content?: T; }; From 5fcc48c5080aa320fd3b284e65988ce002318736 Mon Sep 17 00:00:00 2001 From: Daniel Heene Date: Fri, 21 Aug 2026 13:36:22 +0200 Subject: [PATCH 07/18] feat(hero): migrate BlogPosts' heroImage to hero.background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT --- app/(frontend)/blog/_shared/BlogListPage.tsx | 22 ++++++++++- app/(frontend)/blog/post/[slug]/page.tsx | 9 +++-- .../Renderer/Renderer.test.tsx | 38 ++++++++++++------- .../Renderer/Renderer.tsx | 6 ++- src/collections/BlogPosts/index.ts | 26 +++++++++---- src/lib/seed/posts.test.ts | 35 ++++++++++++----- src/lib/seed/posts.ts | 16 +++++--- src/types/payload.ts | 34 ++++++++++++++--- 8 files changed, 139 insertions(+), 47 deletions(-) diff --git a/app/(frontend)/blog/_shared/BlogListPage.tsx b/app/(frontend)/blog/_shared/BlogListPage.tsx index ae27564..c7f27e4 100644 --- a/app/(frontend)/blog/_shared/BlogListPage.tsx +++ b/app/(frontend)/blog/_shared/BlogListPage.tsx @@ -6,6 +6,7 @@ import { getPayload } from 'payload' import { format } from 'date-fns' +import { SHADER_PRESET_MAP } from '@/components/HeroMedia/shaderPresets' import { ImageMedia } from '@/components/ImageMedia' import { PageContainer } from '@/components/PageContainer' import { cn } from '@/lib/cn' @@ -86,8 +87,19 @@ const queryPublishedPosts = async ({ topicId, page }: { topicId?: string; page: const PostCard = ({ post }: { post: BlogPostData }) => { // populated upload values omit mimeType, so isMediaImage() can't be used — // the relation's target collection is the reliable discriminator + const background = post.hero?.background const heroImage = - post.heroImage?.relationTo === CollectionSlug.MediaImages ? post.heroImage.value : undefined + background?.backgroundType === 'media' && + typeof background.media === 'object' && + background.media !== null && + 'relationTo' in background.media && + background.media.relationTo === CollectionSlug.MediaImages + ? background.media.value + : undefined + const shaderKey = + background?.backgroundType === 'shader' && typeof background.shader === 'string' + ? background.shader + : undefined return ( { ])} /> )} + {shaderKey && ( +
+ )} ) } diff --git a/app/(frontend)/blog/post/[slug]/page.tsx b/app/(frontend)/blog/post/[slug]/page.tsx index fcd832c..8409c94 100644 --- a/app/(frontend)/blog/post/[slug]/page.tsx +++ b/app/(frontend)/blog/post/[slug]/page.tsx @@ -21,6 +21,7 @@ import { generateBlogPosting, generateBreadcrumbList } from '@/lib/jsonLd' import { placeholderParams } from '@/lib/placeholderParams' import { resolveRelations } from '@/lib/resolveRelation' import { highlightRichText } from '@/lib/shiki/highlightRichText' +import { isRenderableImage } from '@/lib/typeGuards' import { CollectionData, CollectionSlug } from '@/types/collections' export async function generateStaticParams() { @@ -62,11 +63,13 @@ export default async function Page({ params: paramsPromise }: PageProps) { }) if (!post) return notFound() - const { title, content, heroImage, createdAt, updatedAt, topics } = post + const { title, content, hero, createdAt, updatedAt, topics } = post const baseUrl = process.env.SERVER_URL || 'https://danielheene.de' const postUrl = `${baseUrl}/posts/${slug}` - const heroData = heroImage?.value + const heroBackground = hero?.background + const heroMedia = heroBackground?.backgroundType === 'media' ? heroBackground.media : undefined + const heroData = isRenderableImage(heroMedia?.value) ? heroMedia.value : undefined const headings = extractHeadings(content) // Shiki is server-only, so code blocks are highlighted here and handed to // RichText, which renders on the client @@ -136,7 +139,7 @@ export default async function Page({ params: paramsPromise }: PageProps) {
{/* A post carries at most one hero image, so this never becomes a carousel. */} - +