/**
 * GENE Motion — Scroll Hero: editor.
 *
 * Tiered by license (soft gate — the block is always usable, front end never
 * gated):
 *   Free  → one background (image/video, video scrubs) + one heading. Scroll
 *           length is a well-tuned fixed default.
 *   Pro   → add more headings that swap one-at-a-time as you scroll, plus the
 *           scroll-length control.
 * The canvas shows a calm static preview (the finished hero), never the pin.
 */
import { __ } from '@wordpress/i18n';
import {
	useBlockProps,
	InspectorControls,
	MediaUpload,
	MediaUploadCheck,
	PanelColorSettings,
} from '@wordpress/block-editor';
import {
	PanelBody,
	Button,
	RangeControl,
	SelectControl,
	TextControl,
	TextareaControl,
	ToggleControl,
	Notice,
} from '@wordpress/components';
import { Fragment, useState, useRef, useEffect } from '@wordpress/element';
import { mapOne, Media } from '../shared/media';
import { isPro, upgradeUrl } from '../shared/pro-gate';
import { beatPos, beatWindow, beatAlign, beatScale, headStyle, subStyle, beatActionsStyle } from '../shared/hero-timeline';

function clampN( n, min, max ) {
	return n < min ? min : n > max ? max : n;
}

// Editing aids: snap a placement % to a nearby anchor (edges / quarters / centre)
// and a scroll fraction to a 5% gridline, so headings and windows line up without
// pixel-hunting. `hit` drives the on-canvas guide highlight.
const SNAP_ANCHORS = [ 0, 25, 50, 75, 100 ];
function snapPct( v ) {
	for ( let i = 0; i < SNAP_ANCHORS.length; i++ ) {
		if ( Math.abs( v - SNAP_ANCHORS[ i ] ) <= 2.4 ) return { v: SNAP_ANCHORS[ i ], hit: true };
	}
	return { v: v, hit: false };
}
function snapFrac( f ) {
	const g = Math.round( f / 0.05 ) * 0.05;
	return Math.abs( f - g ) <= 0.02 ? g : f;
}

const MB = 1048576;
const GAUGE_COLOR = { ok: '#46b450', warn: '#dba617', bad: '#d63638', unknown: '#999' };
function fmtMB( bytes ) {
	return ( bytes / MB ).toFixed( 1 );
}
// A ready-to-copy re-export recipe: 24fps + all-keyframes keeps the scrub smooth
// and the sharpness the same while cutting frames (and file size).
const REENCODE_CMD =
	'ffmpeg -i in.mp4 -an -vf "fps=24,format=yuv420p" -c:v libx264 -crf 22 -g 1 -movflags +faststart out.mp4';

function lines( heading ) {
	const parts = ( heading || '' ).split( '\n' );
	return parts.map( ( line, i ) => (
		<Fragment key={ i }>
			{ line }
			{ i < parts.length - 1 ? <br /> : null }
		</Fragment>
	) );
}

// A single beat field edited in place on the canvas. Uncontrolled: the text
// lives in the DOM (set once on mount) so React re-renders never move the
// caret; each keystroke reads innerText back to state as plain text (the same
// \n model save.js uses). Enter commits single-line fields; the heading keeps
// line breaks. Key/pointer events are stopped so the stage's drag/nudge don't
// fire while typing.
function EditableText( { tag: Tag, className, value, multiline, autoFocus, style, onChange } ) {
	const ref = useRef( null );
	useEffect( () => {
		if ( ref.current ) ref.current.innerText = value || '';
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [] );
	useEffect( () => {
		if ( ! autoFocus || ! ref.current ) return;
		const el = ref.current;
		el.focus();
		const r = document.createRange();
		r.selectNodeContents( el );
		r.collapse( false );
		const sel = window.getSelection();
		sel.removeAllRanges();
		sel.addRange( r );
	}, [ autoFocus ] );
	return (
		<Tag
			ref={ ref }
			className={ className }
			contentEditable
			suppressContentEditableWarning
			spellCheck={ false }
			/* text-transform none while editing: innerText reflects the rendered
			   case, so any transform (a theme's, or custom CSS) would read back
			   transformed and corrupt the stored text. Reading the true source case
			   keeps typing safe. The beat's own align/size style is merged so what
			   you type matches what ships. */
			style={ { outline: 'none', cursor: 'text', textTransform: 'none', ...( style || {} ) } }
			onInput={ ( e ) => onChange( e.currentTarget.innerText.replace( /\r\n?/g, '\n' ) ) }
			onPointerDown={ ( e ) => e.stopPropagation() }
			onKeyDown={ ( e ) => {
				e.stopPropagation();
				if ( e.key === 'Escape' ) { e.preventDefault(); e.currentTarget.blur(); }
				else if ( e.key === 'Enter' && ! multiline ) { e.preventDefault(); e.currentTarget.blur(); }
			} }
		/>
	);
}

// A beat's heading + sub for the canvas: static text, or editable fields while
// this beat is being edited. Each carries its own alignment/size style so the
// canvas matches the front end. (The eyebrow/label was removed — headings and
// sub-lines only.)
function beatTextNodes( { b, editing, setField } ) {
	if ( editing ) {
		return (
			<>
				<EditableText tag="h2" className="gene-hero__head" style={ headStyle( b ) } value={ b.heading || '' } multiline autoFocus onChange={ ( v ) => setField( 'heading', v ) } />
				{ ( b.sub || '' ).trim() ? (
					<EditableText tag="p" className="gene-hero__sub" style={ subStyle( b ) } value={ b.sub } onChange={ ( v ) => setField( 'sub', v ) } />
				) : null }
			</>
		);
	}
	return (
		<>
			{ ( b.heading || '' ).trim() ? <h2 className="gene-hero__head" style={ headStyle( b ) }>{ lines( b.heading ) }</h2> : null }
			{ ( b.sub || '' ).trim() ? <p className="gene-hero__sub" style={ subStyle( b ) }>{ b.sub }</p> : null }
		</>
	);
}

// A background video's weight, judged by what phones actually load (the phone
// clip if set, else the main one), with plain-language advice + a copy-ready
// ffmpeg recipe. Guidance only — it never touches the video. Shared by the
// single hero and each multi-scene chapter so every clip gets the same check.
function WeightGauge( { mainBytes, mobBytes, isPro } ) {
	if ( ! mainBytes && ! mobBytes ) {
		return (
			<div style={ { marginTop: '12px', borderTop: '1px solid #e0e0e0', paddingTop: '12px' } }>
				<p style={ { fontSize: '12px', fontWeight: 600, margin: '0 0 6px' } }>
					{ __( 'Video weight', 'gene-section-builder' ) }
				</p>
				<p style={ { fontSize: '12px', color: GAUGE_COLOR.unknown, fontWeight: 600, margin: 0 } }>
					{ __( 'Measuring…', 'gene-section-builder' ) }
				</p>
			</div>
		);
	}
	const phoneBytes = mobBytes || mainBytes; // phones load the phone clip if set, else the main one
	const state = phoneBytes <= 8 * MB ? 'ok' : phoneBytes <= 15 * MB ? 'warn' : 'bad';
	const fill = clampN( phoneBytes / ( 20 * MB ), 0, 1 );
	const label = {
		ok: __( 'Light — smooth on phones too.', 'gene-section-builder' ),
		warn: __( 'A bit heavy for phones.', 'gene-section-builder' ),
		bad: __( 'Too heavy for phones — best to lighten it.', 'gene-section-builder' ),
	}[ state ];
	const heavy = state === 'warn' || state === 'bad';
	return (
		<div style={ { marginTop: '12px', borderTop: '1px solid #e0e0e0', paddingTop: '12px' } }>
			<p style={ { fontSize: '12px', fontWeight: 600, margin: '0 0 6px' } }>
				{ __( 'Video weight', 'gene-section-builder' ) }
			</p>
			<div style={ { height: '8px', borderRadius: '4px', background: '#e0e0e0', overflow: 'hidden' } }>
				<div style={ { height: '100%', width: fill * 100 + '%', background: GAUGE_COLOR[ state ], transition: 'width .2s' } } />
			</div>
			<p style={ { fontSize: '12px', margin: '6px 0 0', color: GAUGE_COLOR[ state ], fontWeight: 600 } }>
				{ label }
			</p>
			<p style={ { fontSize: '12px', color: '#757575', margin: '2px 0 0' } }>
				{ mobBytes
					? __( 'Phone:', 'gene-section-builder' ) + ' ' + fmtMB( mobBytes ) + ' MB · ' +
					  __( 'Desktop:', 'gene-section-builder' ) + ' ' + fmtMB( mainBytes )+ ' MB'
					: fmtMB( mainBytes ) + ' MB ' + __( '(same video on phone and desktop)', 'gene-section-builder' ) }
			</p>
			{ heavy ? (
				<ul style={ { fontSize: '12px', color: '#50575e', margin: '8px 0 0', paddingLeft: '18px' } }>
					<li>{ __( 'Use a shorter clip — a few seconds is plenty.', 'gene-section-builder' ) }</li>
					<li>{ __( 'Re-export at 24fps, all-keyframes — same sharpness, smaller file.', 'gene-section-builder' ) }</li>
					<li>
						{ isPro
							? __( 'Set a separate, lighter video for phones (above).', 'gene-section-builder' )
							: __( 'A separate, lighter phone video is available in Pro.', 'gene-section-builder' ) }
					</li>
					<li>{ __( 'If it is still heavy, compress it and re-upload.', 'gene-section-builder' ) }</li>
				</ul>
			) : null }
			{ heavy ? (
				<div style={ { marginTop: '6px' } }>
					<code style={ { display: 'block', fontSize: '11px', background: '#f0f0f1', padding: '6px', borderRadius: '3px', wordBreak: 'break-all' } }>
						{ REENCODE_CMD }
					</code>
					<Button
						size="small"
						variant="secondary"
						style={ { marginTop: '4px' } }
						onClick={ () => {
							if ( window.navigator && window.navigator.clipboard ) {
								window.navigator.clipboard.writeText( REENCODE_CMD );
							}
						} }
					>
						{ __( 'Copy command', 'gene-section-builder' ) }
					</Button>
				</div>
			) : null }
		</div>
	);
}

// A beat's CTA buttons editor (label / url / style). Shared by the single hero
// and each multi-scene chapter heading.
function BeatButtons( { buttons, onChange } ) {
	const list = Array.isArray( buttons ) ? buttons : [];
	const set = ( i, key, val ) => {
		const next = list.slice();
		next[ i ] = { ...next[ i ], [ key ]: val };
		onChange( next );
	};
	const add = () => onChange( [ ...list, { label: '', url: '', style: 'solid' } ] );
	const remove = ( i ) => onChange( list.filter( ( b, k ) => k !== i ) );
	return (
		<div style={ { marginTop: '8px', borderTop: '1px dashed #e0e0e0', paddingTop: '8px' } }>
			<p style={ { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#757575', margin: '0 0 6px' } }>
				{ __( 'Buttons', 'gene-section-builder' ) }
			</p>
			{ list.map( ( b, i ) => (
				<div key={ i } style={ { border: '1px solid #ededed', borderRadius: '3px', padding: '8px', marginBottom: '6px' } }>
					<div style={ { display: 'flex', justifyContent: 'flex-end' } }>
						<Button
							size="small"
							icon="trash"
							isDestructive
							label={ __( 'Remove button', 'gene-section-builder' ) }
							onClick={ () => remove( i ) }
						/>
					</div>
					<TextControl
						label={ __( 'Button text', 'gene-section-builder' ) }
						value={ b.label || '' }
						onChange={ ( v ) => set( i, 'label', v ) }
					/>
					<TextControl
						label={ __( 'Link (URL)', 'gene-section-builder' ) }
						value={ b.url || '' }
						onChange={ ( v ) => set( i, 'url', v ) }
					/>
					<SelectControl
						label={ __( 'Style', 'gene-section-builder' ) }
						value={ b.style === 'ghost' ? 'ghost' : 'solid' }
						options={ [
							{ label: __( 'Solid', 'gene-section-builder' ), value: 'solid' },
							{ label: __( 'Outline', 'gene-section-builder' ), value: 'ghost' },
						] }
						onChange={ ( v ) => set( i, 'style', v ) }
					/>
				</div>
			) ) }
			<Button variant="tertiary" onClick={ add }>
				{ __( 'Add button', 'gene-section-builder' ) }
			</Button>
		</div>
	);
}

// Free placement: one heading's position / scroll window / effect. Shared by
// the single hero's headings and each chapter's headings, so both edit the
// same way; `set(key, value)` writes to whichever beat list owns the heading.
function BeatPlacement( { b, i, count, set, setPhone, clearPhone } ) {
	const hasPhone = typeof b.xM === 'number' || typeof b.yM === 'number';
	return (
		<div style={ { marginTop: '8px', borderTop: '1px dashed #e0e0e0', paddingTop: '8px' } }>
			<p style={ { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#757575', margin: '0 0 6px' } }>
				{ __( 'Placement & timing', 'gene-section-builder' ) }
			</p>
			<RangeControl
				label={ __( 'Position X (%)', 'gene-section-builder' ) }
				value={ beatPos( b, false ).x }
				min={ 0 }
				max={ 100 }
				onChange={ ( v ) => set( 'x', v ) }
			/>
			<RangeControl
				label={ __( 'Position Y (%)', 'gene-section-builder' ) }
				value={ beatPos( b, false ).y }
				min={ 0 }
				max={ 100 }
				onChange={ ( v ) => set( 'y', v ) }
			/>
			<RangeControl
				label={ __( 'Appears at (%)', 'gene-section-builder' ) }
				help={ __( 'Where in the scroll this heading arrives.', 'gene-section-builder' ) }
				value={ Math.round( beatWindow( i, count, b ).in * 100 ) }
				min={ 0 }
				max={ 100 }
				onChange={ ( v ) => set( 'in', v ) }
			/>
			<RangeControl
				label={ __( 'Leaves at (%)', 'gene-section-builder' ) }
				help={ __( 'Where it starts to go. It can overlap other headings.', 'gene-section-builder' ) }
				value={ Math.round( beatWindow( i, count, b ).out * 100 ) }
				min={ 0 }
				max={ 100 }
				onChange={ ( v ) => set( 'out', v ) }
			/>
			<SelectControl
				label={ __( 'Effect', 'gene-section-builder' ) }
				value={ b.anim || '' }
				options={ [
					{ label: __( 'Use shared effect', 'gene-section-builder' ), value: '' },
					{ label: __( 'Fade', 'gene-section-builder' ), value: 'fade' },
					{ label: __( 'Rise', 'gene-section-builder' ), value: 'rise' },
					{ label: __( 'Slide', 'gene-section-builder' ), value: 'slide' },
					{ label: __( 'Reveal', 'gene-section-builder' ), value: 'reveal' },
				] }
				onChange={ ( v ) => set( 'anim', v ) }
			/>
			{ ! isBtnItem( b ) ? (
				<>
					<p style={ { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#757575', margin: '10px 0 4px' } }>
						{ __( 'Heading', 'gene-section-builder' ) }
					</p>
					<SelectControl
						label={ __( 'Alignment', 'gene-section-builder' ) }
						value={ beatAlign( b, 'head' ) }
						options={ [
							{ label: __( 'Left', 'gene-section-builder' ), value: 'left' },
							{ label: __( 'Centered', 'gene-section-builder' ), value: 'center' },
							{ label: __( 'Right', 'gene-section-builder' ), value: 'right' },
						] }
						onChange={ ( v ) => set( 'headAlign', v === 'left' || v === 'right' ? v : '' ) }
					/>
					<RangeControl
						label={ __( 'Size (%)', 'gene-section-builder' ) }
						value={ beatScale( b, 'head' ) }
						min={ 60 }
						max={ 200 }
						step={ 5 }
						onChange={ ( v ) => set( 'headScale', v === 100 ? '' : v ) }
					/>
					{ ( b.sub || '' ).trim() ? (
						<>
							<p style={ { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#757575', margin: '10px 0 4px' } }>
								{ __( 'Sub-line', 'gene-section-builder' ) }
							</p>
							<SelectControl
								label={ __( 'Alignment', 'gene-section-builder' ) }
								value={ beatAlign( b, 'sub' ) }
								options={ [
									{ label: __( 'Left', 'gene-section-builder' ), value: 'left' },
									{ label: __( 'Centered', 'gene-section-builder' ), value: 'center' },
									{ label: __( 'Right', 'gene-section-builder' ), value: 'right' },
								] }
								onChange={ ( v ) => set( 'subAlign', v === 'left' || v === 'right' ? v : '' ) }
							/>
							<RangeControl
								label={ __( 'Size (%)', 'gene-section-builder' ) }
								value={ beatScale( b, 'sub' ) }
								min={ 60 }
								max={ 200 }
								step={ 5 }
								onChange={ ( v ) => set( 'subScale', v === 100 ? '' : v ) }
							/>
						</>
					) : null }
				</>
			) : null }
			<ToggleControl
				label={ __( 'Different position on phones', 'gene-section-builder' ) }
				checked={ hasPhone }
				onChange={ ( on ) => {
					if ( on ) {
						// One write for both keys — two sequential set() calls would
						// each start from the stale pre-render list and drop one.
						setPhone( beatPos( b, false ).x, beatPos( b, false ).y );
					} else {
						clearPhone();
					}
				} }
			/>
			{ hasPhone ? (
				<>
					<RangeControl
						label={ __( 'Phone X (%)', 'gene-section-builder' ) }
						value={ beatPos( b, true ).x }
						min={ 0 }
						max={ 100 }
						onChange={ ( v ) => set( 'xM', v ) }
					/>
					<RangeControl
						label={ __( 'Phone Y (%)', 'gene-section-builder' ) }
						value={ beatPos( b, true ).y }
						min={ 0 }
						max={ 100 }
						onChange={ ( v ) => set( 'yM', v ) }
					/>
				</>
			) : null }
		</div>
	);
}

// Static (non-navigable) preview of a beat's buttons for the editor canvas.
// Item helpers: a beat with no text but with buttons is a button item — an
// independent CTA the timeline places and times apart from any heading.
function beatText( b ) {
	return ( b.heading || '' ).trim() || ( b.eyebrow || '' ).trim() || ( b.sub || '' ).trim();
}
function isBtnItem( b ) {
	return ! beatText( b ) && Array.isArray( b.buttons ) && b.buttons.length > 0;
}
// Track row label: the heading's first line, else the first button's label.
function tlItemLabel( b ) {
	const h = ( ( b.heading || '' ).split( '\n' )[ 0 ] || '' ).trim();
	if ( h ) return h;
	const btn = ( Array.isArray( b.buttons ) ? b.buttons : [] ).find(
		( x ) => x && ( x.label || '' ).trim()
	);
	return btn ? btn.label : '';
}

// Timeline invariant: headings are headings, buttons are buttons. Split any
// beat carrying both into a heading item + a button item placed just below it.
// Every beat's window is baked to its current resolved value first, so the
// longer list doesn't shift anyone's timing. Returns null when nothing splits.
function splitHeadingButtons( bts ) {
	const src = Array.isArray( bts ) ? bts : [];
	const carries = ( b ) => b && beatText( b ) && Array.isArray( b.buttons ) && b.buttons.length;
	if ( ! src.some( carries ) ) return null;
	const n = src.length;
	const out = [];
	src.forEach( ( b, i ) => {
		if ( ! b ) return;
		const w = beatWindow( i, n, b );
		const win = { in: Math.round( w.in * 100 ), out: Math.round( w.out * 100 ) };
		if ( ! carries( b ) ) {
			out.push( { ...b, ...win } );
			return;
		}
		const head = { ...b, ...win };
		delete head.buttons;
		const p = beatPos( b, false );
		const btn = {
			buttons: b.buttons,
			x: p.x,
			y: Math.min( 100, Math.round( p.y + 14 ) ),
			...win,
		};
		if ( typeof b.xM === 'number' || typeof b.yM === 'number' ) {
			const pm = beatPos( b, true );
			btn.xM = pm.x;
			btn.yM = Math.min( 100, Math.round( pm.y + 14 ) );
		}
		if ( b.anim ) btn.anim = b.anim;
		out.push( head, btn );
	} );
	return out;
}

// Turning free placement off folds each button item back under the nearest
// heading above it (else the first heading), so the classic hero keeps its
// CTA. Returns null when there's nothing to merge — including when no heading
// exists at all (the button items then just wait, hidden, for the next on).
function mergeButtonItems( bts ) {
	const src = Array.isArray( bts ) ? bts : [];
	const isBtn = ( b ) => b && isBtnItem( b );
	if ( ! src.some( isBtn ) || ! src.some( ( b ) => b && beatText( b ) ) ) return null;
	const headIdx = [];
	src.forEach( ( b, i ) => {
		if ( b && beatText( b ) ) headIdx.push( i );
	} );
	const extra = {};
	src.forEach( ( b, i ) => {
		if ( ! isBtn( b ) ) return;
		let t = -1;
		for ( let k = headIdx.length - 1; k >= 0; k-- ) {
			if ( headIdx[ k ] < i ) {
				t = headIdx[ k ];
				break;
			}
		}
		if ( t === -1 ) t = headIdx[ 0 ];
		( extra[ t ] = extra[ t ] || [] ).push( ...b.buttons );
	} );
	const out = [];
	src.forEach( ( b, i ) => {
		if ( isBtn( b ) ) return;
		out.push(
			extra[ i ]
				? { ...b, buttons: [ ...( Array.isArray( b.buttons ) ? b.buttons : [] ), ...extra[ i ] ] }
				: b
		);
	} );
	return out;
}

// A ghost pill so a freshly added button item (no label yet) still has a
// visible body to drag on the stage.
function btnPlaceholder() {
	return (
		<div className="gene-hero__actions">
			<span className="gene-hero__btn gene-hero__btn--solid" style={ { opacity: 0.55 } }>
				{ __( 'Button', 'gene-section-builder' ) }
			</span>
		</div>
	);
}

function previewButtons( b ) {
	const btns = ( Array.isArray( b.buttons ) ? b.buttons : [] ).filter(
		( x ) => x && ( x.label || '' ).trim()
	);
	if ( ! btns.length ) return null;
	return (
		<div className="gene-hero__actions">
			{ btns.map( ( x, i ) => (
				<span
					key={ i }
					className={ 'gene-hero__btn gene-hero__btn--' + ( x.style === 'ghost' ? 'ghost' : 'solid' ) }
				>
					{ x.label }
				</span>
			) ) }
		</div>
	);
}

export default function Edit( { attributes, setAttributes } ) {
	const { background = {}, backgroundMobile = {}, bgDim = 10, scrubScreens = 4, beats = [], align, headlinePos = 'bottom-left', endShatter = false, shatterZone = 20, shatterStyle = 'glass', timeline = false, beatAnim = 'rise', multiScene = false, scenes = [], headingColor = '', buttonColor = '', buttonTextColor = '', fontPreset = '', buttonShape = 'pill' } = attributes;

	const hasBg = !! ( background && background.url );
	const isVideo = hasBg && background.type === 'video';
	const hasMobile = !! ( backgroundMobile && backgroundMobile.url );

	// Multi-scene (chapters, Pro): a sequence of background videos. When on and a
	// scene has a background, the canvas previews a chapter and the single-hero
	// timeline is set aside (they're different modes).
	const sceneList = Array.isArray( scenes ) && scenes.length ? scenes : [];
	const isMultiActive = isPro && !! multiScene && sceneList.some( ( sc ) => sc && sc.background && sc.background.url );

	// Always show at least one heading editor; a single beat is the free tier.
	const list = beats.length ? beats : [ {} ];
	const shown = isPro ? list : list.slice( 0, 1 );
	// Free placement (Pro) applies to whichever mode is active: the single
	// hero's headings (isTimeline — drives the drag stage below), or each
	// chapter's own headings when chapters play (isTimelineMulti).
	const isTimeline = isPro && !! timeline && list.length > 0 && ! isMultiActive;
	const isTimelineMulti = isPro && !! timeline && isMultiActive;

	const [ selScene, setSelScene ] = useState( 0 );
	const setSceneField = ( i, key, value ) => {
		const base = sceneList.slice();
		base[ i ] = { ...base[ i ], [ key ]: value };
		setAttributes( { scenes: base } );
	};
	const addScene = () =>
		setAttributes( { scenes: [ ...sceneList, { eyebrow: '', heading: '', sub: '', bgDim: 10 } ] } );
	const removeScene = ( i ) => setAttributes( { scenes: sceneList.filter( ( s, k ) => k !== i ) } );
	const moveScene = ( i, dir ) => {
		const j = i + dir;
		if ( j < 0 || j >= sceneList.length ) return;
		const next = sceneList.slice();
		const tmp = next[ i ];
		next[ i ] = next[ j ];
		next[ j ] = tmp;
		setAttributes( { scenes: next } );
	};
	const onSceneBg = ( i, m ) => {
		const mm = mapOne( m );
		if ( m && m.filesizeInBytes ) mm.bytes = Number( m.filesizeInBytes );
		const base = sceneList.slice();
		base[ i ] = { ...base[ i ], background: mm };
		const next = { scenes: base };
		if ( ! align ) next.align = 'full';
		setAttributes( next );
	};
	const onSceneMobile = ( i, m ) => {
		const mm = mapOne( m );
		if ( m && m.filesizeInBytes ) mm.bytes = Number( m.filesizeInBytes );
		setSceneField( i, 'backgroundMobile', mm );
	};

	// A chapter's editable headings: its `beats` list, else the flat single
	// heading it always had. Always returns at least one so there's an editor.
	const beatsOf = ( sc ) => {
		const raw = Array.isArray( sc.beats ) ? sc.beats : [];
		if ( raw.length ) return raw;
		if ( ( sc.heading || '' ).trim() || ( sc.eyebrow || '' ).trim() || ( sc.sub || '' ).trim() ) {
			return [ { eyebrow: sc.eyebrow || '', heading: sc.heading || '', sub: sc.sub || '' } ];
		}
		return [ {} ];
	};
	// Write a chapter's headings to `beats` and clear the flat fields, so `beats`
	// is the single source of truth once the author touches a heading.
	const setSceneBeats = ( i, bts ) => {
		const base = sceneList.slice();
		base[ i ] = { ...base[ i ], beats: bts, eyebrow: '', heading: '', sub: '' };
		setAttributes( { scenes: base } );
	};
	const setSceneBeat = ( i, bi, key, value ) => {
		const bts = beatsOf( sceneList[ i ] || {} ).slice();
		bts[ bi ] = { ...bts[ bi ], [ key ]: value };
		setSceneBeats( i, bts );
	};
	const addSceneBeat = ( i ) => setSceneBeats( i, [ ...beatsOf( sceneList[ i ] || {} ), {} ] );
	const removeSceneBeat = ( i, bi ) => {
		const bts = beatsOf( sceneList[ i ] || {} ).filter( ( b, k ) => k !== bi );
		setSceneBeats( i, bts.length ? bts : [ {} ] );
	};
	const moveSceneBeat = ( i, bi, dir ) => {
		const bts = beatsOf( sceneList[ i ] || {} ).slice();
		const j = bi + dir;
		if ( j < 0 || j >= bts.length ) return;
		const t = bts[ bi ];
		bts[ bi ] = bts[ j ];
		bts[ j ] = t;
		setSceneBeats( i, bts );
	};
	const setSceneBeatPhone = ( i, bi, x, y ) => {
		const bts = beatsOf( sceneList[ i ] || {} ).slice();
		bts[ bi ] = { ...bts[ bi ], xM: x, yM: y };
		setSceneBeats( i, bts );
	};
	const clearSceneBeatPhone = ( i, bi ) => {
		const bts = beatsOf( sceneList[ i ] || {} ).slice();
		const nb = { ...bts[ bi ] };
		delete nb.xM;
		delete nb.yM;
		bts[ bi ] = nb;
		setSceneBeats( i, bts );
	};

	// The chapter shown on the canvas (numbered tabs switch it). Computed here
	// because the timeline editor below edits this chapter's headings too.
	const selSceneIdx = Math.max( 0, Math.min( selScene, Math.max( 0, sceneList.length - 1 ) ) );
	const curScene = sceneList[ selSceneIdx ] || {};
	const curBg = curScene.background || {};
	const curBeats = beatsOf( curScene );

	// While free placement is on, a heading never carries buttons: any beat
	// still holding both (older content) is split into a heading item and a
	// button item the moment it's edited here. One write = one undo step.
	useEffect( () => {
		if ( ! isPro || ! timeline ) return;
		if ( isMultiActive ) {
			let changed = false;
			const next = sceneList.map( ( sc ) => {
				const s = splitHeadingButtons( Array.isArray( sc.beats ) ? sc.beats : [] );
				if ( ! s ) return sc;
				changed = true;
				return { ...sc, beats: s };
			} );
			if ( changed ) setAttributes( { scenes: next } );
		} else {
			const s = splitHeadingButtons( beats );
			if ( s ) setAttributes( { beats: s } );
		}
	}, [ isPro, timeline, isMultiActive, beats, sceneList ] );

	// The timeline toggle. Turning it off folds button items back under their
	// headings (both the single hero's and every chapter's) in the same write,
	// so the classic hero keeps its CTA and nothing silently disappears.
	const setTimelineMode = ( on ) => {
		if ( on ) {
			setAttributes( { timeline: true } ); // the split effect above tidies up
			return;
		}
		const patch = { timeline: false };
		const merged = mergeButtonItems( beats );
		if ( merged ) patch.beats = merged;
		if ( sceneList.length ) {
			let changed = false;
			const next = sceneList.map( ( sc ) => {
				const m = mergeButtonItems( Array.isArray( sc.beats ) ? sc.beats : [] );
				if ( ! m ) return sc;
				changed = true;
				return { ...sc, beats: m };
			} );
			if ( changed ) patch.scenes = next;
		}
		setAttributes( patch );
	};

	// Design choices previewed live on the canvas — the same CSS vars / data
	// attributes save.js writes, so what you see is what the front end gets.
	const designStyle = {};
	if ( headingColor ) designStyle[ '--gene-hero-head-c' ] = headingColor;
	if ( buttonColor ) designStyle[ '--gene-hero-btn-bg' ] = buttonColor;
	if ( buttonTextColor ) designStyle[ '--gene-hero-btn-c' ] = buttonTextColor;
	const designProps = { style: designStyle };
	if ( fontPreset === 'sans' || fontPreset === 'serif' || fontPreset === 'mono' ) {
		designProps[ 'data-font' ] = fontPreset;
	}
	if ( buttonShape === 'rounded' || buttonShape === 'square' ) {
		designProps[ 'data-btn-shape' ] = buttonShape;
	}

	const blockProps = useBlockProps( {
		className:
			'gene-hero gene-hero--editor' +
			( isTimeline || isTimelineMulti ? ' gene-hero--timeline-edit' : '' ),
		'data-has-bg': hasBg ? '1' : '0',
		'data-pos': headlinePos === 'center' ? 'center' : 'bottom-left',
		...designProps,
	} );

	const onSelectBackground = ( m ) => {
		const mapped = mapOne( m );
		if ( m && m.filesizeInBytes ) mapped.bytes = Number( m.filesizeInBytes );
		const next = { background: mapped };
		if ( ! align ) next.align = 'full';
		setAttributes( next );
	};

	const setBeat = ( i, key, value ) => {
		const base = beats.length ? beats.slice() : [ {} ];
		base[ i ] = { ...base[ i ], [ key ]: value };
		setAttributes( { beats: base } );
	};
	const setBeatPhone = ( i, x, y ) => {
		const base = beats.length ? beats.slice() : [ {} ];
		base[ i ] = { ...base[ i ], xM: x, yM: y };
		setAttributes( { beats: base } );
	};
	const clearBeatPhone = ( i ) => {
		const base = beats.length ? beats.slice() : [ {} ];
		const nb = { ...base[ i ] };
		delete nb.xM;
		delete nb.yM;
		base[ i ] = nb;
		setAttributes( { beats: base } );
	};
	const addBeat = () => setAttributes( { beats: [ ...list, { eyebrow: '', heading: '', sub: '' } ] } );
	const removeBeat = ( i ) => setAttributes( { beats: list.filter( ( b, k ) => k !== i ) } );
	const moveBeat = ( i, dir ) => {
		const j = i + dir;
		if ( j < 0 || j >= list.length ) return;
		const next = list.slice();
		const tmp = next[ i ];
		next[ i ] = next[ j ];
		next[ j ] = tmp;
		setAttributes( { beats: next } );
	};

	// --- Video weight gauge (editor guidance only) -------------------------
	// Sum the background video's file size (main + optional phone clip) and show
	// a simple meter with plain-language advice. It never changes the video — it
	// only tells the author what to do (shorten / re-export lighter / re-upload).
	// Size comes from the media library; if it's missing we read Content-Length.
	const [ measured, setMeasured ] = useState( {} );
	const bytesOf = ( m ) => ( m && Number( m.bytes ) ) || ( m && measured[ m.url ] ) || 0;
	// A media item that needs measuring: a video with a url, no stored bytes, and
	// not yet measured this session.
	const needsMeasure = ( m ) =>
		m && m.type === 'video' && m.url && ! m.bytes && ! measured[ m.url ] ? m.url : null;
	const scenesSig = sceneList
		.map( ( s ) => ( ( s.background && s.background.url ) || '' ) + '|' + ( ( s.backgroundMobile && s.backgroundMobile.url ) || '' ) )
		.join( ',' );
	useEffect( () => {
		const want = [];
		if ( needsMeasure( background ) ) want.push( background.url );
		if ( hasMobile && needsMeasure( backgroundMobile ) ) want.push( backgroundMobile.url );
		// Each chapter's clips too, so the per-chapter weight gauges can read sizes.
		sceneList.forEach( ( s ) => {
			const a = needsMeasure( s.background );
			if ( a ) want.push( a );
			const b = needsMeasure( s.backgroundMobile );
			if ( b ) want.push( b );
		} );
		want.forEach( ( u ) => {
			if ( typeof window.fetch !== 'function' ) return;
			window
				.fetch( u, { method: 'HEAD' } )
				.then( ( r ) => {
					const cl = r.headers.get( 'content-length' );
					if ( cl ) setMeasured( ( prev ) => ( { ...prev, [ u ]: Number( cl ) } ) );
				} )
				.catch( () => {} );
		} );
	}, [ isVideo, background.url, background.bytes, hasMobile, backgroundMobile.url, backgroundMobile.bytes, scenesSig ] );

	// --- Frame-synced timeline editor (Pro) --------------------------------
	// A real editing surface: drag each heading on the hero, trim its scroll
	// window on a track below, and — for a video background — the frame you're
	// looking at scrubs to the moment being edited, so placement is done against
	// the actual footage. Everything commits on pointer-up (one undo step); the
	// numeric controls in the sidebar stay as the accessible fine-tune.
	// The same surface edits either the single hero's headings or — in chapter
	// mode — the selected chapter's own headings (tlList routes both).
	const stageRef = useRef( null );
	const trackRef = useRef( null );
	const [ selected, setSelected ] = useState( 0 );
	const [ editing, setEditing ] = useState( null ); // index of the beat edited in place
	const [ device, setDevice ] = useState( 'desktop' );
	const [ scrubAt, setScrubAt ] = useState( null ); // 0..1, or null → use selected's `in`
	const [ drag, setDrag ] = useState( null ); // mirror of the gesture, for rendering
	// The authoritative gesture lives in a ref so pointerup reads the current
	// value synchronously — a plain click fires down+up before React re-renders,
	// so reading `drag` state there would be stale (and the click would do
	// nothing, which is exactly the "click sometimes ignored" bug).
	const press = useRef( null );
	const tlOn = isTimeline || isTimelineMulti;
	const tlList = isTimelineMulti ? curBeats : list;
	const sel = Math.min( selected, tlList.length - 1 );
	const phone = device === 'phone';

	// The background whose frame the stage scrubs: the chapter's in multi mode.
	const tlBg = isTimelineMulti ? curBg : background;
	const tlIsVideo = !! ( tlBg && tlBg.url && tlBg.type === 'video' );
	// A chapter spends its last TRANS breaking to the next while headings run
	// over the hold before it (view.js) — map window fractions onto video time.
	const tlHold = isTimelineMulti ? 1 - 0.16 : 1;

	// The video frame to show: whatever moment is being edited, else selected's in.
	const seekAt =
		scrubAt != null ? scrubAt : beatWindow( sel, tlList.length, tlList[ sel ] || {} ).in;
	useEffect( () => {
		if ( ! tlOn || ! tlIsVideo || ! stageRef.current ) return;
		const v = stageRef.current.querySelector( '.gene-hero__bg video' );
		if ( ! v ) return;
		v.preload = 'auto';
		const seek = () => {
			if ( v.duration ) {
				try { v.currentTime = clampN( seekAt * tlHold, 0, 0.999 ) * v.duration; } catch ( e ) {}
			}
		};
		if ( v.readyState >= 1 ) seek();
		else v.addEventListener( 'loadedmetadata', seek, { once: true } );
	}, [ seekAt, tlOn, tlIsVideo, tlHold, tlBg && tlBg.url ] );

	// Effective (drag-aware) values for rendering while dragging.
	const effPos = ( i ) =>
		drag && drag.i === i && drag.x != null
			? { x: drag.x, y: drag.y }
			: beatPos( tlList[ i ] || {}, phone );
	const effWin = ( i ) =>
		drag && drag.i === i && drag.in != null
			? { in: drag.in, out: drag.out }
			: beatWindow( i, tlList.length, tlList[ i ] || {} );

	// Commit a drag to whichever list is being edited (one write = one undo).
	const writeTlBeat = ( i, patch ) => {
		if ( isTimelineMulti ) {
			const bts = curBeats.slice();
			bts[ i ] = { ...bts[ i ], ...patch };
			setSceneBeats( selSceneIdx, bts );
			return;
		}
		const base = beats.length ? beats.slice() : [ {} ];
		base[ i ] = { ...base[ i ], ...patch };
		setAttributes( { beats: base } );
	};
	const commitPos = ( i, x, y ) => {
		const rx = Math.round( clampN( x, 0, 100 ) );
		const ry = Math.round( clampN( y, 0, 100 ) );
		writeTlBeat( i, phone ? { xM: rx, yM: ry } : { x: rx, y: ry } );
	};
	const commitWin = ( i, inFrac, outFrac ) => {
		writeTlBeat( i, {
			in: Math.round( clampN( inFrac, 0, 1 ) * 100 ),
			out: Math.round( clampN( outFrac, 0, 1 ) * 100 ),
		} );
	};

	// Canvas interaction: a press first selects; it only becomes a drag once the
	// pointer passes a small threshold, so a plain click never nudges the beat.
	// Clicking an already-selected heading (no drag) opens it for editing — a
	// reliable "click to select, click again to edit" instead of a finicky
	// double-tap. `kind: 'pending'` is that armed-but-not-yet-moved state.
	const onBeatDown = ( e, i ) => {
		if ( ! tlOn || ! stageRef.current ) return;
		e.preventDefault();
		e.stopPropagation();
		const rect = stageRef.current.getBoundingClientRect();
		const p = beatPos( tlList[ i ] || {}, phone );
		const g = { kind: 'pending', i, rect, x: p.x, y: p.y, sx: e.clientX, sy: e.clientY, wasSel: sel === i };
		press.current = g;
		setSelected( i );
		setDrag( g );
		setScrubAt( beatWindow( i, tlList.length, tlList[ i ] || {} ).in );
		try { e.currentTarget.setPointerCapture( e.pointerId ); } catch ( err ) {}
	};
	const onBeatMove = ( e, i ) => {
		const g = press.current;
		if ( ! g || g.i !== i ) return;
		if ( g.kind === 'pending' ) {
			// Not a drag until the pointer moves past ~4px — keeps clicks steady.
			if ( Math.abs( e.clientX - g.sx ) < 4 && Math.abs( e.clientY - g.sy ) < 4 ) return;
		}
		const rx = clampN( ( ( e.clientX - g.rect.left ) / g.rect.width ) * 100, 0, 100 );
		const ry = clampN( ( ( e.clientY - g.rect.top ) / g.rect.height ) * 100, 0, 100 );
		const sx = snapPct( rx ), sy = snapPct( ry );
		g.kind = 'move'; g.x = sx.v; g.y = sy.v; g.snapX = sx.hit; g.snapY = sy.hit;
		setDrag( { ...g } );
	};
	const onBeatUp = ( e, i ) => {
		const g = press.current;
		press.current = null;
		if ( ! g || g.i !== i ) { setDrag( null ); return; }
		if ( g.kind === 'move' ) {
			commitPos( i, g.x, g.y );
		} else if ( g.wasSel && ! isBtnItem( tlList[ i ] || {} ) ) {
			// A click on an already-selected heading (no drag) → edit in place.
			setEditing( i );
		}
		setDrag( null );
		try { e.currentTarget.releasePointerCapture( e.pointerId ); } catch ( err ) {}
	};
	// Keyboard nudge: move the selected heading by 1% (Shift = 5%). Each press is
	// one undo step — the accessible counterpart to dragging.
	const onBeatKey = ( e, i ) => {
		if ( ! tlOn ) return;
		const step = e.shiftKey ? 5 : 1;
		const p = beatPos( tlList[ i ] || {}, phone );
		let x = p.x, y = p.y, hit = true;
		if ( e.key === 'ArrowLeft' ) x -= step;
		else if ( e.key === 'ArrowRight' ) x += step;
		else if ( e.key === 'ArrowUp' ) y -= step;
		else if ( e.key === 'ArrowDown' ) y += step;
		else hit = false;
		if ( ! hit ) return;
		e.preventDefault();
		setSelected( i );
		commitPos( i, x, y );
	};

	// Track drag: trim a heading's scroll window (or move the whole band).
	const trackFrac = ( clientX ) => {
		const r = trackRef.current.getBoundingClientRect();
		return clampN( ( clientX - r.left ) / ( r.width || 1 ), 0, 1 );
	};
	const onBandDown = ( e, i, kind ) => {
		e.preventDefault();
		e.stopPropagation();
		setSelected( i );
		const w = beatWindow( i, tlList.length, tlList[ i ] || {} );
		const at = trackFrac( e.clientX );
		setDrag( { kind, i, in: w.in, out: w.out, grab: at, base: w } );
		setScrubAt( kind === 'out' ? w.out : w.in );
		try { e.currentTarget.setPointerCapture( e.pointerId ); } catch ( err ) {}
	};
	const onBandMove = ( e, i ) => {
		if ( ! drag || drag.i !== i || ( drag.kind !== 'in' && drag.kind !== 'out' && drag.kind !== 'band' ) ) return;
		const at = trackFrac( e.clientX );
		let ni = drag.in, no = drag.out;
		if ( drag.kind === 'in' ) { ni = snapFrac( Math.min( at, drag.out ) ); setScrubAt( ni ); }
		else if ( drag.kind === 'out' ) { no = snapFrac( Math.max( at, drag.in ) ); setScrubAt( no ); }
		else {
			const span = drag.base.out - drag.base.in;
			let start = snapFrac( clampN( drag.base.in + ( at - drag.grab ), 0, 1 - span ) );
			ni = start; no = start + span; setScrubAt( ni );
		}
		setDrag( { ...drag, in: ni, out: no } );
	};
	const onBandUp = ( e, i ) => {
		if ( ! drag || drag.i !== i ) return;
		if ( drag.kind === 'in' || drag.kind === 'out' || drag.kind === 'band' ) commitWin( i, drag.in, drag.out );
		setDrag( null );
		try { e.currentTarget.releasePointerCapture( e.pointerId ); } catch ( err ) {}
	};

	// Shared timeline chrome: the toolbar (drag tip + Desktop/Phone), the snap
	// guides, and the window track. Rendered by both the single-hero stage and
	// the chapter stage — the handlers above already route to the right list.
	const tlBar = (
		<div className="gene-hero__tl-bar" contentEditable={ false }>
			<span className="gene-hero__tl-tip">
				{ __( 'Drag a heading to place it; click a selected heading to edit its text. Trim when it appears on the track below.', 'gene-section-builder' ) }
			</span>
			<span className="gene-hero__tl-dev">
				<Button size="small" isPressed={ ! phone } onClick={ () => setDevice( 'desktop' ) }>
					{ __( 'Desktop', 'gene-section-builder' ) }
				</Button>
				<Button size="small" isPressed={ phone } onClick={ () => setDevice( 'phone' ) }>
					{ __( 'Phone', 'gene-section-builder' ) }
				</Button>
			</span>
		</div>
	);
	const tlGuides =
		drag && drag.kind === 'move' ? (
			<div className="gene-hero__tl-guides" contentEditable={ false }>
				<div
					className={ 'gene-hero__tl-guide gene-hero__tl-guide--v' + ( drag.snapX ? ' is-snap' : '' ) }
					style={ { left: ( drag.snapX ? drag.x : 50 ) + '%' } }
				/>
				<div
					className={ 'gene-hero__tl-guide gene-hero__tl-guide--h' + ( drag.snapY ? ' is-snap' : '' ) }
					style={ { top: ( drag.snapY ? drag.y : 50 ) + '%' } }
				/>
			</div>
		) : null;
	const tlTrack = (
		<div className="gene-hero__tl-track-wrap" contentEditable={ false }>
			<div
				className="gene-hero__tl-track"
				ref={ trackRef }
				style={ { height: tlList.length * 26 + 10 + 'px' } }
			>
				{ tlList.map( ( b, i ) => {
					const w = effWin( i );
					const isSel = i === sel;
					const bandHandlers = ( kind ) => ( {
						onPointerDown: ( e ) => onBandDown( e, i, kind ),
						onPointerMove: ( e ) => onBandMove( e, i ),
						onPointerUp: ( e ) => onBandUp( e, i ),
					} );
					return (
						<div
							key={ i }
							className={ 'gene-hero__tl-band' + ( isSel ? ' is-selected' : '' ) }
							style={ {
								left: w.in * 100 + '%',
								width: Math.max( 2, ( w.out - w.in ) * 100 ) + '%',
								top: i * 26 + 5 + 'px',
							} }
							{ ...bandHandlers( 'band' ) }
						>
							<span className="gene-hero__tl-h gene-hero__tl-h--in" { ...bandHandlers( 'in' ) } />
							<span className="gene-hero__tl-label">
								{ i + 1 }. { tlItemLabel( b ) }
							</span>
							<span className="gene-hero__tl-h gene-hero__tl-h--out" { ...bandHandlers( 'out' ) } />
						</div>
					);
				} ) }
				<div className="gene-hero__tl-playhead" style={ { left: seekAt * 100 + '%' } } />
			</div>
			<div className="gene-hero__tl-scale">
				<span>
					{ isTimelineMulti
						? __( 'Chapter start', 'gene-section-builder' )
						: __( 'Scroll start', 'gene-section-builder' ) }
				</span>
				<span>
					{ isTimelineMulti
						? __( 'Chapter end', 'gene-section-builder' )
						: __( 'Scroll end', 'gene-section-builder' ) }
				</span>
			</div>
		</div>
	);

	// Multi-scene canvas: preview one chapter (the sequence plays only on the
	// live page). A row of numbered tabs switches which chapter is shown. With
	// free placement on, the chapter becomes the same drag stage as the single
	// hero: drag each heading, trim its window on the track, frame-synced.
	const curBeat = curBeats[ 0 ] || {};
	const multiCanvas = (
		<>
			<div className="gene-hero__ms-bar" contentEditable={ false }>
				{ sceneList.map( ( sc, i ) => (
					<button
						key={ i }
						type="button"
						className={ 'gene-hero__ms-tab' + ( i === selSceneIdx ? ' is-on' : '' ) }
						onClick={ () => {
							setSelScene( i );
							setSelected( 0 );
							setScrubAt( null );
							setDrag( null );
							setEditing( null );
						} }
					>
						{ i + 1 }
					</button>
				) ) }
				{ ! isTimelineMulti ? (
					<span className="gene-hero__ms-tip">
						{ __( 'Preview a chapter. Each plays with the scroll on the live page.', 'gene-section-builder' ) }
					</span>
				) : null }
			</div>
			{ isTimelineMulti ? tlBar : null }
			<div className="gene-hero__pin" ref={ stageRef }>
				{ curBg.url ? (
					<div className="gene-hero__bg">
						<Media m={ curBg } />
						<div
							className="gene-hero__scrim"
							style={ { '--dim': ( ( typeof curScene.bgDim === 'number' ? curScene.bgDim : 0 ) || 0 ) / 100 } }
						/>
					</div>
				) : null }
				{ isTimelineMulti ? (
					/* Free placement: this chapter's headings, each draggable at its
					   own (x%, y%) — same stage as the single hero. The windows play
					   over this chapter's scroll segment on the live page. */
					<div className="gene-hero__beats">
						{ curBeats.map( ( bt, bi ) => {
							const p = effPos( bi );
							const editingThis = editing === bi && ! isBtnItem( bt );
							const dragHandlers = editingThis
								? {}
								: {
										onPointerDown: ( e ) => onBeatDown( e, bi ),
										onPointerMove: ( e ) => onBeatMove( e, bi ),
										onPointerUp: ( e ) => onBeatUp( e, bi ),
										onKeyDown: ( e ) => onBeatKey( e, bi ),
								  };
							return (
								<div
									key={ bi }
									className={
										'gene-hero__beat' +
										( isBtnItem( bt ) ? ' gene-hero__beat--btn' : '' ) +
										( bi === sel ? ' is-selected' : '' ) +
										( editingThis ? ' is-editing' : '' )
									}
									style={ {
										left: p.x + '%',
										top: p.y + '%',
										transform: 'translate(-50%, -50%)',
										cursor: editingThis ? 'text' : 'grab',
										touchAction: 'none',
										...beatActionsStyle( bt ),
									} }
									tabIndex={ 0 }
									onBlur={
										editingThis
											? ( e ) => { if ( ! e.currentTarget.contains( e.relatedTarget ) ) setEditing( null ); }
											: undefined
									}
									{ ...dragHandlers }
								>
									{ beatTextNodes( {
										b: bt,
										editing: editingThis,
										setField: ( k, v ) => setSceneBeat( selSceneIdx, bi, k, v ),
									} ) }
									{ previewButtons( bt ) || ( isBtnItem( bt ) ? btnPlaceholder() : null ) }
								</div>
							);
						} ) }
					</div>
				) : (
				<div className="gene-hero__beats">
					{ ( curBeat.heading || '' ).trim() || ( curBeat.eyebrow || '' ).trim() || ( curBeat.sub || '' ).trim() ? (
						<div className="gene-hero__beat">
							{ ( curBeat.eyebrow || '' ).trim() ? (
								<p className="gene-hero__eyebrow">{ curBeat.eyebrow }</p>
							) : null }
							{ ( curBeat.heading || '' ).trim() ? (
								<h2 className="gene-hero__head">{ lines( curBeat.heading ) }</h2>
							) : null }
							{ ( curBeat.sub || '' ).trim() ? (
								<p className="gene-hero__sub">{ curBeat.sub }</p>
							) : null }
							{ previewButtons( curBeat ) }
							{ curBeats.length > 1 ? (
								<p className="gene-hero__ms-count">
									{ curBeats.length + ' ' + __( 'headings swap here', 'gene-section-builder' ) }
								</p>
							) : null }
						</div>
					) : (
						<p className="gene-hero__hint">
							{ __( 'Set a background and heading for this chapter.', 'gene-section-builder' ) }
						</p>
					) }
				</div>
				) }
				{ isTimelineMulti ? tlGuides : null }
			</div>
			{ isTimelineMulti ? tlTrack : null }
		</>
	);

	return (
		<>
			<InspectorControls>
				{ /* Single-hero panels. While chapters play they'd be inert (the
				     chapters replace the single background + headings), so they
				     step aside and the Chapters panel leads. */ }
				{ ! isMultiActive ? (
				<>
				<PanelBody title={ __( 'Background', 'gene-section-builder' ) }>
					<MediaUploadCheck>
						<MediaUpload
							allowedTypes={ [ 'image', 'video' ] }
							value={ background && background.id }
							onSelect={ onSelectBackground }
							render={ ( { open } ) => (
								<Button variant="secondary" onClick={ open } style={ { marginBottom: '8px' } }>
									{ hasBg
										? __( 'Replace background', 'gene-section-builder' )
										: __( 'Set a background (image or video)', 'gene-section-builder' ) }
								</Button>
							) }
						/>
					</MediaUploadCheck>
					{ hasBg ? (
						<>
							<Button
								variant="link"
								isDestructive
								onClick={ () => setAttributes( { background: {} } ) }
								style={ { display: 'block', marginBottom: '8px' } }
							>
								{ __( 'Remove background', 'gene-section-builder' ) }
							</Button>
							<RangeControl
								label={ __( 'Dim overlay (%)', 'gene-section-builder' ) }
								value={ bgDim }
								min={ 0 }
								max={ 85 }
								onChange={ ( v ) => setAttributes( { bgDim: v } ) }
							/>
							{ isVideo ? (
								<p style={ { fontSize: '12px', color: '#757575', margin: '4px 0 0' } }>
									{ __(
										'The video scrubs with scroll. Export it with every frame as a keyframe (ffmpeg -g 1) for smooth seeking.',
										'gene-section-builder'
									) }
								</p>
							) : null }
							{ isVideo && isPro ? (
								<div style={ { marginTop: '12px', borderTop: '1px solid #e0e0e0', paddingTop: '12px' } }>
									<p style={ { fontSize: '12px', fontWeight: 600, margin: '0 0 6px' } }>
										{ __( 'Mobile video (optional)', 'gene-section-builder' ) }
									</p>
									<MediaUploadCheck>
										<MediaUpload
											allowedTypes={ [ 'video' ] }
											value={ backgroundMobile && backgroundMobile.id }
											onSelect={ ( m ) => {
												const mm = mapOne( m );
												if ( m && m.filesizeInBytes ) mm.bytes = Number( m.filesizeInBytes );
												setAttributes( { backgroundMobile: mm } );
											} }
											render={ ( { open } ) => (
												<Button variant="secondary" onClick={ open }>
													{ hasMobile
														? __( 'Replace mobile video', 'gene-section-builder' )
														: __( 'Set a mobile video', 'gene-section-builder' ) }
												</Button>
											) }
										/>
									</MediaUploadCheck>
									{ hasMobile ? (
										<Button
											variant="link"
											isDestructive
											onClick={ () => setAttributes( { backgroundMobile: {} } ) }
											style={ { display: 'block', marginTop: '6px' } }
										>
											{ __( 'Remove mobile video', 'gene-section-builder' ) }
										</Button>
									) : null }
									<p style={ { fontSize: '12px', color: '#757575', margin: '6px 0 0' } }>
										{ __(
											'Shown on phones (≤781px) in place of the main video. Export it all-keyframes too (ffmpeg -g 1).',
											'gene-section-builder'
										) }
									</p>
								</div>
							) : null }
							{ isVideo && ! isPro ? (
								<p style={ { fontSize: '12px', color: '#757575', margin: '8px 0 0' } }>
									{ __(
										'Show a separate video on phones with Scroll Hero Pro.',
										'gene-section-builder'
									) }
								</p>
							) : null }
							{ isVideo ? (
								<WeightGauge
									mainBytes={ bytesOf( background ) }
									mobBytes={ hasMobile && backgroundMobile.type === 'video' ? bytesOf( backgroundMobile ) : 0 }
									isPro={ isPro }
								/>
							) : null }
						</>
					) : null }
				</PanelBody>

				<PanelBody title={ __( 'Heading', 'gene-section-builder' ) }>
					{ shown.map( ( b, i ) => (
						<div
							key={ i }
							style={ {
								border: '1px solid #e0e0e0',
								borderRadius: '4px',
								padding: '10px',
								marginBottom: '12px',
							} }
						>
							{ isPro && list.length > 1 ? (
								<div
									style={ {
										display: 'flex',
										justifyContent: 'space-between',
										alignItems: 'center',
										marginBottom: '6px',
									} }
								>
									<strong style={ { fontSize: '12px' } }>
										{ isBtnItem( b )
											? __( 'Button', 'gene-section-builder' )
											: __( 'Heading', 'gene-section-builder' ) }{ ' ' }
										{ i + 1 }
									</strong>
									<span>
										<Button
											size="small"
											icon="arrow-up-alt2"
											label={ __( 'Move up', 'gene-section-builder' ) }
											disabled={ i === 0 }
											onClick={ () => moveBeat( i, -1 ) }
										/>
										<Button
											size="small"
											icon="arrow-down-alt2"
											label={ __( 'Move down', 'gene-section-builder' ) }
											disabled={ i === list.length - 1 }
											onClick={ () => moveBeat( i, 1 ) }
										/>
										<Button
											size="small"
											icon="trash"
											isDestructive
											label={ __( 'Remove', 'gene-section-builder' ) }
											onClick={ () => removeBeat( i ) }
										/>
									</span>
								</div>
							) : null }
							{ ! isBtnItem( b ) ? (
								<>
									<TextareaControl
										label={ __( 'Heading', 'gene-section-builder' ) }
										help={ __( 'Line breaks are kept.', 'gene-section-builder' ) }
										value={ b.heading || '' }
										onChange={ ( v ) => setBeat( i, 'heading', v ) }
									/>
									<TextControl
										label={ __( 'Sub-line', 'gene-section-builder' ) }
										value={ b.sub || '' }
										onChange={ ( v ) => setBeat( i, 'sub', v ) }
									/>
								</>
							) : null }
							{ /* With the timeline on, buttons live as their own items (Add
							     button below) — heading+buttons beats are auto-split, so
							     headings never show an inline buttons editor there. */ }
							{ isBtnItem( b ) || ! isTimeline ? (
								<BeatButtons buttons={ b.buttons } onChange={ ( v ) => setBeat( i, 'buttons', v ) } />
							) : null }
							{ isTimeline ? (
								<BeatPlacement
									b={ b }
									i={ i }
									count={ list.length }
									set={ ( k, v ) => setBeat( i, k, v ) }
									setPhone={ ( x, y ) => setBeatPhone( i, x, y ) }
									clearPhone={ () => clearBeatPhone( i ) }
								/>
							) : null }
						</div>
					) ) }

					{ isPro ? (
						<>
							<Button variant="primary" onClick={ addBeat }>
								{ __( 'Add heading', 'gene-section-builder' ) }
							</Button>
							{ isTimeline ? (
								<Button
									variant="secondary"
									style={ { marginLeft: '8px' } }
									onClick={ () =>
										setAttributes( {
											beats: [ ...list, { buttons: [ { label: '', url: '', style: 'solid' } ] } ],
										} )
									}
								>
									{ __( 'Add button', 'gene-section-builder' ) }
								</Button>
							) : null }
							<div style={ { marginTop: '16px' } }>
								<SelectControl
									label={ __( 'Headline placement', 'gene-section-builder' ) }
									value={ headlinePos === 'center' ? 'center' : 'bottom-left' }
									options={ [
										{ label: __( 'Bottom left', 'gene-section-builder' ), value: 'bottom-left' },
										{ label: __( 'Centered', 'gene-section-builder' ), value: 'center' },
									] }
									onChange={ ( v ) => setAttributes( { headlinePos: v } ) }
								/>
							</div>
						</>
					) : (
						<Notice status="info" isDismissible={ false }>
							{ __(
								'Add more headings that appear one after another — and place them freely on a scroll timeline — with Scroll Hero Pro.',
								'gene-section-builder'
							) }
							<div style={ { marginTop: '8px' } }>
								<Button variant="secondary" href={ upgradeUrl } target="_blank" rel="noopener">
									{ __( 'Get Pro', 'gene-section-builder' ) }
								</Button>
							</div>
						</Notice>
					) }
				</PanelBody>

				{ /* Everything that shapes the scroll ride, in one place. */ }
				{ isPro || isVideo ? (
					<PanelBody title={ __( 'Scroll & motion', 'gene-section-builder' ) } initialOpen={ false }>
						{ isPro ? (
							<RangeControl
								label={ __( 'Scroll length (screens)', 'gene-section-builder' ) }
								help={ __(
									'How many viewport-heights of scrolling play through the hero.',
									'gene-section-builder'
								) }
								value={ scrubScreens }
								min={ 2 }
								max={ 8 }
								onChange={ ( v ) => setAttributes( { scrubScreens: v } ) }
							/>
						) : null }
						{ isPro ? (
							<>
								<ToggleControl
									label={ __( 'Free placement (timeline)', 'gene-section-builder' ) }
									help={ __(
										'Place each heading anywhere over the hero and set when it appears and leaves as visitors scroll — like slides on a timeline.',
										'gene-section-builder'
									) }
									checked={ !! timeline }
									onChange={ ( v ) => setTimelineMode( v ) }
								/>
								{ timeline ? (
									<SelectControl
										label={ __( 'Shared effect', 'gene-section-builder' ) }
										help={ __( 'Used by every heading unless it sets its own.', 'gene-section-builder' ) }
										value={ beatAnim }
										options={ [
											{ label: __( 'Fade', 'gene-section-builder' ), value: 'fade' },
											{ label: __( 'Rise', 'gene-section-builder' ), value: 'rise' },
											{ label: __( 'Slide', 'gene-section-builder' ), value: 'slide' },
											{ label: __( 'Reveal', 'gene-section-builder' ), value: 'reveal' },
										] }
										onChange={ ( v ) => setAttributes( { beatAnim: v } ) }
									/>
								) : null }
							</>
						) : null }
						{ isVideo ? (
							<div style={ isPro ? { marginTop: '12px', borderTop: '1px solid #e0e0e0', paddingTop: '12px' } : undefined }>
								<ToggleControl
									label={ __( 'Break apart at the end', 'gene-section-builder' ) }
									help={ __(
										'When the scroll reaches the end, the video breaks apart. Scrolling back reassembles it.',
										'gene-section-builder'
									) }
									checked={ !! endShatter }
									onChange={ ( v ) => setAttributes( { endShatter: v } ) }
								/>
								{ endShatter && isPro ? (
									<SelectControl
										label={ __( 'Break style', 'gene-section-builder' ) }
										value={ shatterStyle === 'split' ? 'split' : 'glass' }
										options={ [
											{ label: __( 'Glass shards', 'gene-section-builder' ), value: 'glass' },
											{ label: __( 'Split apart', 'gene-section-builder' ), value: 'split' },
										] }
										onChange={ ( v ) => setAttributes( { shatterStyle: v } ) }
									/>
								) : null }
								{ endShatter && isPro ? (
									<RangeControl
										label={ __( 'Break length (screens)', 'gene-section-builder' ) }
										help={ __(
											'How much scrolling the break takes. Shorter feels snappier.',
											'gene-section-builder'
										) }
										value={ shatterZone }
										min={ 8 }
										max={ 40 }
										step={ 2 }
										onChange={ ( v ) => setAttributes( { shatterZone: v } ) }
									/>
								) : null }
								{ endShatter && ! isPro ? (
									<p style={ { fontSize: '12px', color: '#757575', margin: '4px 0 0' } }>
										{ __(
											'Choose the break style (glass or split) and tune its speed with Scroll Hero Pro.',
											'gene-section-builder'
										) }
									</p>
								) : null }
							</div>
						) : null }
					</PanelBody>
				) : null }
				</>
				) : null }

			<PanelBody title={ __( 'Chapters (multi-scene)', 'gene-section-builder' ) } initialOpen={ !! multiScene }>
					{ isPro ? (
						<>
							<ToggleControl
								label={ __( 'Play chapters as you scroll', 'gene-section-builder' ) }
								help={ __(
									'Add several background videos; each scrubs with the scroll, then hands off to the next. While chapters play, the hero\'s single background and headings step aside.',
									'gene-section-builder'
								) }
								checked={ !! multiScene }
								onChange={ ( v ) => setAttributes( { multiScene: v } ) }
							/>
							{ multiScene ? (
								<>
									{ sceneList.length === 0 ? (
										<p style={ { fontSize: '12px', color: '#757575', margin: '8px 0' } }>
											{ __(
												'Add a chapter to begin. Each chapter is one background (a video works best) with a heading.',
												'gene-section-builder'
											) }
										</p>
									) : null }
									{ sceneList.map( ( sc, i ) => {
										const bg = sc.background || {};
										const scVideo = bg.type === 'video';
										const scDim = typeof sc.bgDim === 'number' ? sc.bgDim : 10;
										return (
											<div
												key={ i }
												style={ {
													border: '1px solid #e0e0e0',
													borderRadius: '4px',
													padding: '10px',
													marginBottom: '12px',
												} }
											>
												<div
													style={ {
														display: 'flex',
														justifyContent: 'space-between',
														alignItems: 'center',
														marginBottom: '6px',
													} }
												>
													<strong style={ { fontSize: '12px' } }>
														{ __( 'Chapter', 'gene-section-builder' ) } { i + 1 }
													</strong>
													<span>
														<Button
															size="small"
															icon="arrow-up-alt2"
															label={ __( 'Move up', 'gene-section-builder' ) }
															disabled={ i === 0 }
															onClick={ () => moveScene( i, -1 ) }
														/>
														<Button
															size="small"
															icon="arrow-down-alt2"
															label={ __( 'Move down', 'gene-section-builder' ) }
															disabled={ i === sceneList.length - 1 }
															onClick={ () => moveScene( i, 1 ) }
														/>
														<Button
															size="small"
															icon="trash"
															isDestructive
															label={ __( 'Remove', 'gene-section-builder' ) }
															onClick={ () => removeScene( i ) }
														/>
													</span>
												</div>
												<MediaUploadCheck>
													<MediaUpload
														allowedTypes={ [ 'image', 'video' ] }
														value={ bg.id }
														onSelect={ ( m ) => onSceneBg( i, m ) }
														render={ ( { open } ) => (
															<Button
																variant="secondary"
																onClick={ () => {
																	setSelScene( i );
																	open();
																} }
																style={ { marginBottom: '8px' } }
															>
																{ bg.url
																	? __( 'Replace background', 'gene-section-builder' )
																	: __( 'Set a background (image or video)', 'gene-section-builder' ) }
															</Button>
														) }
													/>
												</MediaUploadCheck>
												{ bg.url ? (
													<Button
														variant="link"
														isDestructive
														onClick={ () => setSceneField( i, 'background', {} ) }
														style={ { display: 'block', marginBottom: '8px' } }
													>
														{ __( 'Remove background', 'gene-section-builder' ) }
													</Button>
												) : null }
												{ scVideo ? (
													<p style={ { fontSize: '12px', color: '#757575', margin: '0 0 8px' } }>
														{ __(
															'Export it all-keyframes (ffmpeg -g 1) so the scrub stays smooth.',
															'gene-section-builder'
														) }
													</p>
												) : null }
												<RangeControl
													label={ __( 'Dim overlay (%)', 'gene-section-builder' ) }
													value={ scDim }
													min={ 0 }
													max={ 85 }
													onChange={ ( v ) => setSceneField( i, 'bgDim', v ) }
												/>
												{ /* Free placement positions each heading itself. */ }
												{ ! timeline ? (
												<SelectControl
													label={ __( 'Headline placement', 'gene-section-builder' ) }
													help={ __( 'This chapter only — each chapter can put its headline somewhere else.', 'gene-section-builder' ) }
													value={
														sc.pos === 'bottom-left' || sc.pos === 'center' || sc.pos === 'bottom-right'
															? sc.pos
															: ''
													}
													options={ [
														{ label: __( 'Default (hero setting)', 'gene-section-builder' ), value: '' },
														{ label: __( 'Bottom left', 'gene-section-builder' ), value: 'bottom-left' },
														{ label: __( 'Centered', 'gene-section-builder' ), value: 'center' },
														{ label: __( 'Bottom right', 'gene-section-builder' ), value: 'bottom-right' },
													] }
													onChange={ ( v ) => setSceneField( i, 'pos', v ) }
												/>
												) : null }
												{ beatsOf( sc ).map( ( bt, bi, arr ) => (
													<div
														key={ bi }
														style={
															arr.length > 1
																? { border: '1px solid #ededed', borderRadius: '3px', padding: '8px', marginBottom: '8px' }
																: { marginBottom: '4px' }
														}
													>
														{ arr.length > 1 ? (
															<div
																style={ {
																	display: 'flex',
																	justifyContent: 'space-between',
																	alignItems: 'center',
																	marginBottom: '4px',
																} }
															>
																<strong style={ { fontSize: '11px', color: '#757575' } }>
																	{ isBtnItem( bt )
																		? __( 'Button', 'gene-section-builder' )
																		: __( 'Heading', 'gene-section-builder' ) }{ ' ' }
																	{ bi + 1 }
																</strong>
																<span>
																	<Button
																		size="small"
																		icon="arrow-up-alt2"
																		label={ __( 'Move up', 'gene-section-builder' ) }
																		disabled={ bi === 0 }
																		onClick={ () => moveSceneBeat( i, bi, -1 ) }
																	/>
																	<Button
																		size="small"
																		icon="arrow-down-alt2"
																		label={ __( 'Move down', 'gene-section-builder' ) }
																		disabled={ bi === arr.length - 1 }
																		onClick={ () => moveSceneBeat( i, bi, 1 ) }
																	/>
																	<Button
																		size="small"
																		icon="trash"
																		isDestructive
																		label={ __( 'Remove', 'gene-section-builder' ) }
																		onClick={ () => removeSceneBeat( i, bi ) }
																	/>
																</span>
															</div>
														) : null }
														{ ! isBtnItem( bt ) ? (
															<>
																<TextareaControl
																	label={ __( 'Heading', 'gene-section-builder' ) }
																	help={ __( 'Line breaks are kept.', 'gene-section-builder' ) }
																	value={ bt.heading || '' }
																	onChange={ ( v ) => setSceneBeat( i, bi, 'heading', v ) }
																/>
																<TextControl
																	label={ __( 'Sub-line', 'gene-section-builder' ) }
																	value={ bt.sub || '' }
																	onChange={ ( v ) => setSceneBeat( i, bi, 'sub', v ) }
																/>
															</>
														) : null }
														{ isBtnItem( bt ) || ! timeline ? (
															<BeatButtons buttons={ bt.buttons } onChange={ ( v ) => setSceneBeat( i, bi, 'buttons', v ) } />
														) : null }
														{ timeline ? (
															<BeatPlacement
																b={ bt }
																i={ bi }
																count={ arr.length }
																set={ ( k, v ) => setSceneBeat( i, bi, k, v ) }
																setPhone={ ( x, y ) => setSceneBeatPhone( i, bi, x, y ) }
																clearPhone={ () => clearSceneBeatPhone( i, bi ) }
															/>
														) : null }
													</div>
												) ) }
												<Button variant="tertiary" onClick={ () => addSceneBeat( i ) }>
													{ __( 'Add heading to this chapter', 'gene-section-builder' ) }
												</Button>
												{ timeline ? (
													<Button
														variant="tertiary"
														onClick={ () =>
															setSceneBeats( i, [
																...beatsOf( sceneList[ i ] || {} ),
																{ buttons: [ { label: '', url: '', style: 'solid' } ] },
															] )
														}
													>
														{ __( 'Add button to this chapter', 'gene-section-builder' ) }
													</Button>
												) : null }
												{ scVideo ? (
													<div style={ { marginTop: '10px', borderTop: '1px dashed #e0e0e0', paddingTop: '10px' } }>
														<p style={ { fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#757575', margin: '0 0 6px' } }>
															{ __( 'Mobile clip (optional)', 'gene-section-builder' ) }
														</p>
														<MediaUploadCheck>
															<MediaUpload
																allowedTypes={ [ 'video' ] }
																value={ sc.backgroundMobile && sc.backgroundMobile.id }
																onSelect={ ( m ) => onSceneMobile( i, m ) }
																render={ ( { open } ) => (
																	<Button variant="secondary" onClick={ open }>
																		{ sc.backgroundMobile && sc.backgroundMobile.url
																			? __( 'Replace mobile clip', 'gene-section-builder' )
																			: __( 'Set a mobile clip', 'gene-section-builder' ) }
																	</Button>
																) }
															/>
														</MediaUploadCheck>
														{ sc.backgroundMobile && sc.backgroundMobile.url ? (
															<Button
																variant="link"
																isDestructive
																onClick={ () => setSceneField( i, 'backgroundMobile', {} ) }
																style={ { display: 'block', marginTop: '6px' } }
															>
																{ __( 'Remove mobile clip', 'gene-section-builder' ) }
															</Button>
														) : null }
														<p style={ { fontSize: '12px', color: '#757575', margin: '6px 0 0' } }>
															{ __( 'Shown on phones (≤781px) for this chapter. Export it all-keyframes too.', 'gene-section-builder' ) }
														</p>
														<WeightGauge
															mainBytes={ bytesOf( bg ) }
															mobBytes={ sc.backgroundMobile && sc.backgroundMobile.type === 'video' ? bytesOf( sc.backgroundMobile ) : 0 }
															isPro={ isPro }
														/>
													</div>
												) : null }
											</div>
										);
									} ) }
									<Button variant="primary" onClick={ addScene }>
										{ __( 'Add chapter', 'gene-section-builder' ) }
									</Button>
									<div style={ { marginTop: '14px', borderTop: '1px solid #e0e0e0', paddingTop: '12px' } }>
										<SelectControl
											label={ __( 'Chapter transition', 'gene-section-builder' ) }
											help={ __(
												'How one chapter breaks apart to reveal the next as you scroll.',
												'gene-section-builder'
											) }
											value={ shatterStyle === 'split' ? 'split' : 'glass' }
											options={ [
												{ label: __( 'Glass shards', 'gene-section-builder' ), value: 'glass' },
												{ label: __( 'Split apart', 'gene-section-builder' ), value: 'split' },
											] }
											onChange={ ( v ) => setAttributes( { shatterStyle: v } ) }
										/>
										<RangeControl
											label={ __( 'Scroll length (screens)', 'gene-section-builder' ) }
											help={ __(
												'How many viewport-heights of scrolling play through the hero.',
												'gene-section-builder'
											) }
											value={ scrubScreens }
											min={ 2 }
											max={ 8 }
											onChange={ ( v ) => setAttributes( { scrubScreens: v } ) }
										/>
										<ToggleControl
											label={ __( 'Free placement (timeline)', 'gene-section-builder' ) }
											help={ __(
												'Place each heading anywhere over the hero and set when it appears and leaves as visitors scroll — like slides on a timeline.',
												'gene-section-builder'
											) }
											checked={ !! timeline }
											onChange={ ( v ) => setTimelineMode( v ) }
										/>
										{ timeline ? (
											<SelectControl
												label={ __( 'Shared effect', 'gene-section-builder' ) }
												help={ __( 'Used by every heading unless it sets its own.', 'gene-section-builder' ) }
												value={ beatAnim }
												options={ [
													{ label: __( 'Fade', 'gene-section-builder' ), value: 'fade' },
													{ label: __( 'Rise', 'gene-section-builder' ), value: 'rise' },
													{ label: __( 'Slide', 'gene-section-builder' ), value: 'slide' },
													{ label: __( 'Reveal', 'gene-section-builder' ), value: 'reveal' },
												] }
												onChange={ ( v ) => setAttributes( { beatAnim: v } ) }
											/>
										) : null }
									</div>
									<p style={ { fontSize: '12px', color: '#757575', margin: '10px 0 0' } }>
										{ __(
											'Scroll length applies per chapter. Keep each clip short and all-keyframes — only the current and next chapter ever load.',
											'gene-section-builder'
										) }
									</p>
								</>
							) : null }
						</>
					) : (
						<Notice status="info" isDismissible={ false }>
							{ __(
								'Play a sequence of background videos — each scrubs with the scroll, then hands off to the next — with Scroll Hero Pro.',
								'gene-section-builder'
							) }
							<div style={ { marginTop: '8px' } }>
								<Button variant="secondary" href={ upgradeUrl } target="_blank" rel="noopener">
									{ __( 'Get Pro', 'gene-section-builder' ) }
								</Button>
							</div>
						</Notice>
					) }
				</PanelBody>

				<PanelColorSettings
				title={ __( 'Design', 'gene-section-builder' ) }
				initialOpen={ false }
				colorSettings={ [
					{
						value: headingColor || undefined,
						onChange: ( v ) => setAttributes( { headingColor: v || '' } ),
						label: __( 'Heading color', 'gene-section-builder' ),
					},
					{
						value: buttonColor || undefined,
						onChange: ( v ) => setAttributes( { buttonColor: v || '' } ),
						label: __( 'Button color', 'gene-section-builder' ),
					},
					{
						value: buttonTextColor || undefined,
						onChange: ( v ) => setAttributes( { buttonTextColor: v || '' } ),
						label: __( 'Button text color', 'gene-section-builder' ),
					},
				] }
			>
				<p style={ { fontSize: '12px', color: '#757575', margin: '0 0 12px' } }>
					{ __( 'The button color fills solid buttons and outlines ghost ones; the text color is for solid buttons.', 'gene-section-builder' ) }
				</p>
				{ isPro ? (
					<>
						<SelectControl
							label={ __( 'Font', 'gene-section-builder' ) }
							help={ __( 'System fonts only — nothing loads from outside, so the page stays fast.', 'gene-section-builder' ) }
							value={ fontPreset === 'sans' || fontPreset === 'serif' || fontPreset === 'mono' ? fontPreset : '' }
							options={ [
								{ label: __( 'Theme default', 'gene-section-builder' ), value: '' },
								{ label: __( 'Clean sans-serif', 'gene-section-builder' ), value: 'sans' },
								{ label: __( 'Elegant serif', 'gene-section-builder' ), value: 'serif' },
								{ label: __( 'Monospace', 'gene-section-builder' ), value: 'mono' },
							] }
							onChange={ ( v ) => setAttributes( { fontPreset: v } ) }
						/>
						<SelectControl
							label={ __( 'Button shape', 'gene-section-builder' ) }
							value={ buttonShape === 'rounded' || buttonShape === 'square' ? buttonShape : 'pill' }
							options={ [
								{ label: __( 'Pill', 'gene-section-builder' ), value: 'pill' },
								{ label: __( 'Rounded', 'gene-section-builder' ), value: 'rounded' },
								{ label: __( 'Square', 'gene-section-builder' ), value: 'square' },
							] }
							onChange={ ( v ) => setAttributes( { buttonShape: v } ) }
						/>
					</>
				) : (
					<p style={ { fontSize: '12px', color: '#757575', margin: 0 } }>
						{ __( 'Font choices and button shapes come with Scroll Hero Pro.', 'gene-section-builder' ) }{ ' ' }
						<a href={ upgradeUrl } target="_blank" rel="noopener noreferrer">
							{ __( 'Get Pro', 'gene-section-builder' ) }
						</a>
					</p>
				) }
			</PanelColorSettings>
			</InspectorControls>

			<div { ...blockProps }>
				{ isMultiActive ? multiCanvas : (
				<>
				{ isTimeline ? tlBar : null }
				<div className="gene-hero__pin" ref={ stageRef }>
					{ hasBg ? (
						<div className="gene-hero__bg">
							<Media m={ background } />
							<div className="gene-hero__scrim" style={ { '--dim': ( bgDim || 0 ) / 100 } } />
						</div>
					) : null }
					<div className="gene-hero__beats">
						{ list.some( ( b ) => beatText( b ) || ( isTimeline && isBtnItem( b ) ) ) ? (
							( isPro ? list : list.slice( 0, 1 ) ).map( ( b, i ) => {
								const p = isTimeline ? effPos( i ) : null;
								const isSel = isTimeline && i === sel;
								const editingThis = isTimeline && editing === i && ! isBtnItem( b );
								const style = isTimeline
									? {
											left: p.x + '%',
											top: p.y + '%',
											transform: 'translate(-50%, -50%)',
											cursor: editingThis ? 'text' : 'grab',
											touchAction: 'none',
											...beatActionsStyle( b ),
									  }
									: undefined;
								const handlers =
									isTimeline && ! editingThis
										? {
												tabIndex: 0,
												onPointerDown: ( e ) => onBeatDown( e, i ),
												onPointerMove: ( e ) => onBeatMove( e, i ),
												onPointerUp: ( e ) => onBeatUp( e, i ),
												onKeyDown: ( e ) => onBeatKey( e, i ),
										  }
										: isTimeline
										? { tabIndex: 0 }
										: {};
								return (
									<div
										className={
											'gene-hero__beat' +
											( isTimeline && isBtnItem( b ) ? ' gene-hero__beat--btn' : '' ) +
											( isSel ? ' is-selected' : '' ) +
											( editingThis ? ' is-editing' : '' )
										}
										key={ i }
										style={ style }
										onBlur={
											editingThis
												? ( e ) => { if ( ! e.currentTarget.contains( e.relatedTarget ) ) setEditing( null ); }
												: undefined
										}
										{ ...handlers }
									>
										{ isTimeline ? (
											beatTextNodes( {
												b,
												editing: editingThis,
												setField: ( k, v ) => setBeat( i, k, v ),
											} )
										) : (
											<>
												{ ( b.eyebrow || '' ).trim() ? (
													<p className="gene-hero__eyebrow">{ b.eyebrow }</p>
												) : null }
												{ ( b.heading || '' ).trim() ? (
													<h2 className="gene-hero__head">{ lines( b.heading ) }</h2>
												) : null }
												{ ( b.sub || '' ).trim() ? (
													<p className="gene-hero__sub">{ b.sub }</p>
												) : null }
											</>
										) }
										{ previewButtons( b ) || ( isTimeline && isBtnItem( b ) ? btnPlaceholder() : null ) }
									</div>
								);
							} )
						) : (
							<p className="gene-hero__hint">
								{ __( 'Set a background and write a heading in the sidebar.', 'gene-section-builder' ) }
							</p>
						) }
					</div>
					{ isTimeline ? tlGuides : null }
				</div>
				{ isTimeline ? tlTrack : null }
				</>
				) }
			</div>
		</>
	);
}
