Skip to content

feat(photofeature): crop hero images to their focal point - #156

Merged
RonanHevenor merged 2 commits into
mainfrom
docs/photofeature-focal-point
Sep 9, 2026
Merged

RonanHevenor merged 2 commits into
mainfrom
docs/photofeature-focal-point

Conversation

@RonanHevenor

Copy link
Copy Markdown
Member

Brainstorm, not an implementation — opened to settle the approach before anyone writes code. No code changes, one new doc: docs/photofeature-focal-point.md.

The problem

components/Article/Photofeature/ArticleHeader.tsx:43 puts the hero in a fixed h-screen box and fills it with object-cover and no object-position, so CSS crops evenly from both edges at 50% 50%.

A 3:2 photo in a 16:9 viewport loses ~25% of its height, split top and bottom. News subjects are almost never vertically centred — they sit in the upper third — so the top crop takes faces first. On a phone in portrait, a landscape hero can lose more than half its height.

The finding worth reading

media.focalX and media.focalY already exist and nothing reads them.

  • In the initial baseline — migrations/20260211_224237_initial_baseline.ts:65
  • In the production SQL path — scripts/run_deploy_sql_migrations.sh:329
  • Typed in payload-types.ts:248
  • Payload enables the focal-point selector on upload collections unless disabled (focalPoint: focalPointEnabled = true), and Media never disables it

So editors can already drag a focal point, the values already persist in both environments, and the frontend has never consumed them. The cheapest fix isn't "add a field" — it's "read the field we already have."

Options weighed

approach migration editor work main catch
A consume media.focalX/Y → object-position none none applies to the image everywhere, not photofeature-only
B photofeature-scoped override on articles 4 columns, both paths a second point to set two sources of truth
C compute via sharp attention/entropy none none finds contrast, not faces; no editor recourse
D top-biased default (50% 35%) none none crude, but free and strictly better than centre

Recommendation

A, with D as the fallback, and B only if it proves necessary.

Reading the existing focal point costs nothing and fixes every surface that crops, not just the hero. Where none is set, fall back to a top bias rather than dead centre. The article-level override is easy to add later — gradientOpacity (collections/Articles.ts:372) shows the exact pattern, and 20260331_100000_add_photofeature.ts shows the migration.

I'd skip C. Automatic cropping that can't be corrected is how you end up with a headline photo centred on a lamppost.

Open questions in the doc

  1. Is the focal-point selector actually visible in the Media admin? Config says yes; I haven't clicked through to confirm.
  2. How many existing images have a non-default focal point? If zero, option D is what actually does the work on day one.
  3. Should this extend past the hero? Section cards and the homepage crop the same way. The ask was photofeature-only, but A would improve them for free.
  4. Is 35% the right fallback, or should we pick it from heroes that currently crop badly?

🤖 Generated with Claude Code

Ronan Hevenor and others added 2 commits September 9, 2026 16:05
The photofeature hero fills a fixed h-screen box with object-cover and no
object-position, so CSS crops evenly from both edges at 50% 50%. News subjects
sit in the upper third, so the top crop takes faces first — worse the taller
and narrower the viewport gets.

Writes up four approaches with their migration cost and failure modes, and
recommends one. The material finding is that media.focalX / focalY already
exist — in the initial baseline, in the production SQL path, and in
payload-types — and Payload enables the focal-point selector on upload
collections by default, which Media never disables. Editors can already set a
focal point; nothing in the frontend has ever read it.

No code changes. Opened to settle the approach before implementing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The photofeature hero fills a fixed h-screen box with object-cover and no
object-position, so CSS cropped evenly from both edges at 50% 50%. A 3:2 photo
in a 16:9 viewport loses about a quarter of its height, split top and bottom,
and news subjects sit in the upper third — so the top crop took faces first,
worse the taller and narrower the viewport got.

media.focalX / focalY already existed and nothing read them. They are in the
initial baseline, in the production SQL path, and in payload-types, and
Payload enables the focal-point selector on upload collections by default,
which Media never disables. So editors could already set a focal point; the
frontend simply ignored it.

utils/focalPoint.ts turns that stored point into an object-position, clamped
to 0-100 and defaulting to dead centre when unset. The photofeature header
applies it to every photo it crops: the hero, author headshots, and write-in
author photos.

Reading the column was necessary but not sufficient. The article route
sanitises media through a field whitelist before handing it to the client
component — toPublicArticleMedia, and toPublicArticleUser for headshots — and
that whitelist dropped focalX/focalY, so the component always saw undefined
and fell back to centre. Both sanitisers now carry the focal point through.

Verified end to end against a real photofeature by moving the point in the
database and watching the crop follow: unset -> 50% 50%, 62/18 -> 62% 18%,
12/8 -> 12% 8%, and 150/-20 -> 100% 0% clamped.

No migration: the columns already exist in both paths.

Deliberately leaves out a top-biased default for images with no focal point.
Payload writes 50/50 for any upload whose point has never been moved, so an
untouched image cannot be told apart from a deliberately centred one, and a
blanket bias would silently override editors who meant centre. Every image in
the database currently sits at 50/50, so this changes nothing on screen until
an editor drags a point — it makes the fix possible rather than retroactively
re-framing existing photofeatures.

pnpm typecheck clean. pnpm lint 0 errors (53 pre-existing migrations/
warnings, unchanged).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RonanHevenor RonanHevenor changed the title docs(photofeature): brainstorm focal point for hero images feat(photofeature): crop hero images to their focal point Sep 9, 2026
@RonanHevenor

Copy link
Copy Markdown
Member Author

Implemented — option A

Following the brainstorm above, this is now a working change rather than a doc. The analysis is kept in docs/photofeature-focal-point.md as the record of why.

utils/focalPoint.ts turns an upload's focal point into a CSS object-position, clamped to 0–100 and defaulting to dead centre when unset. The photofeature header applies it to every photo it crops — the hero, author headshots, and write-in author photos.

The part that wasn't obvious

Reading the column was necessary but not sufficient. The article route sanitises media through a field whitelist before handing it to the client component — toPublicArticleMedia, and toPublicArticleUser for headshots — and that whitelist dropped focalX/focalY. The component always saw undefined and fell back to centre. Both sanitisers now carry the focal point through.

That's why the first end-to-end test still rendered 50% 50% after the database said 62/18.

Verified end to end

Moved the focal point in the database against a real photofeature and watched the crop follow:

stored focal point rendered
null / never set 50% 50%
62 / 18 62% 18%
12 / 8 12% 8%
150 / -20 100% 0% (clamped)

No migration

media.focalX / focalY already exist in both paths — initial baseline, production SQL, and payload-types.ts.

One thing deliberately left out

No top-biased default for images with no focal point. Payload writes 50/50 for any upload whose point has never been moved, so an untouched image can't be told apart from a deliberately centred one, and a blanket bias would silently override editors who meant centre.

Note

Every image in the database currently sits at 50/50, so this changes nothing on screen until an editor drags a focal point. It makes the fix possible and puts the control in editors' hands; it does not retroactively re-frame existing photofeatures.

Changing that default is a one-line change in focalObjectPosition if we decide overriding editor intent is the right call.

Checks

pnpm typecheck clean. pnpm lint 0 errors (53 pre-existing migrations/ warnings, unchanged).

@RonanHevenor
RonanHevenor merged commit 93a31d3 into main Sep 9, 2026
7 checks passed
@RonanHevenor
RonanHevenor deleted the docs/photofeature-focal-point branch September 9, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant