text over the header photograph, placed by dragging it

The wireframe puts two blocks of text on the header picture. The page had one `phrase`, drawn under it.

`header_overlays` is a jsonb list — `[{ text, x, y, size, color, align }]` — rather than a pair of
`header_text_1_*` columns. The design asks for two today; a third is then data rather than a migration,
and nothing has to decide what an empty second block means. `headerPhrase` is left alone: folding them
together would silently move somebody's existing copy on top of a photograph.

Everything is relative and has to be. x and y are percentages of the picture, so a position means the
same thing on a phone and a desktop. `size` is a percentage of the picture's WIDTH, because a headline
set at 40px is a headline on a laptop and a caption on a large screen. The preview renders that with
container query units against the picture's own box, which is exactly what the percentage means — the
page will do the same, so the two cannot drift.

The editor is the picture with the text on it, at the shape the page uses, because where a line sits on
a photograph is not a decision anybody can make from two number fields.

The API clamps every field rather than rejecting: a position into the picture, a size into something
legible, the colour to a hex triple. A caller sending nonsense gets a usable overlay instead of a 400,
and the page can never be handed a position that puts text off the edge of its own header.

Pushed to both databases and verified by querying information_schema rather than by reading db:push's
exit code, which is documented to lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 00:19:30 +01:00
co-authored by Claude Opus 5
parent 5fb447cf21
commit d1e79d95db
2 changed files with 211 additions and 14 deletions
+181
View File
@@ -0,0 +1,181 @@
import { useRef } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/**
* Text laid over a picture, positioned by dragging it.
*
* ── Why it is drawn on the picture rather than listed beside it ──
*
* The thing being decided is where a line of text sits on a photograph, and that is not a decision
* anybody can make from two number fields. The editor is the picture with the text on it, at the shape
* the page uses, so what is arranged is what is published.
*
* ── Everything is relative, and has to be ──
*
* `x` and `y` are percentages of the picture, so a position means the same thing on a phone and a
* desktop. `size` is a percentage of the picture's WIDTH rather than pixels, for the same reason: a
* headline set at 40px is a headline on a laptop and a caption on a large screen.
*
* The preview renders that with container query units — `cqw` — against the box, which is exactly what
* the percentage means. The page will do the same, so the two cannot drift.
*/
export type Overlay = {
text: string;
/** Percent of the picture. The text's own anchor corner, per `align`. */
x: number;
y: number;
/** Percent of the picture's width. */
size: number;
color: string;
align: 'left' | 'center' | 'right';
};
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, size: 4, color: '#ffffff', align: 'left' });
export const OverlayEditor = ({
overlays,
onChange,
children,
}: {
overlays: Overlay[];
onChange: (overlays: Overlay[]) => void;
/** The picture. Rendered underneath, at whatever shape its own slot decides. */
children: React.ReactNode;
}) => {
const box = useRef<HTMLDivElement | null>(null);
const dragging = useRef<{ index: number; dx: number; dy: number } | null>(null);
const update = (index: number, patch: Partial<Overlay>) =>
onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay)));
const onPointerDown = (event: React.PointerEvent, index: number) => {
const rect = box.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
(event.target as Element).setPointerCapture?.(event.pointerId);
// The grab offset, so the text does not jump its own corner under the cursor on the first move.
dragging.current = {
index,
dx: ((event.clientX - rect.left) / rect.width) * 100 - overlays[index]!.x,
dy: ((event.clientY - rect.top) / rect.height) * 100 - overlays[index]!.y,
};
};
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragging.current;
const rect = box.current?.getBoundingClientRect();
if (!drag || !rect) return;
update(drag.index, {
x: Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100 - drag.dx)),
y: Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100 - drag.dy)),
});
};
return (
<div className="flex flex-col gap-3">
{/*
`@container` so the overlays can be sized in `cqw` — a percentage of THIS box rather than of the
viewport. The picture is the thing text should scale with, and the panel is not the page width.
*/}
<div ref={box} className="@container relative" onPointerMove={onPointerMove} onPointerUp={() => (dragging.current = null)}>
{children}
{overlays.map((overlay, index) => (
<div
key={index}
onPointerDown={(event) => onPointerDown(event, index)}
className="absolute cursor-grab touch-none select-none whitespace-pre-wrap active:cursor-grabbing"
style={{
left: `${overlay.x}%`,
top: `${overlay.y}%`,
// The anchor follows the alignment, so a right-aligned block grows leftwards from where
// it was put rather than sliding out of the picture.
transform: overlay.align === 'right' ? 'translateX(-100%)' : overlay.align === 'center' ? 'translateX(-50%)' : undefined,
fontSize: `${overlay.size}cqw`,
color: overlay.color,
textAlign: overlay.align,
lineHeight: 1.15,
// Only so the text stays readable while it is being placed over a busy photograph. The
// page draws it without one — this is scaffolding, not styling.
textShadow: '0 1px 3px rgba(0,0,0,.45)',
}}
>
{overlay.text || 'Text'}
</div>
))}
</div>
{/* The controls, under the picture rather than floating over it. Two blocks means two rows, and a
popover per block would hide the thing being compared. */}
<div className="flex flex-col gap-2">
{overlays.map((overlay, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border border-border p-2">
<Input
value={overlay.text}
placeholder="Text"
className="min-w-0 flex-1"
onChange={(event) => update(index, { text: event.currentTarget.value })}
/>
{/* Size as a number the person can reason about — 4 means "a twenty-fifth of the picture
wide" is not a thought anybody has, so it is presented as a plain step. */}
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.max(0.5, Math.round((overlay.size - 0.5) * 10) / 10) })}>
−
</Button>
<span className="w-8 text-center text-xs tabular-nums text-muted-foreground">{overlay.size}</span>
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.min(20, Math.round((overlay.size + 0.5) * 10) / 10) })}>
+
</Button>
</div>
<select
value={overlay.align}
onChange={(event) => update(index, { align: event.currentTarget.value as Overlay['align'] })}
className="h-8 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="left">Left</option>
<option value="center">Centre</option>
<option value="right">Right</option>
</select>
{/* A native colour input: it is the one control every platform already knows how to draw,
and a hand-rolled picker here would be worse on every one of them. */}
<input
type="color"
value={overlay.color}
onChange={(event) => update(index, { color: event.currentTarget.value })}
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
title="Colour"
/>
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onChange(overlays.filter((_, i) => i !== index))}
title="Remove"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start"
onClick={() => onChange([...overlays, emptyOverlay()])}
disabled={overlays.length >= 8}
>
<Plus className="size-3.5" />
Add text
</Button>
</div>
</div>
);
};
+19 -3
View File
@@ -7,6 +7,7 @@ import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { RichText } from '../RichText';
import { MediaField } from '../MediaField';
import { OverlayEditor, type Overlay } from '../OverlayEditor';
import { useSaveTarget } from '../SaveTarget';
/**
@@ -40,7 +41,14 @@ import { useSaveTarget } from '../SaveTarget';
*/
type About = {
header: { url: string | null; crop: string | null; fit: string | null; phrase: string | null };
header: {
url: string | null;
crop: string | null;
fit: string | null;
phrase: string | null;
/** Text laid over the picture — the wireframe's two blocks. */
overlays: Overlay[];
};
about_text: string | null;
product_video_url: string | null;
product_text: string | null;
@@ -56,6 +64,7 @@ export const AboutPanel = () => {
const [headerUrl, setHeaderUrl] = useState('');
const [phrase, setPhrase] = useState('');
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
const [overlays, setOverlays] = useState<Overlay[]>([]);
const [aboutText, setAboutText] = useState('');
const [productVideo, setProductVideo] = useState('');
const [productText, setProductText] = useState('');
@@ -69,6 +78,7 @@ export const AboutPanel = () => {
setAbout(data);
setHeaderUrl(data.header.url ?? '');
setHeaderCrop(data.header.crop);
setOverlays(data.header.overlays ?? []);
setPhrase(data.header.phrase ?? '');
setAboutText(data.about_text ?? '');
setProductVideo(data.product_video_url ?? '');
@@ -95,6 +105,7 @@ export const AboutPanel = () => {
const edited = {
header_url: orNull(headerUrl),
header_crop: headerCrop,
header_overlays: overlays,
header_phrase: orNull(phrase),
about_text: orNull(aboutText),
product_video_url: orNull(productVideo),
@@ -105,6 +116,7 @@ export const AboutPanel = () => {
const stored = about && {
header_url: about.header.url,
header_crop: about.header.crop,
header_overlays: about.header.overlays ?? [],
header_phrase: about.header.phrase,
about_text: about.about_text,
product_video_url: about.product_video_url,
@@ -148,12 +160,15 @@ export const AboutPanel = () => {
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
have to agree or the admin shows a shape the site does not have, which is worse than showing
no shape at all: it looks decided. */}
{/* The picture is the editor's canvas: the overlays are positioned against the same box the
page draws, so what is arranged here is what is published. */}
<OverlayEditor overlays={overlays} onChange={setOverlays}>
<MediaField
value={headerUrl || null}
onChange={(path) => {
setHeaderUrl(path ?? '');
// A framing belongs to the picture it was made for. Keeping it across a change would apply
// one photograph's decision to another one's composition.
// A framing belongs to the picture it was made for. Keeping it across a change would
// apply one photograph's decision to another one's composition.
setHeaderCrop(null);
}}
crop={headerCrop}
@@ -161,6 +176,7 @@ export const AboutPanel = () => {
aspect="21/9"
label="header photograph"
/>
</OverlayEditor>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Phrase</label>