Hero Phase 4: moon/badge/image elements + resize + snap grid

Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete.

- element tray adds moon, badge, and image; property panels:
  - moon: size / glow / color
  - badge: text / background / text color / corner radius
  - image: upload (/admin/uploads, >1MB warning) / width% / alt
- corner resize handle on selected elements (image→width%, moon→size,
  text_block→box width)
- 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on
- HeroElement: image element shows an "Upload an image" placeholder until a
  source is set (a srcless image never ships live)

Verified in-browser: all five element types add + edit; moon resized 64->104px
via the handle; snap grid overlays; a published moon + badge render on the live
portal; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 08:47:05 -05:00
parent 785090eb97
commit f69c86f737
3 changed files with 179 additions and 8 deletions

View File

@@ -115,8 +115,16 @@ layout adapts across viewports without breakpoint data. `version` is validated
add/remove lines, align) and buttons editor (label/path/variant, add/remove). add/remove lines, align) and buttons editor (label/path/variant, add/remove).
Verified: select shows the line editor, editing a line updates the canvas live, Verified: select shows the line editor, editing a line updates the canvas live,
drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects. drag moved 50%→65%, add→3/delete→2 elements, empty-canvas click deselects.
- **Phase 4 — moon + badge + image + resize + snap grid.** Remaining element types, - **Phase 4 — moon + badge + image + resize + snap grid** ✅ (verified 2026-06-28).
resize handles, 8px snap. *Exit:* place a moon and an uploaded image, resize, publish. Tray adds moon/badge/image; property panels (moon: size/glow/color; badge:
text/colors/radius; image: upload/width/alt); corner resize handle (image→width%,
moon→size, text→box width); 8px snap-grid toggle with overlay; image placeholder
until a file is chosen. Verified: each type adds + edits, resize moved a moon
64→104px, snap grid shows, and a published moon+badge render on the live portal.
**Status: v1 feature-complete.** All phases verified end-to-end; ready for PR.
Deferred (noted in the design doc as follow-ups): 8-point resize (only a corner
handle for now), per-viewport layouts, server-side image compression.
## 8. Edge cases (from the doc, carried forward) ## 8. Edge cases (from the doc, carried forward)
- `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`. - `JSON.parse` wrapped in try/catch + `version` check → fall back to `DEFAULT_LAYOUT`.

View File

