zoom and pan the lead photograph

The Shape control it got last commit did nothing, and the reason is the trap that
file already warns about: `contentFor` is a per-kind allowlist and everything it
does not name is dropped. `subject-cover` was not in it, so the panel wrote a
ratio, got a 200, and the API stored `{}`. Its own comment says that is "exactly
what silently threw `ratio` away when the admin first sent one".

Now it holds a ratio and a crop, and the block gets the same framing surface the
About Us hero has.

Two things it does NOT get, both deliberate:

  No Change and no Remove — `fixed` on MediaField. The picture is the subject's
  first carousel item and this block cannot store a different one. Framing is its
  decision; which photograph is not.

  No local knowledge of which picture that is. The site answers
  `/pages/:id/subject-lead`, because which template draws which section, which row
  is the example and which of its pictures leads are four facts that live in
  templates.ts — a second copy here is how the two drift when a fifth template
  arrives.

And it says out loud that the crop applies to every row the template draws. It is
framed against one photograph and used on all of them, which is the one
surprising thing about cropping a template rather than a page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:19:04 +01:00
co-authored by Claude Opus 5
parent 2aff4a130f
commit c6cebc5000
3 changed files with 104 additions and 21 deletions
+36 -21
View File
@@ -101,6 +101,7 @@ export const MediaField = ({
aspect,
kind = 'photo',
label,
fixed = false,
}: {
/** A path under `originals/`, or null. */
value: string | null;
@@ -114,6 +115,14 @@ export const MediaField = ({
kind?: 'photo' | 'video';
/** What the picker's title says, so it names the thing rather than saying "media". */
label?: string;
/**
* The picture is not this field's to change — only to frame.
*
* For a slot whose image comes from somewhere else: the subject's lead photograph is the first item
* of its carousel, so Change and Remove here would offer to do something this block cannot store.
* The zoom and pan stay, because framing IS the block's own decision.
*/
fixed?: boolean;
}) => {
const [picking, setPicking] = useState(false);
const [over, setOver] = useState(false);
@@ -382,27 +391,33 @@ export const MediaField = ({
</div>
)}
<Button
size="sm"
variant="secondary"
className="h-7 gap-1 px-2 text-xs shadow"
onClick={() => setPicking(true)}
>
<Pencil className="size-3" />
Change
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 px-2 text-xs shadow hover:text-destructive"
onClick={() => {
setBroken(false);
onChange(null);
}}
title="Remove"
>
<Trash2 className="size-3" />
</Button>
{/* Framing is this block's decision; WHICH picture is not, when the picture comes from
somewhere else. See `fixed`. */}
{!fixed && (
<>
<Button
size="sm"
variant="secondary"
className="h-7 gap-1 px-2 text-xs shadow"
onClick={() => setPicking(true)}
>
<Pencil className="size-3" />
Change
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 px-2 text-xs shadow hover:text-destructive"
onClick={() => {
setBroken(false);
onChange(null);
}}
title="Remove"
>
<Trash2 className="size-3" />
</Button>
</>
)}
</div>
)}
</div>