import React, { useState, useCallback, CSSProperties } from 'react'; import { useEditor } from '@craftjs/core'; import { GRADIENTS, SIZE_PRESETS, ASPECT_RATIOS, SPACING_PRESETS, BORDER_STYLES, } from '../../../constants/presets'; import { uploadAsset } from '../../../utils/assets'; /* ---------- Helper: auto text color for bg ---------- */ export function autoTextColor(bg: string): string { if (bg.startsWith('#')) { const hex = bg.replace('#', ''); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.5 ? '#18181b' : '#ffffff'; } return '#ffffff'; } /* ---------- Helper: upload to WHP ---------- Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so the existing callers importing `uploadToWhp` from here keep working unchanged. */ export const uploadToWhp = uploadAsset; /* ---------- Shared inline styles ---------- */ export const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4, textTransform: 'capitalize' }; export const inputStyle: CSSProperties = { width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, boxSizing: 'border-box' }; export const smallInputStyle: CSSProperties = { ...inputStyle, fontSize: 11, padding: '3px 6px' }; export const btnActiveStyle = (active: boolean): CSSProperties => ({ flex: 1, padding: '5px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#a1a1aa', fontWeight: active ? 600 : 400, }); export const sectionGap: CSSProperties = { marginBottom: 14 }; /* ---------- Reusable sub-components ---------- */ interface SectionLabelProps { children: React.ReactNode; } export const SectionLabel: React.FC = ({ children }) => ( ); interface ColorSwatchGridProps { colors: { label: string; value: string }[]; activeValue: string | undefined; onSelect: (value: string) => void; } export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect }) => (
onSelect(e.target.value)} style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> onSelect(e.target.value)} placeholder="#000000" style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
{colors.map((c) => (
); interface PresetButtonGridProps { presets: { label: string; value: string }[]; activeValue: string | undefined; onSelect: (value: string) => void; /** Explicit column count. When omitted, a column count is derived from * `presets.length` (see `defaultPresetGridColumns`) so odd-sized preset * sets (5, 6, ...) don't leave a lone orphan button dangling on its own * row under the fixed 4-column grid. */ columns?: number; } /** Picks a column count that avoids a single orphan on the last row. * 4-or-fewer presets keep the classic single row of 4. 5 gets its own * row (5 cols). 6 splits into two even rows of 3. Anything else falls * back to a 4- or 3-column grid depending on which divides evenly. */ export function defaultPresetGridColumns(count: number): number { if (count <= 4) return 4; if (count === 5) return 5; if (count === 6) return 3; if (count % 4 === 0) return 4; if (count % 3 === 0) return 3; return 4; } export const PresetButtonGrid: React.FC = ({ presets, activeValue, onSelect, columns }) => { const cols = columns ?? defaultPresetGridColumns(presets.length); return (
{presets.map((p) => ( ))}
); }; interface GradientSwatchGridProps { activeValue: string | undefined; onSelect: (value: string) => void; } /* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */ function parseGradient(val: string | undefined): { angle: number; from: string; to: string } { if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' }; const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/); if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] }; return { angle: 135, from: '#667eea', to: '#764ba2' }; } export const GradientSwatchGrid: React.FC = ({ activeValue, onSelect }) => { const [showCustom, setShowCustom] = useState(false); const parsed = parseGradient(activeValue); const [customFrom, setCustomFrom] = useState(parsed.from); const [customTo, setCustomTo] = useState(parsed.to); const [customAngle, setCustomAngle] = useState(parsed.angle); const applyCustomGradient = (from: string, to: string, angle: number) => { setCustomFrom(from); setCustomTo(to); setCustomAngle(angle); onSelect(`linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`); }; return (
{/* Custom gradient builder toggle */} {showCustom && (
applyCustomGradient(e.target.value, customTo, customAngle)} style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> applyCustomGradient(e.target.value, customTo, customAngle)} style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
applyCustomGradient(customFrom, e.target.value, customAngle)} style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> applyCustomGradient(customFrom, e.target.value, customAngle)} style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
applyCustomGradient(customFrom, customTo, parseInt(e.target.value))} style={{ width: '100%' }} />
{/* Live preview */}
)} {/* Preset swatches */}
{GRADIENTS.map((g) => ( ))}
); }; interface TextInputFieldProps { label: string; value: string; placeholder?: string; onChange: (value: string) => void; } export const TextInputField: React.FC = ({ label, value, placeholder, onChange }) => (
{label} onChange(e.target.value)} />
); /* ---------- Color picker with hex input ---------- */ interface ColorPickerFieldProps { label: string; value: string; onChange: (value: string) => void; } export const ColorPickerField: React.FC = ({ label, value, onChange }) => (
onChange(e.target.value)} style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> onChange(e.target.value)} placeholder="#000000" style={{ ...inputStyle, flex: 1 }} />
); /* ---------- Nav-family color fields ---------- The Colors section is shared across the whole nav family (Navbar / Logo / Footer / Menu). Those components DON'T share a color-prop schema: Navbar/Logo/Footer use backgroundColor/textColor/ctaColor, while Menu uses linkColor/linkHoverColor/ctaBgColor/ctaTextColor. Deriving the visible fields from whichever props actually exist keeps the section from rendering empty (the "Colors dropdown with nothing to select" bug on Menu). */ export interface NavColorField { key: string; label: string; fallback: string; } const NAV_COLOR_FIELDS: NavColorField[] = [ // Navbar / Logo / Footer schema { key: 'backgroundColor', label: 'Background', fallback: '#ffffff' }, { key: 'textColor', label: 'Text Color', fallback: '#18181b' }, { key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' }, { key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' }, // Menu schema (distinct prop names) { key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' }, { key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' }, { key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' }, // ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text { key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' }, ]; export function navColorFields(nodeProps: Record): NavColorField[] { return NAV_COLOR_FIELDS.filter((f) => nodeProps[f.key] !== undefined); } /* ---------- Collapsible section ---------- */ export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => { const [open, setOpen] = useState(defaultOpen); return (
{open &&
{children}
}
); }; /* ---------- StylePanelProps interface ---------- */ export interface StylePanelProps { selectedId: string; nodeProps: Record; } /* ---------- useNodeProp ---------- Shared setProp/setPropStyle boilerplate repeated across many *StylePanel.tsx files. Only adopted where a panel's inline definitions were byte-equivalent to this (some panels use different param names or extra logic and keep their own local versions). */ export function useNodeProp(selectedId: string) { const { actions } = useEditor(); const setProp = useCallback((key: string, value: any) => actions.setProp(selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]); const setPropStyle = useCallback((prop: string, value: string) => actions.setProp(selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]); return { setProp, setPropStyle }; } /* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */ interface ArrayPropEditorProps { selectedId: string; propKey: string; items: any[]; renderItem: (item: any, index: number) => React.ReactNode; emptyItem: any; } export const ArrayPropEditor: React.FC = ({ selectedId, propKey, items, renderItem, emptyItem }) => { const { actions } = useEditor(); const addItem = useCallback(() => { actions.setProp(selectedId, (props: any) => { props[propKey] = [...(props[propKey] || []), typeof emptyItem === 'object' ? { ...emptyItem } : emptyItem]; }); }, [actions, selectedId, propKey, emptyItem]); const removeItem = useCallback((index: number) => { actions.setProp(selectedId, (props: any) => { const updated = [...(props[propKey] || [])]; updated.splice(index, 1); props[propKey] = updated; }); }, [actions, selectedId, propKey]); return (
{items.map((item, i) => (
{renderItem(item, i)}
))}
); }; /* ========================================================================= ENH-Foundation: reusable StylePanel controls --------------------------------------------------------------------- Presentational (value + onChange) controls shared by upcoming feature panels (size/spacing/border/typography/animation/visibility rollout). Nothing below is wired into any feature panel yet -- that's the job of the downstream feature branches. Each control's onChange API is documented on its props interface; see .superpowers/sdd/task-enh-foundation-report.md for the consolidated list. ========================================================================= */ /* ---------- NumericUnitInput ---------- A number field + unit dropdown that parses/holds a CSS length string ("16px", "50%", "auto", "") and calls onChange(nextString). - Clearing the number field emits '' (not '0px') -- lets the consumer treat '' as "unset/inherit" instead of forcing a 0 value. - value === 'auto' or '' both display as a blank number field (with the `placeholder` -- default "auto" -- shown as a hint). - Changing the unit re-emits with the current number and the new unit; if the number is blank, changing the unit is a no-op (still emits ''). - `units` is configurable (default ['px','%']); the unit is hidden when * this has length 1. */ units?: string[]; min?: number; max?: number; step?: number; placeholder?: string; /** Base for the data-testid attrs (`${testId}`, `${testId}-number`, * `${testId}-unit`); default 'numeric-unit'. */ testId?: string; } export const NumericUnitInput: React.FC = ({ value, onChange, units = ['px', '%'], min, max, step, placeholder = 'auto', testId = 'numeric-unit', }) => { const { num, unit } = parseCssLength(value, units); const emit = (nextNum: string, nextUnit: string) => { if (nextNum === '') { onChange(''); return; } onChange(`${nextNum}${nextUnit}`); }; return (
emit(e.target.value, unit)} style={{ ...inputStyle, flex: 1 }} /> {units.length > 1 && ( )}
); }; /* ---------- SizeControl ---------- Width (or height) editor: preset buttons (25/50/75/100%, Auto, Full) + a custom NumericUnitInput. One dimension per instance -- render two (label="Width" / label="Height") for components needing both, e.g. ImageBlock / VideoBlock sizing. */ export interface SizeControlProps { label: string; /** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */ value: string; onChange: (value: string) => void; presets?: { label: string; value: string }[]; units?: string[]; } export const SizeControl: React.FC = ({ label, value, onChange, presets = SIZE_PRESETS, units = ['px', '%'] }) => (
{label}
p.value === value) ? '' : value} onChange={onChange} units={units} testId="size-control-custom" />
); /* ---------- AspectRatioControl ---------- Preset buttons + a custom "W:H" text entry. Emits a CSS `aspect-ratio` value, e.g. "16 / 9". 'Original' emits ''. Custom entry accepts "16:9" / "16/9" / "16x9" and is parsed on blur or Enter. */ export function parseAspectRatioInput(raw: string): string { const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$/); if (!m) return ''; return `${m[1]} / ${m[2]}`; } function formatAspectRatioForInput(value: string): string { const m = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/); return m ? `${m[1]}:${m[2]}` : ''; } export interface AspectRatioControlProps { /** CSS `aspect-ratio` value, e.g. "16 / 9", or '' for Original/unset. */ value: string; onChange: (value: string) => void; presets?: { label: string; value: string }[]; } export const AspectRatioControl: React.FC = ({ value, onChange, presets = ASPECT_RATIOS }) => { const [custom, setCustom] = useState(() => formatAspectRatioForInput(value)); const applyCustom = () => onChange(parseAspectRatioInput(custom)); return (
Aspect Ratio { onChange(v); setCustom(''); }} />
setCustom(e.target.value)} onBlur={applyCustom} onKeyDown={(e) => { if (e.key === 'Enter') applyCustom(); }} style={{ ...inputStyle }} />
); }; /* ---------- FocalPointGrid ---------- 3x3 grid of buttons mapping to CSS `object-position` keyword pairs. Used for image crop framing (object-position on an ImageBlock/VideoBlock with object-fit: cover). Highlights the active cell; defaults the highlight to 'center' when value is empty. */ export const FOCAL_POINTS: { label: string; value: string }[] = [ { label: '↖', value: 'left top' }, { label: '↑', value: 'center top' }, { label: '↗', value: 'right top' }, { label: '←', value: 'left center' }, { label: '•', value: 'center' }, { label: '→', value: 'right center' }, { label: '↙', value: 'left bottom' }, { label: '↓', value: 'center bottom' }, { label: '↘', value: 'right bottom' }, ]; export interface FocalPointGridProps { /** CSS `object-position` value, e.g. "center" / "left top". */ value: string; onChange: (value: string) => void; } export const FocalPointGrid: React.FC = ({ value, onChange }) => { const active = value || 'center'; return (
Focal Point
{FOCAL_POINTS.map((p) => ( ))}
); }; /* ---------- SpacingControl ---------- Margin OR padding, per-side, with a link/unlink toggle. API: onChange(side, value) -- called once per side. In linked mode (default), editing the single shared field or clicking a preset calls onChange for all four sides ('top','right','bottom','left') with the same value so the consumer can setPropStyle each side independently (e.g. marginTop/marginRight/marginBottom/marginLeft). In unlinked mode each side's NumericUnitInput calls onChange for just that side. */ export type SpacingSide = 'top' | 'right' | 'bottom' | 'left'; export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; } const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left']; export interface SpacingControlProps { label: string; value: SpacingSideValues; /** Called per-side. Linked edits/presets call this once for each of the * 4 sides (in top/right/bottom/left order) with the same value. */ onChange: (side: SpacingSide, value: string) => void; presets?: { label: string; value: string }[]; units?: string[]; } export const SpacingControl: React.FC = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => { const [linked, setLinked] = useState(true); const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v)); const linkedValue = value.top ?? ''; const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue); return (
{label}
{linked ? (
) : (
{SPACING_SIDES.map((s) => (
onChange(s, v)} units={units} testId={`spacing-${s}`} />
))}
)}
); }; /* ---------- BorderControl ---------- Width (px) + style (none/solid/dashed/dotted) + color. Single onChange receives the merged { width, style, color } object so the consumer can either setProp each part or build a shorthand via buildBorderShorthand() (returns 'none' when width is falsy or style is 'none'). */ export interface BorderValue { width: string; style: string; color: string; } export interface BorderControlProps { value: BorderValue; onChange: (value: BorderValue) => void; label?: string; } export const BorderControl: React.FC = ({ value, onChange, label = 'Border' }) => { const set = (patch: Partial) => onChange({ ...value, ...patch }); return (
{label}
set({ width: v })} units={['px']} placeholder="0" testId="border-width" />
set({ color: v })} />
); }; export function buildBorderShorthand(value: BorderValue): string { if (!value.width || !value.style || value.style === 'none') return 'none'; return `${value.width} ${value.style} ${value.color || '#000000'}`; } /* ---------- AnimationControl ---------- "Entrance animation" picker + optional delay. Emits the EXACT prop names the export machinery already consumes (verified against buildDataAttrs() in src/utils/html-export.ts): `animation` and `animationDelay`. NOTE: the export supports fade-in / slide-up / slide-left / slide-right / zoom-in / bounce (no "slide-down" -- html-export.ts has no such variant; slide-left/slide-right exist instead), so ANIMATIONS reflects that exact set rather than guessing. animationDelay is a plain seconds string (e.g. "0.3") -- html-export.ts's inline script assigns it straight to el.style.animationDelay, so consumers should suffix/accept a unit-bearing string like "0.3s" if that's what they choose to store; this control stores/edits the raw numeric string and leaves unit formatting to the caller (kept as the simplest contract; see report for the exact behavior verified against html-export.ts). */ export const ANIMATIONS: { label: string; value: string }[] = [ { label: 'None', value: 'none' }, { label: 'Fade In', value: 'fade-in' }, { label: 'Slide Up', value: 'slide-up' }, { label: 'Slide Left', value: 'slide-left' }, { label: 'Slide Right', value: 'slide-right' }, { label: 'Zoom In', value: 'zoom-in' }, { label: 'Bounce', value: 'bounce' }, ]; export interface AnimationValue { animation: string; animationDelay?: string; } export interface AnimationControlProps { value: AnimationValue; onChange: (value: AnimationValue) => void; } export const AnimationControl: React.FC = ({ value, onChange }) => { const animation = value.animation || 'none'; const animationDelay = value.animationDelay ?? '0'; return (
Entrance Animation onChange({ animation: v, animationDelay })} /> {animation !== 'none' && (
onChange({ animation, animationDelay: e.target.value })} style={inputStyle} />
)}
); }; /* ---------- VisibilityControl ---------- Hide on Desktop / Tablet / Mobile toggles. Emits the EXACT prop names the export machinery already consumes (verified against buildDataAttrs() in src/utils/html-export.ts): `hideOnDesktop`, `hideOnTablet`, `hideOnMobile` (each rendered as data-hide-desktop/tablet/mobile when truthy). Single onChange receives the merged object. */ export interface VisibilityValue { hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } export interface VisibilityControlProps { value: VisibilityValue; onChange: (value: VisibilityValue) => void; } const VISIBILITY_KEYS: (keyof VisibilityValue)[] = ['hideOnDesktop', 'hideOnTablet', 'hideOnMobile']; export const VisibilityControl: React.FC = ({ value, onChange }) => (
Visibility
{VISIBILITY_KEYS.map((key) => ( ))}
);