diff --git a/web/ColorField.tsx b/web/ColorField.tsx
new file mode 100644
index 0000000..979ae63
--- /dev/null
+++ b/web/ColorField.tsx
@@ -0,0 +1,115 @@
+import { useEffect, useRef, useState } from 'react';
+import { Input } from '@/components/ui/input';
+
+/**
+ * A colour, chosen without leaving the panel.
+ *
+ * ── Why not `` ──
+ *
+ * It was one, and the browser's own picker is a window-level popup positioned by the browser. In a
+ * panel near the right edge it opened off the side of the window and was simply unreachable — nothing
+ * in CSS can move it, because it is not in the document.
+ *
+ * ── What replaces it ──
+ *
+ * Swatches and a hex field, in a panel anchored to the RIGHT of its trigger so it grows inward. Text
+ * over a photograph is white or black almost every time; the field is there for the times it is not.
+ *
+ * Deliberately not a gradient area and a hue strip. That is what the native control already does well,
+ * and rebuilding it badly to fix a positioning problem would be a worse control than the one being
+ * replaced.
+ */
+
+/** Enough to cover a photograph, and short enough to read as choices rather than as a palette. */
+const PRESETS = [
+ '#ffffff',
+ '#f5f5f5',
+ '#cccccc',
+ '#888888',
+ '#333333',
+ '#000000',
+ '#f0c808',
+ '#e05a3a',
+];
+
+export const ColorField = ({ value, onChange }: { value: string; onChange: (color: string) => void }) => {
+ const [open, setOpen] = useState(false);
+ const [draft, setDraft] = useState(value);
+ const wrapper = useRef(null);
+
+ // Follows the value when it changes elsewhere — a different block being selected, or a load.
+ useEffect(() => setDraft(value), [value]);
+
+ /*
+ Close on a press anywhere else.
+
+ `pointerdown` rather than `click`, so it closes on the way down and a press meant for something
+ behind the panel does not land twice. Capture, because a control inside a panel below may stop
+ propagation for its own reasons.
+ */
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (event: PointerEvent) => {
+ if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener('pointerdown', onDown, true);
+ return () => document.removeEventListener('pointerdown', onDown, true);
+ }, [open]);
+
+ const commit = (next: string) => {
+ const trimmed = next.trim();
+ const full = /^#?[0-9a-fA-F]{6}$/.test(trimmed) ? `#${trimmed.replace('#', '')}`.toLowerCase() : null;
+ if (full) onChange(full);
+ };
+
+ return (
+
+ );
+};
diff --git a/web/OverlayEditor.tsx b/web/OverlayEditor.tsx
index 488e8f4..b5b84d9 100644
--- a/web/OverlayEditor.tsx
+++ b/web/OverlayEditor.tsx
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
+import { ColorField } from './ColorField';
/**
* Text laid over a picture, positioned by dragging it.
@@ -225,15 +226,9 @@ export const OverlayEditor = ({
- {/* 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. */}
- update(index, { color: event.currentTarget.value })}
- className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
- title="Colour"
- />
+ {/* Not ``. The browser's picker is a window-level popup and opened off
+ the side of the window in a panel near the right edge — see ColorField. */}
+ update(index, { color })} />