Hero editor: larger faithful canvas + real moon from the hero art

Addresses feedback that the editor was too small/cramped (elements overlapping)
and that the generated CSS moon looked bad.

- AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap)
- HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the
  column, capped at ~66vh). Because viewport-unit fonts and % positions scale
  together, the canvas is now a faithful miniature of the live hero — the default
  text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid;
  resize math is scale-aware.
- Moon element now renders the actual moon cropped from the hero artwork
  (client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS
  dot; MoonPanel exposes size + glow.

Verified: at 1440px the canvas is ~785x442 with the panel beside it, default
elements don't overlap, and the moon loads the real image. Build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 09:13:22 -05:00
parent f69c86f737
commit 73eac2a138
4 changed files with 75 additions and 38 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import MoonDot from './MoonDot.jsx'
const MOON_IMAGE = '/assets/img/hero-moon.png'
// Font family tokens a line may opt into; default is the page serif. // Font family tokens a line may opt into; default is the page serif.
const FONT = { display: 'var(--display)', sans: 'var(--sans)' } const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
@@ -105,8 +106,23 @@ function content(element) {
return <TextBlock props={element.props || {}} /> return <TextBlock props={element.props || {}} />
case 'buttons': case 'buttons':
return <Buttons props={element.props || {}} /> return <Buttons props={element.props || {}} />
case 'moon': case 'moon': {
return <MoonDot size={element.props?.size || 48} glow={element.props?.glow ?? 0.45} color={element.props?.color} /> const size = element.props?.size || 96
const glow = element.props?.glow ?? 0.45
return (
<img
src={MOON_IMAGE}
alt=""
draggable={false}
style={{
width: size,
height: 'auto',
display: 'block',
filter: glow ? `drop-shadow(0 0 ${size * 0.45}px rgba(216,226,239,${glow}))` : undefined,
}}
/>
)
}
case 'badge': case 'badge':
return <Badge props={element.props || {}} /> return <Badge props={element.props || {}} />
case 'image': case 'image':

View File

@@ -41,6 +41,8 @@ export default function AdminLayout() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const title = TITLES[location.pathname] || 'Admin' const title = TITLES[location.pathname] || 'Admin'
// The hero canvas editor needs room — let it use the full content width.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)' const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt). // Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
@@ -147,7 +149,7 @@ export default function AdminLayout() {
</div> </div>
</header> </header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 1000, width: '100%' }}> <div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: wide ? 'none' : 1000, width: '100%' }}>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -20,7 +20,7 @@ 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 === 'moon') return { ...base, props: { size: 96, glow: 0.5 } }
if (type === 'badge') return { ...base, props: { text: 'New badge', bgColor: '#1a2d4a', textColor: '#c2d2e6', borderRadius: 999 } } 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: '' } } if (type === 'image') return { ...base, props: { src: '', width: 40, alt: '' } }
return base return base
@@ -37,9 +37,27 @@ export default function HeroEditor() {
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 [snap, setSnap] = useState(false)
const [scale, setScale] = useState(1)
const teaserRef = useRef('') const teaserRef = useRef('')
const skipSave = useRef(true) const skipSave = useRef(true)
const canvasRef = useRef(null) const canvasRef = useRef(null) // the 1280x720 stage (scaled to fit)
const colRef = useRef(null) // measures available width
// Render the canvas as a scaled 1280x720 stage so it's a faithful miniature of
// the live hero (viewport-unit fonts + % positions all scale together).
useEffect(() => {
const el = colRef.current
if (!el) return
const recompute = () => setScale(Math.min(el.clientWidth / 1280, (window.innerHeight * 0.66) / 720))
recompute()
const ro = new ResizeObserver(recompute)
ro.observe(el)
window.addEventListener('resize', recompute)
return () => {
ro.disconnect()
window.removeEventListener('resize', recompute)
}
}, [loading])
useEffect(() => { useEffect(() => {
let active = true let active = true
@@ -133,8 +151,8 @@ export default function HeroEditor() {
} catch { } catch {
/* ignore */ /* ignore */
} }
const stepX = (8 / rect.width) * 100 // 8px snap, as % of canvas const stepX = (8 / 1280) * 100 // 8px snap on the 1280x720 stage, as %
const stepY = (8 / rect.height) * 100 const stepY = (8 / 720) * 100
const move = (ev) => { const move = (ev) => {
let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100) let nx = clamp(ox + ((ev.clientX - sx) / rect.width) * 100, 0, 100)
let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100) let ny = clamp(oy + ((ev.clientY - sy) / rect.height) * 100, 0, 100)
@@ -169,10 +187,11 @@ export default function HeroEditor() {
} }
const move = (ev) => { const move = (ev) => {
const dxPx = ev.clientX - sx const dxPx = ev.clientX - sx
const dxLogical = dxPx / scale // client px → stage px
let val let val
if (el.type === 'image') val = clamp(orig + (dxPx / rect.width) * 100, 5, 100) // % 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 if (el.type === 'moon') val = clamp(orig + dxLogical, 24, 400) // px
else val = clamp(orig + dxPx, 120, 1080) // text_block box px else val = clamp(orig + dxLogical, 120, 1180) // text_block box px
updateProps(el.id, { [dim]: Math.round(val) }) updateProps(el.id, { [dim]: Math.round(val) })
} }
const up = () => { const up = () => {
@@ -253,24 +272,26 @@ export default function HeroEditor() {
</div> </div>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 460px', minWidth: 320 }}> <div ref={colRef} style={{ flex: '1 1 620px', minWidth: 320 }}>
<div style={{ position: 'relative', width: 1280 * scale, height: 720 * scale, maxWidth: '100%', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--line)', background: 'var(--bg-deep)' }}>
<div <div
ref={canvasRef} ref={canvasRef}
onPointerDown={(e) => { onPointerDown={(e) => {
if (e.target === e.currentTarget) setSelectedId(null) if (e.target === e.currentTarget) setSelectedId(null)
}} }}
style={{ style={{
position: 'relative', position: 'absolute',
width: '100%', top: 0,
aspectRatio: '16 / 9', left: 0,
borderRadius: 10, width: 1280,
overflow: 'hidden', height: 720,
border: '1px solid var(--line)', transformOrigin: 'top left',
transform: `scale(${scale})`,
...heroBackground(layout), ...heroBackground(layout),
}} }}
> >
{snap && ( {snap && (
<div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '8px 8px', pointerEvents: 'none', zIndex: 0 }} /> <div className="hero-canvas-grid" style={{ position: 'absolute', inset: 0, backgroundSize: '16px 16px', pointerEvents: 'none', zIndex: 0 }} />
)} )}
{elements.map((el) => ( {elements.map((el) => (
<HeroElement <HeroElement
@@ -290,12 +311,13 @@ export default function HeroEditor() {
</HeroElement> </HeroElement>
))} ))}
</div> </div>
</div>
<p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}> <p className="sans dim" style={{ fontSize: '0.76rem', marginTop: 8 }}>
Click to select · drag to move · Delete key removes the selected element. Click to select · drag to move · Delete key removes the selected element.
</p> </p>
</div> </div>
<aside className="panel-flat" style={{ flex: '0 0 300px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16 }}> <aside className="panel-flat" style={{ flex: '0 0 320px', padding: 18, display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 20 }}>
{selected ? ( {selected ? (
<ElementPanel <ElementPanel
key={selected.id} key={selected.id}
@@ -479,17 +501,14 @@ function MoonPanel({ element, onProps }) {
const p = element.props || {} const p = element.props || {}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.8rem' }}>Uses the moon from the hero artwork.</p>
<label> <label>
<span className="field-label">Size {p.size || 64}px</span> <span className="field-label">Size {p.size || 96}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%' }} /> <input type="range" min="24" max="320" step="1" value={p.size || 96} onChange={(e) => onProps({ size: Number(e.target.value) })} style={{ width: '100%' }} />
</label> </label>
<label> <label>
<span className="field-label">Glow {Math.round((p.glow ?? 0.45) * 100)}%</span> <span className="field-label">Glow {Math.round((p.glow ?? 0.5) * 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%' }} /> <input type="range" min="0" max="1" step="0.01" value={p.glow ?? 0.5} 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> </label>
</div> </div>
) )