Skip to content

Develop - #34

Open
danielheene wants to merge 296 commits into
mainfrom
develop
Open

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

Conversation

@danielheene

Copy link
Copy Markdown
Owner

No description provided.

danielheene and others added 30 commits July 13, 2026 17:05
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>
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>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
react-pdf font loading no longer depends on process.env.SERVER_URL /
HTTP fetch at render time -- fonts are read from local files bundled
in each @danielheene/font-pp-* package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Accidentally committed as an empty file in the previous commit while
staging an unrelated font-package migration; restoring the pre-existing
config content here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deletes web/src/fonts/*.ts and the four duplicated font asset
directories under web/public/fonts/ now that every consumer (web
Next.js layouts, react-pdf, Storybook) imports fonts from the
@danielheene/font-pp-* workspace packages instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 7's commit accidentally renamed storybook/package.json's name
field to @danielheene/storybook, which wasn't requested and breaks
.changeset/config.json's fixed version group (which matches packages
by their literal "storybook" name). Reverting the name only; the
font-pp-frama workspace dependency addition from Task 7 is kept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Final review flagged these 8 font files were tracked with the
executable bit set (100755), inconsistent with the identical-nature
files in the other 3 sibling packages (100644) — cosmetic artifact of
the original source files' permissions on disk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Daniel Heene <daniel@heene.io>
…stem

- Remove unused ProjectList field components and styles
- Remove unused collection/global data fetchers
- Remove unused resume CSS customizations
- Replace generateDocumentThumbnail task with generateDocumentThumbnails (supports multiple thumbnails)
- Update Icon component ref type from IconifyIcon to SVGSVGElement
- Update middleware to proxy latest resume document requests
- Migrate PDF builders to new fetcher functions
- Clean up package.json dependencies and resolutions
- Fix formatting in Link component

Signed-off-by: Daniel Heene <daniel@heene.io>
- Remove UmamiControlBar widget and date picker modal
- Remove all Umami widget components (Stats, PageViews, Events, Paths)
- Remove UmamiCharts context provider and related hooks
- Clean up widget

Signed-off-by: Daniel Heene <daniel@heene.io>
Add a complete testing environment to a previously untested repo:

- Vitest with native tsconfig-paths resolution, v8 coverage, and shared
  mocks (payload via importOriginal spread, next/cache, redis) plus a
  forced TZ=UTC in vitest.setup.ts
- 141 unit tests across 21 colocated *.test.ts files covering lib
  utilities, date classes, i18n, JSON-LD generators, access control and
  Payload field hooks (latent quirks pinned with comments, not fixed)
- Playwright chromium smoke suite in e2e/ with a committed, format-valid
  .env.test so the app boots against the zod env gate; runnable locally
  (auto dev server) and via the version-matched Playwright Docker image
  with host networking (scripts/e2e-docker.sh)
- test/test:watch/test:coverage/test:e2e/test:e2e:ui/test:e2e:docker
  scripts, a unit-test CI workflow on Node 26, and rewritten testing
  docs in AGENTS.md/README.md replacing the never-implemented
  node --test convention

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt Next.js Cache Components (cacheComponents: true) manually on
16.2.12:

- migrate the eight unstable_cache fetchers to 'use cache' functions
  with cacheTag/cacheLife('max'); revalidate helpers stay on
  revalidateTag so existing Payload hooks keep working
- switch the root layout and generateMeta to the cached fetcher
  variants
- split page queries into a cached published variant and a dynamic
  draft variant branched on draftMode(), replacing the await headers()
  force-dynamic hack ([slug], blog/post/[slug], FeaturedTopics)
- guard every generateStaticParams with a '__placeholder__' fallback so
  builds against an empty database still validate
- defer /api/service-status to request time via connection() and drop
  the runtime segment config from the SSE route (incompatible with
  cacheComponents)

Also fixes two pre-existing production build blockers: mediabunny is
now in serverExternalPackages (Turbopack inlined its WASM as a template
string with invalid octal escapes), and the unused WIP
StaticMeshGradient shader is excluded from type checking. All 23 pages
prerender; CMS routes land as Partial Prerender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blog topic/post infrastructure

- Move video thumbnail extraction from inline afterChange hook to queued GenerateVideoThumbnails task for faster uploads and retry support
- Fix ImageMedia aspect-ratio calculation to output ratio (width/height) instead of template string
- Add ArticleSidebar component with scroll-spy TableOfContents for blog post navigation
- Export VideoMedia component with thumbnail poster and blurred placeholder support
- Add blog topic access fix (remove authenticatedOrPublished check that crashed on missing _status field)
- Add seed:blog scripts for content generation
- Update blog list page with PostCard grid, pagination, and topic filtering
- Clean up Link component unused imports and variable names

Signed-off-by: Daniel Heene <daniel@heene.io>
Adds a searchable icon picker backed by the project's self-hosted Iconify
API, in two forms sharing one panel component:

- IconPickerField: a text field storing `prefix:name`, edited through a
  preview swatch, manual input and a browsable grid
- IconPickerFeature: a Lexical toolbar button and /icon slash command
  inserting an inline icon node

The client node is standalone rather than extending the server node:
Lexical matches a serialized node's type against the registered class by
identity, so two classes sharing the `icon` type break loading saved
documents. The insert command lives in its own module for the same
reason — importing it from the server node would pull that class into
the browser bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default converters only handle Payload's built-in `media` collection,
so this project's `images`/`videos` uploads rendered as nothing. Populated
upload values omit `mimeType`, so the node's target collection is used as
the discriminator instead.

- images render through ImageMedia, videos through VideoMedia with their
  generated poster frame and blur-up placeholder
- links resolve via the custom LinkField shape (nested under `fields.link`,
  keyed by `type`), which LinkJSXConverter cannot read — hrefs were empty
- CodeBlock renders through the existing CodeBlockRenderer
- headings emit slugified ids so a table of contents can anchor to them
- inline icon nodes from the IconPicker feature

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resume PDFs and their page thumbnails now live in the existing media
collections, leaving this one unused. Its 138 documents were deleted
through the Local API so the S3 storage adapter removed the underlying
files rather than orphaning them in the bucket.

Also drops the commented-out thumbnail block in
generateLocalizedResumeDocument, superseded by the
GenerateDocumentThumbnails task already used above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a hidden `document-references` table recording which documents use
which, so "is this still referenced?" is one indexed query instead of a
per-collection scan whose field paths differ everywhere.

Capture happens at the collection level rather than in field hooks: field
hooks miss references inside Lexical rich text and never fire on delete.
Walking the saved document also means blocks, arrays, tabs and rich text
are covered without enumerating paths per entity, and it works before
every schema migrates to MediaField.

Three reference shapes are handled — polymorphic values, Lexical
upload/relationship nodes, and monomorphic relationships, whose bare id
carries no type and is resolved through the field config.

Tracking covers every collection; delete-guarding defaults to media only,
where it replaces a hand-rolled check that scanned one hardcoded field
path inside a try/catch that swallowed errors into "no references".
Blocked deletes now name the referencing documents and paths.

Backfill with `pnpm refs:backfill`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
danielheene and others added 11 commits August 20, 2026 18:59
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')
danielheene and others added 18 commits August 23, 2026 00:35
* 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>
…eld tests

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
`payload generate:types` adds the new required `linkType` field to
`LinkFieldData` and drops `resolvedLabel`/`appearance`, matching the
LinkField rewrite from earlier commits.

Making `linkType` required in the generated types surfaced two more
call sites the plan's file list didn't cover, both writing hand-built
LinkFieldData literals that never pass through the CMS:

- `src/lib/seed/lexical.ts`'s `link()` seed helper (used by
  `pages.ts`/`posts.ts`) was missing `linkType` entirely, same gap
  Task 6 fixed in `pages.ts`'s inline literal. Added `linkType: 'url'`
  (this helper only ever builds custom-URL links) and updated its
  doc comment, which described the field shape before this refactor.
- `src/components/Footer/Footer.tsx`'s three social-link literals
  (email/telephone/sameAs) all set `url`, never `reference` — added
  `linkType: 'url'` to each.
- `src/components/Link/index.test.tsx` (added in an earlier commit)
  needed `linkType="url"` on its two CMSLink usages.

Updated `lexical.test.ts`'s shape assertion to include `linkType`.

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

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

The final-review fix pass added a reference-before-url precedence check
using bare truthiness (`if (siblingData?.reference)`), but resolveLinkTarget
(the function that actually decides the rendered href) gates on a fully
populated reference: `reference?.relationTo && reference.value`. These
diverged on a half-populated reference alongside a populated url — the
doc comment's claim that the inferred mode "always agrees with which
target the frontend actually renders" wasn't literally true for that case.

Narrow the reference check to the same relationTo-and-value gate, and add
a test proving a half-populated reference correctly falls through to 'url'
inference rather than getting stuck in an empty reference-mode UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
…t nested `fields.link`

`RichText/index.tsx`'s `link` converter override read `node.fields.link` —
a nested sub-key that has never existed for this project's link nodes.
`LinkFeature({ fields: [...LinkField().fields] })` spreads `LinkField`'s
*inner* fields directly onto the node's `fields` object (lexical's
`transformExtraFields` replaces its base fields with these rather than
merging them under a key), so `fields` was always the flat
`{ linkType, reference, url, newTab, iconBefore, label, iconAfter,
iconOnly }` shape — never `{ link: {...} }`.

The bug predates the LinkField simplification: `creditsRendering.test.tsx`
already documented it as "a pre-existing bug in shared rendering
infrastructure" it deliberately didn't assert against. Every link in every
rich text field — human-authored ones included — silently rendered as
plain text instead of an anchor.

Extracted the converter into its own module, `linkConverter.tsx`, reading
`node.fields` directly through the same `resolveLinkTarget` helper CMSLink
uses. Splitting it out (rather than testing through `RichText/index.tsx`)
sidesteps that module's `blocks.CodeBlock` converter, which transitively
imports `@payloadcms/ui`'s `CopyIcon` — a `.scss` import Vitest's Node
transform can't load — the same class of bundler-only-import problem
`src/fields/Icon/index.ts`'s module comment describes.

Added `linkConverter.test.tsx`, driving Payload's real
`convertLexicalNodesToJSX` + `defaultJSXConverters` over hand-built link
nodes in the actual flat shape, proving a real `<a>` renders (with and
without `newTab`) and that an unresolvable target still falls back to bare
children. Updated `creditsRendering.test.tsx`'s scope-note comment, which
had documented the bug as deliberately unfixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Picks up the JSDoc comments payload generate:types derives from
admin.description on iconOnly and label (restored in caa0fcd) — these
weren't present in the types committed alongside that fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjuWYPbCQ16C3Fkc1k8grT
Removed outdated `service-status` API route, unused migrations, and redundant `ScheduledResumeGenerationWidget` alongside its helper modules and tests. Deleted unused `ChecksumValidator` export. Cleaned up `package.json` scripts and updated dependencies. Simplified codebase by consolidating link field migration naming tests.

Signed-off-by: Daniel Heene <daniel@heene.io>
…ils and config

Each shader component is now in its own self-contained folder:

  src/components/Shaders/
  ├── DarkVeil/
  │   ├── DarkVeil.tsx        (component — moved)
  │   ├── DarkVeil.stories.tsx (moved)
  │   ├── config.ts           (new: curated hero defaultProps)
  │   ├── thumbnail.png       (new: static placeholder — replace with screenshot)
  │   └── index.ts            (re-exports component, types, defaultProps)
  ├── FaultyTerminal/  (same structure)
  ├── GradientBlinds/  (same structure)
  └── Grainient/       (same structure)

shaderPresets.ts: replace gradient string with statically imported
StaticImageData thumbnail — enables pixel-accurate previews in OG
images and blog list cards without any WebGL context.

ShaderHeroBackground.tsx: import defaultProps from each shader's
config.ts instead of inline bundles.

shaderComponents.ts (admin picker): use defaultProps from config.ts
for DarkVeil/GradientBlinds/Grainient; keep FaultyTerminal's admin
preview props (green tint, low intensity) inline with a comment.

opengraph-image.tsx (pages + posts): render thumbnail as <img> instead
of CSS background gradient.

BlogListPage.tsx: render thumbnail via next/image <Image> instead of
a styled div with a CSS gradient background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019J1SZPc1FVHu11Gsypd3SL
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