Skip to content

Develop - #34

Open
danielheene wants to merge 279 commits into
mainfrom
develop
Open

Develop#34
danielheene wants to merge 279 commits into
mainfrom
develop

Conversation

@danielheene

Copy link
Copy Markdown
Owner

No description provided.

danielheene and others added 30 commits July 11, 2026 18:15
Seven-task plan: relocate app code, split package.json between root
tooling and the new web package, register web in the pnpm workspace,
centralize env loading via @next/env, update biome includes, and run
a full install/dev/build/migrate verification pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@next/env is CJS-only with no exports field, and its interop shape
differs between Next.js's config loader (named exports on the namespace,
default undefined) and plain Node ESM used by Payload's CLI (whole
exports object as default, named import throws). Use a namespace import
with a default fallback so loadEnvConfig resolves under both. Also pass
forceReload: true so the repo-root .env* files are actually read instead
of returning @next/env's primed empty cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pnpm-lock.yaml was previously regenerated while the root package.json
had uncommitted, unrelated WIP changes (@changesets/* additions) in
the working tree, so the committed lockfile's root importer no longer
matched the committed root package.json. Regenerate against a clean
checkout of package.json so the lockfile is internally consistent
with this commit again; the storybook importer's Tailwind additions
are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Also fixes Icons.mdx's broken '@sb//IconGallery.tsx' import (an
unconfigured alias introduced when files moved into src/ earlier this
session) to a correct relative import, since it directly blocked
verifying this task's own build.
Generated via the acquire-codebase-knowledge workflow to give future
sessions (human or agent) a verified map of the stack, structure,
architecture, conventions, integrations, testing setup, and known
concerns without re-deriving them from scratch each time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ix import paths

- Convert `web/env.ts` to async `loadEnv()` that returns validated `Env` or exits on schema failure
- Add `storybook/global.d.ts` for CSS module declarations
- Update `storybook/package.json` with import mappings for `#styles/*` and `#fonts/*`
- Configure `storybook/.storybook/main.ts` with `TsconfigPathsPlugin` for path resolution
- Integrate Tailwind decorators and theme support in `storybook/.storybook/preview.tsx`
- Change `web/tsconfig.json` jsx to `react-jsx` and clean up excludes
- Format `web/payload.config.ts` and `web/next.config.ts` with prettier-style spacing
- Update `web/next-env.d.ts` to new Next.js routes import style
- Install `tsconfig-paths-webpack-plugin` and remove `polished` from storybook dependencies

Signed-off-by: Daniel Heene <daniel@heene.io>
Adds web/Dockerfile (base/deps/builder/runner stages, full node_modules
retained per CON-002 so `payload jobs:run` resolves as a command
override), web/docker-entrypoint.sh (optional userspace-networking
Tailscale bring-up then `exec "$@"`, no mode-branching per REQ-003/
REQ-011), and web/.dockerignore.

Deviates from the plan's draft Dockerfile in two build-tested ways:
CI=true in the builder stage (pnpm's non-interactive deps-status check
otherwise aborts with no TTY during `docker build`), and copying
web/app into the runner stage (Payload's admin panel dynamically
imports app/(payload)/admin/importMap.js by path at runtime).

Build currently fails at the `next build` step due to an upstream,
unpatched Turbopack production-build bug in next@16.2.10 affecting
next/font/local resolution — reproduces identically outside Docker,
documented in .superpowers/sdd/task-1-report.md.
Docker only auto-discovers a plain .dockerignore at the build
context root, not inside a subdirectory. Since web/Dockerfile is
built with the repo root as context (`-f web/Dockerfile .`), the
correctly-resolved per-Dockerfile ignore file is
web/Dockerfile.dockerignore. Verified empirically: build context
transfer drops from 3.82GB (unfiltered) to 507MB (filtered) with
the file correctly named and placed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cker image

Adds storybook/Dockerfile (deps/build/nginx-runtime stages) and
storybook/Dockerfile.dockerignore for the repo-root build context, mirroring
the pattern from web/Dockerfile (Task 1). Named the ignore file
Dockerfile.dockerignore rather than .dockerignore, since Docker's
per-Dockerfile ignore-file resolution requires that exact naming when the
build context is the repo root.

The deps stage installs both `storybook` and `web` (`--filter storybook...
--filter web...`), not just storybook: Storybook's build compiles web/src
components directly (staticDirs, tsconfig path aliases, story sources)
rather than importing `web` as a declared workspace dependency, so
`--filter storybook...` alone doesn't pull in web's own dependencies (e.g.
@iconify-icon/react, used by web/src/components/Icon).

Also fixes a pre-existing bug (reproducible outside Docker on a plain
`pnpm --filter storybook run build`) that blocked any build: package.json's
`imports` field used `../web/src/styles/*` as a subpath target, which Node's
imports spec rejects (targets must start with `./`, no parent-directory
traversal). Repoints preview.tsx's CSS import at the already-working `@/*`
tsconfig path alias instead, and drops the broken/dead `imports` field
(#fonts/* was already unused - the only font import uses the working
@fonts/* tsconfig alias).

Verified: docker buildx build succeeds, context transfer is ~150-240kB
(vs 3.8GB+ with a deliberately misnamed ignore file, confirmed as a negative
control), the image runs on nginx:1-alpine at 88.3MB, and curl against the
running container returns HTTP 200 with the Storybook manager HTML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bump storybook to 1.0.0 to match web's baseline, then group both
packages under Changesets' `fixed` mode so every release bumps them
to the identical version number in lockstep (Babel/Payload model).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds .github/workflows/docker-images.yml with build-web, build-storybook,
and deploy jobs. Tags are computed via docker/metadata-action per branch/PR
context, web builds select the correct Tailscale-routed database secret
(prod/develop/preview) via BuildKit build secrets, and successful main/develop
pushes trigger the corresponding Dokploy webhook. PR builds never trigger
deploy. Implements TASK-013 through TASK-017 (GOAL-004).
Dependabot's docker ecosystem only scans the exact directory given (no
recursion), so the existing root entry doesn't cover the new
web/Dockerfile and storybook/Dockerfile added for the image pipeline.
…ages

Captures the design for splitting the four PP font families into
self-contained @danielheene/font-* packages usable by both Next.js and
react-pdf, including a verified feasibility spike proving next/font/local
works when called from a separate workspace package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
next/font/local requires a literal src array at the call site, so next.ts
can never consume a shared manifest. Since one consumer would end up
hardcoding literals anyway, keeping a manifest only for pdf.ts added
indirection without payoff — both files now hardcode their own literal
file list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the local pdf.ts react-pdf config (reading bundled ttf files
directly instead of over HTTP), removes the unused manifest-style
index.ts, and narrows package.json to the two subpath exports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The index.ts deletion from the prior commit was made in the working
tree but never staged, so it wasn't actually part of that commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… ignore

Task 2's build accidentally committed tsup's dist/ output. Widens the
existing dist/media ignore rule to dist generally, since no other
tracked dist/ directory exists in the repo, and untracks the one that
slipped in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preserves the existing behavior of registering each weight twice in
the react-pdf config (normal and italic both pointing at the same
file), since no separate italic files exist for this family.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
danielheene and others added 28 commits August 20, 2026 18:10
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQE1CxwMDmNUcYJQJBGjFz
deletedPages only made sense for Pages. Renamed to deleted and added
skipped, so the same channel type and admin-panel toast summary work
for cleanPosts/cleanTopics (added in later tasks) without inventing a
new field name per collection.

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_01EQE1CxwMDmNUcYJQJBGjFz
Ported verbatim (same node shapes, same deterministic-random helpers)
into src/lib/seed/lexical.ts so Pages, BlogPosts, and BlogTopics'
seed generators can share one implementation instead of each
carrying its own copy. Added test coverage the original script
never had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Flagged by Task 3's review — imported but never referenced. No
italic-specific test coverage was planned; IS_BOLD already exercises
the format bitmask structurally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQE1CxwMDmNUcYJQJBGjFz
- payload.config.ts reads MIGRATION_DIR, defaulting to
  migrations/required — the set safe to run unattended in every
  environment, including production.
- migrations/dev (opt-in via MIGRATION_DIR=migrations/dev, wired
  through the new migrate:dev script) will hold local-only sample
  data that must never reach production.
- migrations/required/20260818_090411_seed_site_settings.ts seeds
  the SiteSettings header nav with a single "Home" entry when empty,
  so a brand-new database doesn't render with an empty header. Both
  up and down are idempotent and scoped to only the entry this
  migration owns.
lexical.ts's link() was ported from scripts/seed-blog.ts, whose claimed
field shape (nested under a 'link' key with a 'type' discriminator) no
longer matches this project's actual LinkField (flat fields, no 'type'
field at all — removed in an earlier refactor). Every link insertion
into a rich-text field threw a deterministic ValidationError. Verified
the fix against the real Payload validator, not just the unit test.

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_01EQE1CxwMDmNUcYJQJBGjFz
Replaced the generic 'Seeded Dummy Page N' title and single
boilerplate paragraph with a realistic title pool and 2-4 blocks per
page (One-Column, Two-Column, Code, Link Group), each with real
prose generated via the shared src/lib/seed/lexical builders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Follows the seedPages/cleanPages shape exactly. cleanTopics' one
new behavior: it never deletes a seeded topic a non-seeded (real)
post still references, relying on the corrected topics.contains
query from the BlogTopics access-check fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Ports scripts/seed-blog.ts's lexicalArticle generator onto the
shared src/lib/seed/lexical builders. seedPosts resolves 1-2 topic
ids per post, creating its own seeded topics via seedTopics when too
few exist rather than requiring seedTopics to run first. cleanPosts
cascade-deletes hero images with the same live-flag-recheck safety
property cleanPages already has, and does not touch BlogTopics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…ribution

The `caption` editor variant passes an array to `LinkFeature({ fields })`, so
lexical's `transformExtraFields` replaces the default `linkType`/`url`/`newTab`
base fields with `LinkField()`'s own. `buildCreditsValue` was emitting the
default shape, which has no `label` — and `label` is what `CMSLink` actually
renders. Because `label` carries `defaultValue: '{title}'`, this passed
validation and then rendered the literal template in the credits line.

Emit `{ reference: null, url, newTab, label }` with empty children (CMSLink
renders label *then* children, so text children would duplicate it), matching
the canonical shape `migrateLinkFields` produces.

Adds a validation-boundary test that sanitizes `LinkField().fields` through
Payload's own `sanitizeFields` and reproduces `linkValidation`'s
`fieldSchemasToFormState` call, asserting on resolved values as well as
`errorPaths` — an errors-only assertion would not have caught this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQE1CxwMDmNUcYJQJBGjFz
seedTopics is idempotent-by-slug, not "create N more" — it skips pool
slots that already exist. A naive shortfall calculation could silently
under-fulfill the requested count. resolveTopicIds now loops until it
actually has `count` ids (bounded, since topicFor's cycling suffixes
make running out of the pool practically impossible for realistic
seed sizes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Extends the single 'pages'-only branch to also handle 'posts' and
'topics', each calling the matching seedX/cleanX pair from Tasks 5-6.
Same task, same channel, same mode-parameterized shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Link nodes emitted by buildCreditsValue set `children: []`, on the theory
that CMSLink prints `resolvedLabel || label` followed by the children and
would otherwise duplicate the text. CMSLink is not on this render path:
MediaImages.credits is rendered via `<RichText data={credit.credits} />`,
which emits a bare `<a>{children}</a>`.

Every Lexical -> JSX link converter — Payload's own and this repo's
override — builds the anchor body from `nodesToJSX({ nodes: node.children })`
and never from `fields.label`, so empty children rendered as an empty
`<a href="…"></a>`. Each link node now carries a real text child.

The flat `fields` shape is confirmed correct and unchanged: LinkFeature is
configured with `[...LinkField().fields]`, which spreads the group's inner
fields, and lexical's transformExtraFields replaces the base fields when
given an array — the same flat shape the admin link editor drawer produces.

Adds creditsRendering.test.tsx, which drives Payload's converter stack over
the real output and asserts on the HTML, with a negative control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQE1CxwMDmNUcYJQJBGjFz
Registers the existing, unmodified SeedActions component on both
collections, matching Pages' registration exactly — no component
changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Superseded by seed:posts and seed:topics (src/lib/seed/posts.ts,
src/lib/seed/topics.ts), which cover the same content generation
via the shared src/lib/seed/lexical module, plus the seeded-dummy
tagging and admin-panel action seed-blog.ts never had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…uired migration

migrations/dev/20260820_120000_seed_dummy_pages.ts seeds 5 sample Pages
via the existing seedPages/cleanPages fixtures (src/lib/seed/pages.ts —
same ones pnpm run seed:pages uses) so a fresh clone gets usable content
automatically on `pnpm run migrate:dev`, without duplicating that fixture
logic. Idempotent and reversible, matching the seed script's own
matched-by-slug skip behavior.

migrations/required/20260818_090411_seed_site_settings.ts: its
updateGlobal call triggers an afterChange hook that calls Next.js's
revalidateTag, which throws outside of a running Next.js request
("Invariant: static generation store missing") — i.e. every time this
runs via the standalone `payload migrate` CLI, including in production.
Sets req.context.skipUpdateCachedData (the escape hatch the hook already
checks for, used elsewhere in the repo) before both updateGlobal calls.

Verified end-to-end against local Mongo: migrate:dev now completes
(previously crashed the process), both migrations are idempotent on
re-run, and migrate:down cleanly reverts both.
…-returns

resolveTopicIds was being called fresh for every seeded post, causing
redundant queries and giving every post the same leading 1-2 topics
instead of a spread across the pool. Hoisted resolution to run once
per seedPosts call, rotating which topics each post gets by index.
Also logs when the bounded retry loop can't reach the requested count,
since a silent short-return now affects every post in a run instead
of just one.

Also: dropped the vestigial type:'custom' field from pages.ts's
linkGroupBlock (inconsistent with lexical.ts's corrected link() shape),
replaced two inline text-node literals with the shared text() helper,
and restored the spec's videoIds parity branch in lexicalArticle
(dead but present, matching the design doc's explicit instruction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
The final-review fix hoisted resolveTopicIds out of seedPosts' loop but
requested only MAX_TOPICS_PER_POST (2), so the whole corpus rotated
between just 2 topics regardless of how many were seeded — the review's
re-verification flagged this as a residual realism gap versus the
retired script's full-pool rotation. Pool size is now
min(count, MAX_TOPIC_POOL_SIZE=6), floored at 2, so a larger seed run
spreads posts across more topics. Updated two tests whose mocks assumed
a fixed 2-topic pool request to match the wider, count-scaled request.
…d admin UI

Mirrors the seedPages/cleanPages pattern (src/lib/seed/pages.ts): the
813-line monolithic scripts/seed-blog.ts becomes a thin argv-parsing
wrapper around new seedBlog/cleanBlog functions in src/lib/seed/blog.ts,
making the fixture logic reusable outside the CLI script — specifically
by the job-queue seed/clean task, matching Pages' admin-panel integration.

Two real bugs surfaced while verifying the extraction end-to-end against
local Mongo (not just typechecked):

- The seeded article's link() node nested its fields under a `link` key
  (`fields: { link: { type, newTab, label, url, reference } }`). Payload's
  Lexical link validator runs `node.fields` directly against the custom
  LinkField's inner field schema, which expects those fields flat —  so
  every post containing a link node failed to validate and the whole
  create() aborted. This bug was already latent in the original
  scripts/seed-blog.ts; it likely never fired because 0.45 odds across
  many section loops made it easy to not hit in a spot-check.
- cleanBlog's topic-deletion loop threw and aborted the rest of cleanup
  the moment a seeded topic was still referenced by an unrelated,
  non-seeded post (a real reference-integrity guard elsewhere in the
  app). Now caught per-topic and skipped with a warning, so one
  still-referenced topic no longer blocks removing everything else.

Also, since Pages and Blog now return different result shapes (one
document type vs. two, plus media), generalized `SeedTaskProgress`'s
success payload from fixed optional fields (created/deletedPages/
deletedMedia) to a `counts: Record<string, number>` label→count bag, and
updated SeedActions' toast to render it generically — avoids each newly
seedable collection needing bespoke fields wired through the SSE type,
the task, and the toast.

BlogPosts admin now carries the same Seed…/Clean list-menu action as
Pages (mounted once, since seeding always creates/removes topics and
posts together — see src/lib/seed/blog.ts's docstring).

Verified end-to-end against local Mongo: seed creates topics + posts +
downloads placeholder images correctly (including the fixed link nodes),
reruns are idempotent, and clean removes everything it created while
correctly skipping a topic still referenced elsewhere.
…pages-seed-realism

# Conflicts:
#	scripts/seed-blog.ts
#	src/collections/BlogPosts/index.ts
#	src/components/AdminPanel/SeedActions/SeedActions.tsx
#	src/jobs-queue/tasks/seedCollection.ts
#	src/lib/sse/channels.ts
Comment on lines +31 to +33
const response = await fetch(`${UNSPLASH_API_BASE}/photos/${photoId}`, {
headers: unsplashHeaders(accessKey),
})
}),
}
}
if (String(url).includes('images.unsplash.com')) {
if (node.type === 'link') node.children = []
}

expect(renderCredits(value).replace(/<[^>]+>/g, '')).toBe('Photo by on ')

// No empty anchors: the exact failure mode `children: []` produced.
expect(html).not.toMatch(/<a\b[^>]*><\/a>/)
expect(html.replace(/<[^>]+>/g, '')).toBe('Photo by Jane Doe on Unsplash')
* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* fix(hero): update not-found.tsx's HeroMedia call to the new background 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

* feat(hero): migrate Pages' hero.media to hero.background

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

* feat(hero): migrate BlogPosts' heroImage to hero.background

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

* fix(hero): correct BlogPosts' listViewThumbnailPath for its single-object 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

* fix(hero): correct Pages' listViewThumbnailPath for its polymorphic media 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

* fix(og): render the root OG image with real site data via Takumi

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

* feat(og): add a per-page OG image for Pages

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

* feat(og): add a per-post OG image for BlogPosts

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

* feat(og): add an OG image for BlogTopics

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

* feat(og): add OG images for the /blog index and its pagination

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* feat(og): add generateMetadata and an OG image for ResumeDocuments

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

* feat(seed): occasionally give seeded Pages/BlogPosts a shader hero

~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

* chore: fix verification findings

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT

* fix: complete generateMeta's openGraph block, guard the shader-preset 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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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.

3 participants