Hero Phase 1: layout data path + portal renderer

First phase of the hero canvas editor (see HERO_EDITOR.md).

- settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it
  (corrects the design doc — public settings is a whitelist, not getAll();
  hero_layout_draft stays admin-only)
- new HeroElement.jsx: renders one layout element by type (text_block,
  buttons, moon, badge, image); absolute % positioning with anchor; shared
  by the portal now and the editor canvas later
- MoonDot: optional color override for the hero moon element
- Portal.jsx: parse hero_layout (version-checked, try/catch), render elements
  sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the
  page is byte-for-byte unchanged until staff publish their own

No schema change. Verified: default render matches the old hero; publishing a
hero_layout re-renders the portal; the draft key is not exposed publicly; client
builds; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 02:18:09 -05:00
parent 30c2a30c80
commit 578bffc51f
4 changed files with 234 additions and 38 deletions

View File

@@ -0,0 +1,139 @@
import { Link } from 'react-router-dom'
import MoonDot from './MoonDot.jsx'
// Font family tokens a line may opt into; default is the page serif.
const FONT = { display: 'var(--display)', sans: 'var(--sans)' }
// fontSize may be a number (px, from the editor) or a CSS string (e.g. a clamp()
// used by the pre-populated default so the hero stays responsive until edited).
function sizeToCss(v) {
return typeof v === 'number' ? `${v}px` : v
}
function lineStyle(line) {
return {
display: 'block', // each line stacks (so a span line behaves like the others)
margin: line.marginTop != null ? `${line.marginTop}px 0 0` : '0',
fontFamily: FONT[line.font] || undefined,
fontSize: sizeToCss(line.fontSize),
color: line.color || 'inherit',
fontWeight: line.weight || undefined,
fontStyle: line.italic ? 'italic' : undefined,
letterSpacing: line.letterSpacing || undefined,
textTransform: line.transform || undefined,
lineHeight: line.lineHeight || undefined,
maxWidth: line.maxWidth ? `${line.maxWidth}px` : undefined,
marginLeft: line.maxWidth ? 'auto' : undefined,
marginRight: line.maxWidth ? 'auto' : undefined,
}
}
function TextBlock({ props }) {
const align = props.align || 'center'
return (
<div style={{ textAlign: align, textShadow: '0 2px 22px rgba(0,0,0,0.82)' }}>
{(props.lines || []).map((line, i) => {
const Tag = /^(h1|h2|h3|p|span)$/.test(line.tag) ? line.tag : 'p'
return (
<Tag key={i} style={lineStyle(line)}>
{line.text}
</Tag>
)
})}
</div>
)
}
function Buttons({ props }) {
const justify = props.align === 'left' ? 'flex-start' : props.align === 'right' ? 'flex-end' : 'center'
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: props.gap ?? 12, justifyContent: justify }}>
{(props.items || []).map((b, i) => (
<Link key={i} to={b.to || '#'} className={`btn ${b.variant === 'ghost' ? 'btn-ghost' : 'btn-primary'}`}>
{b.label}
</Link>
))}
</div>
)
}
function Badge({ props }) {
return (
<span
className="sans"
style={{
display: 'inline-block',
padding: '6px 14px',
background: props.bgColor || 'rgba(11,22,48,0.6)',
color: props.textColor || '#c2d2e6',
borderRadius: props.borderRadius ?? 999,
fontSize: '0.74rem',
fontWeight: 700,
letterSpacing: '0.18em',
textTransform: 'uppercase',
}}
>
{props.text}
</span>
)
}
function HeroImage({ props }) {
return (
<img
src={props.src}
alt={props.alt || ''}
style={{ width: `${props.width || 40}%`, height: 'auto', display: 'block', borderRadius: 8 }}
/>
)
}
function content(element) {
switch (element.type) {
case 'text_block':
return <TextBlock props={element.props || {}} />
case 'buttons':
return <Buttons props={element.props || {}} />
case 'moon':
return <MoonDot size={element.props?.size || 48} glow={element.props?.glow ?? 0.45} color={element.props?.color} />
case 'badge':
return <Badge props={element.props || {}} />
case 'image':
return <HeroImage props={element.props || {}} />
default:
return null
}
}
// Absolute-positioned wrapper + type-specific content. `wrapperStyle` lets the
// editor layer on selection affordances without changing positioning logic.
export default function HeroElement({ element, wrapperStyle, children }) {
const anchor = element.anchor || 'center'
const transform =
anchor === 'center'
? 'translate(-50%, -50%)'
: anchor === 'top-right'
? 'translateX(-100%)'
: undefined
// text_block/buttons may set a box width (px); kept within the viewport on mobile.
const boxWidth =
(element.type === 'text_block' || element.type === 'buttons') && element.props?.width
? `min(${element.props.width}px, calc(100vw - 36px))`
: undefined
return (
<div
style={{
position: 'absolute',
left: `${element.x}%`,
top: `${element.y}%`,
zIndex: element.z || 0,
transform,
width: boxWidth,
...wrapperStyle,
}}
>
{content(element)}
{children}
</div>
)
}

View File

@@ -1,9 +1,8 @@
// The little glowing moon used in the logo, login, and maintenance screens.
export default function MoonDot({ size = 13, glow = 0.45 }) {
return (
<span
className="moon"
style={{ width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }}
/>
)
// The little glowing moon used in the logo, login, maintenance screens, and the
// hero canvas. `color` overrides the radial-gradient start point (else the CSS
// .moon default is used).
export default function MoonDot({ size = 13, glow = 0.45, color }) {
const style = { width: size, height: size, boxShadow: `0 0 ${size * 0.8}px rgba(216,226,239,${glow})` }
if (color) style.background = `radial-gradient(circle at 35% 30%, ${color}, #9fb0c6 55%, #5d6e88)`
return <span className="moon" style={style} />
}