@@ -79,6 +79,17 @@ function Badge({ props }) {
} }
function HeroImage({ props }) { function HeroImage({ props }) {
if (!props.src) {
// Editor placeholder until an image is chosen (a srcless image never ships live).
return (
<div
className="sans"
style={{ width: 160, height: 100, display: 'grid', placeItems: 'center', border: '1px dashed var(--accent)', borderRadius: 8, color: 'var(--muted)', fontSize: '0.8rem', background: 'rgba(11,22,48,0.4)' }}
>
Upload an image
</div>
)
}
return ( return (
<img <img
src={props.src} src={props.src}

View File

@@ -20,9 +20,14 @@ function newElement(type, z) {
if (type === 'buttons') { if (type === 'buttons') {
return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } } return { ...base, y: 60, props: { align: 'center', gap: 12, items: [{ label: 'Button', to: '/', variant: 'primary' }] } }
} }
if (type === 'moon') return { ...base, props: { size: 64, glow: 0.45, color: '#eef3f8' } }
if (type === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } }
if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
return base return base
} }
const RESIZABLE = { text_block: 'width', image: 'width', moon: 'size' }
export default function HeroEditor() { export default function HeroEditor() {
const [layout, setLayout] = useState(null) const [layout, setLayout] = useState(null)
const [live, setLive] = useState(null) const [live, setLive] = useState(null)
@@ -31,6 +36,7 @@ export default function HeroEditor() {
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [snap, setSnap] = useState(false)
const teaserRef = useRef('') const teaserRef = useRef('')
const skipSave = useRef(true) const skipSave = useRef(true)
const canvasRef = useRef(null) const canvasRef = useRef(null)
@@ -127,9 +133,15 @@ export default function HeroEditor() {
} catch { } catch {
/* ignore */ /* ignore */
} }
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas
const stepY = (8 / rect.height) * 100
const move = (ev) => { const move = (ev) => {
const nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100) let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
const ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100) let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
if (snap) {
nx = Math.round(nx / stepX) * stepX
ny = Math.round(ny / stepY) * stepY
}
updateElement(el.id, { x: round2(nx), y: round2(ny) }) updateElement(el.id, { x: round2(nx), y: round2(ny) })
} }
const up = () => { const up = () => {
@@ -140,6 +152,37 @@ export default function HeroEditor() {
node.addEventListener('pointerup', up) node.addEventListener('pointerup', up)
} }
// Corner-handle resize: adjusts the type-appropriate dimension.
function onResizePointerDown(e, el) {
if (e.button !== 0) return
e.stopPropagation()
const dim = RESIZABLE[el.type]
if (!dim) return
const rect = canvasRef.current.getBoundingClientRect()
const sx = e.clientX
const orig = el.props?.[dim] ?? (dim === 'width' && el.type === 'image' ? 40 : dim === 'width' ? 600 : 64)
const node = e.currentTarget
try {
node.setPointerCapture(e.pointerId)
} catch {
/* ignore */
}
const move = (ev) => {
const dxPx = ev.clientX - sx
let val
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // %
else if (el.type === 'moon') val = clamp(orig + dxPx, 8, 400) // px
else val = clamp(orig + dxPx, 120, 1080) // text_block box px
updateProps(el.id, { [dim]: Math.round(val) })
}
const up = () => {
node.removeEventListener('pointermove', move)
node.removeEventListener('pointerup', up)
}
node.addEventListener('pointermove', move)
node.addEventListener('pointerup', up)
}
async function onUploadBg(e) { async function onUploadBg(e) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
e.target.value = '' e.target.value = ''
@@ -197,6 +240,16 @@ export default function HeroEditor() {
<span className="field-label" style={{ margin: 0 }}>Add:</span> <span className="field-label" style={{ margin: 0 }}>Add:</span>
<button onClick={() => addElement('text_block')} className="pill">+ Text</button> <button onClick={() => addElement('text_block')} className="pill">+ Text</button>
<button onClick={() => addElement('buttons')} className="pill">+ Buttons</button> <button onClick={() => addElement('buttons')} className="pill">+ Buttons</button>
<button onClick={() => addElement('moon')} className="pill">+ Moon</button>
<button onClick={() => addElement('badge')} className="pill">+ Badge</button>
<button onClick={() => addElement('image')} className="pill">+ Image</button>
<button
onClick={() => setSnap((v) => !v)}
className="pill"
style={{ marginLeft: 'auto', borderColor: snap ? 'var(--accent)' : 'var(--line)', color: snap ? 'var(--accent)' : 'var(--muted)' }}
>
Snap grid: {snap ? 'on' : 'off'}
</button>
</div> </div>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
@@ -216,6 +269,9 @@ export default function HeroEditor() {
...heroBackground(layout), ...heroBackground(layout),
}} }}
> >
{snap && (
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '8px 8px', pointerEvents: 'none', zIndex: 0 }} />
)}
{elements.map((el) => ( {elements.map((el) => (
<HeroElement <HeroElement
key={el.id} key={el.id}
@@ -223,7 +279,15 @@ export default function HeroEditor() {
editor editor
selected={el.id === selectedId} selected={el.id === selectedId}
onPointerDown={(e) => onElPointerDown(e, el)} onPointerDown={(e) => onElPointerDown(e, el)}
>
{el.id === selectedId && RESIZABLE[el.type] && (
<div
onPointerDown={(e) => onResizePointerDown(e, el)}
title="Resize"
style={{ position: 'absolute', right: -6, bottom: -6, width: 14, height: 14, borderRadius: 3, background: 'var(--accent)', border: '1px solid var(--bg-deep)', cursor: 'nwse-resize', pointerEvents: 'auto' }}
/> />
)}
</HeroElement>
))} ))}
</div> </div>
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}> <p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
@@ -308,9 +372,9 @@ function ElementPanel({ element, onProps, onRemove, onForward, onBack, onDeselec
{element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />} {element.type === 'text_block' && <TextBlockPanel element={element} onProps={onProps} />}
{element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />} {element.type === 'buttons' && <ButtonsPanel element={element} onProps={onProps} />}
{!['text_block', 'buttons'].includes(element.type) && ( {element.type === 'moon' && <MoonPanel element={element} onProps={onProps} />}
<p className="sans dim" style={{ fontSize: '0.82rem' }}>Editing controls for this element type arrive in the next phase.</p> {element.type === 'badge' && <BadgePanel element={element} onProps={onProps} />}
)} {element.type === 'image' && <ImagePanel element={element} onProps={onProps} />}
<div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}> <div style={{ display: 'flex', gap: 8, borderTop: '1px solid var(--line)', paddingTop: 12 }}>
<button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button> <button onClick={onBack} className="pill" style={{ fontSize: '0.8rem' }}>Send back</button>
@@ -408,3 +472,91 @@ function ButtonsPanel({ element, onProps }) {
</div> </div>
) )
} }
const swatch = { width: '100%', height: 38, padding: 2, border: '1px solid var(--line)', borderRadius: 6, background: 'var(--bg)' }
function MoonPanel({ element, onProps }) {
const p = element.props || {}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label>
<span className="field-label">Size {p.size || 64}px</span>
<input type="range" min="8" max="160" step="1" value={p.size || 64} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
</label>
<label>
<span className="field-label">Glow {Math.round((p.glow ?? 0.45) * 100)}%</span>
<input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.45} onChange={(e) => onProps({ glow: Number(e.target.value) })} style={{ width: '100%' }} />
</label>
<label>
<span className="field-label">Color</span>
<input type="color" value={hexOf(p.color)} onChange={(e) => onProps({ color: e.target.value })} style={{ ...swatch, width: 48 }} />
</label>
</div>
)
}
function BadgePanel({ element, onProps }) {
const p = element.props || {}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label>
<span className="field-label">Text</span>
<input className="input" value={p.text || ''} onChange={(e) => onProps({ text: e.target.value })} />
</label>
<div style={{ display: 'flex', gap: 12 }}>
<label style={{ flex: 1 }}>
<span className="field-label">Background</span>
<input type="color" value={hexOf(p.bgColor)} onChange={(e) => onProps({ bgColor: e.target.value })} style={swatch} />
</label>
<label style={{ flex: 1 }}>
<span className="field-label">Text color</span>
<input type="color" value={hexOf(p.textColor)} onChange={(e) => onProps({ textColor: e.target.value })} style={swatch} />
</label>
</div>
<label>
<span className="field-label">Corner radius {Math.min(p.borderRadius ?? 999, 24)}px</span>
<input type="range" min="0" max="24" step="1" value={Math.min(p.borderRadius ?? 999, 24)} onChange={(e) => onProps({ borderRadius: Number(e.target.value) })} style={{ width: '100%' }} />
</label>
</div>
)
}
function ImagePanel({ element, onProps }) {
const p = element.props || {}
const [up, setUp] = useState(false)
async function onFile(e) {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
if (f.size > 1024 * 1024 && !confirm('This image is over 1 MB and may slow the page. Upload anyway?')) return
setUp(true)
try {
const { url } = await api.admin.upload(f)
onProps({ src: url })
} catch {
/* ignore */
} finally {
setUp(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<span className="field-label">Image</span>
{p.src && <img src={p.src} alt="" style={{ width: '100%', maxHeight: 90, objectFit: 'contain', borderRadius: 6, border: '1px solid var(--line)', marginBottom: 8 }} />}
<label className="btn btn-ghost btn-sq" style={{ display: 'inline-block', cursor: 'pointer' }}>
{up ? 'Uploading…' : p.src ? 'Replace' : 'Upload'}
<input type="file" accept="image/*" onChange={onFile} hidden disabled={up} />
</label>
</div>
<label>
<span className="field-label">Width {p.width || 40}%</span>
<input type="range" min="10" max="100" step="1" value={p.width || 40} onChange={(e) => onProps({ width: Number(e.target.value) })} style={{ width: '100%' }} />
</label>
<label>
<span className="field-label">Alt text</span>
<input className="input" value={p.alt || ''} onChange={(e) => onProps({ alt: e.target.value })} />
</label>
</div>
)
}