Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 4.8 97cb439508 site-builder: fix Menu guided panel (empty Colors) + surface layout/nav colors
The shared NavStylePanel gated its Colors controls on the Navbar's prop
names (backgroundColor/textColor/ctaColor), so a Menu -- whose color props
are linkColor/linkHoverColor/ctaBgColor/ctaTextColor -- rendered an empty
Colors section (customer report: 'nothing to select').

- Add navColorFields() helper: derives the visible color controls from the
  props actually present, covering both the Navbar and Menu schemas
  (+ Navbar's hoverColor, which render/toHtml consume but had no control).
- Add a Menu Layout section (alignment/orientation/gap/font size), guarded
  on Menu's own props so it never leaks into Navbar or a standalone Logo.
- Unit test (navColorFields.test.ts) locks each component to its real props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:10:46 -07:00
shadowdao d20b77e66d site-builder: dedicated Features editor in guided panel (per-card icon/image+upload/button, robust to template items missing keys) 2026-07-07 17:19:37 -07:00
shadowdao 5a26e4ef43 site-builder: FeaturesGrid renders image when 'image' set + expose image/button keys so the guided array editor edits them 2026-07-07 14:16:14 -07:00
shadowdao 1cfb51f181 site-builder: surface Spotify/Twitch + contact-form recipient in the Styles (guided) panel
The right panel only renders GuidedStyles (there is no Settings tab), so the
per-component Settings panels I'd edited never showed. Add Spotify/Twitch to
SocialStylePanel's platform dropdown and the 'Send submissions to' + thank-you
fields to FormStylePanel (shown for any form with a recipientEmail prop).
2026-07-07 14:06:24 -07:00
shadowdao 814ad29b91 Merge branch 'site-builder-feedback-batch' 2026-07-07 13:50:12 -07:00
shadowdao b9c5d3dd1c site-builder: relay wiring on FormContainer (template forms) + shared helper
The recipient field was only on the ContactForm block; templates build forms
from FormContainer + InputField, so template-based contact forms had no way to
set a target address. Add 'Send submissions to' + thank-you fields to
FormContainer, and extract the marker/placeholder/honeypot into a shared
form-relay-wiring helper so ContactForm and FormContainer can't drift.
2026-07-07 13:35:18 -07:00
jknapp 6b9c258d26 Merge pull request 'ContactForm relay wiring (recipient, thank-you, honeypot, marker)' (#2) from contact-form-relay into main 2026-07-07 19:35:27 +00:00
shadowdaoandClaude Opus 4.8 53c40f856f site-builder: feedback batch (social, features, header menu, spacer)
Five of six items from user feedback (Contact Form email delivery split
into a focused, live-tested follow-up):

- Social Links: add Spotify + Twitch (FA 4.7.0 already ships both glyphs).
- Features Grid: per-feature icon/image toggle (upload + URL) and an
  optional button (text + url); render, settings, and HTML export updated,
  backward compatible with existing icon-only features.
- Header: seed the default header with a Navbar (logo + Home/About/Services/
  Contact) so new sites open with an editable menu-with-links instead of an
  empty header zone. Adds a vitest guard that the seed deserializes and
  exports a real <nav>.
- Canvas: slim the empty header/footer placeholder from a padded band to a
  thin hint line so an empty zone no longer reads as a stray spacer.

Design spec: docs/superpowers/specs/2026-07-06-site-builder-feedback-batch-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:53:22 -07:00
17 changed files with 654 additions and 29 deletions
@@ -29,6 +29,8 @@ const platformIcons: Record<string, string> = {
pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp',
spotify: 'fa-spotify',
twitch: 'fa-twitch',
};
const platformLabels: Record<string, string> = {
@@ -42,6 +44,8 @@ const platformLabels: Record<string, string> = {
pinterest: 'Pinterest',
snapchat: 'Snapchat',
whatsapp: 'WhatsApp',
spotify: 'Spotify',
twitch: 'Twitch',
};
const allPlatforms = Object.keys(platformIcons);
+2 -9
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
@@ -435,15 +436,7 @@ ContactForm.craft = {
alignSelf: 'flex-start',
});
const useRelay = !!props.recipientEmail;
const fid = 'F' + Math.random().toString(36).slice(2, 8);
const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#');
const honeypot = useRelay
? `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`
: '';
const marker = useRelay
? `<!--WHP-FORM id="${fid}" recipient="${esc(props.recipientEmail)}" thankyou="${esc(props.thankYouUrl || '')}"-->`
: '';
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction);
return {
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
@@ -0,0 +1,27 @@
import { describe, test, expect } from 'vitest';
import { FormContainer } from './FormContainer';
const toHtml = (FormContainer as any).toHtml;
describe('FormContainer.toHtml relay wiring', () => {
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
expect(html).toMatch(/<!--WHP-FORM id="F[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/);
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
expect(html).toContain('name="_gotcha"');
// honeypot precedes the form's children
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
// marker id === action id
const mid = html.match(/id="(F[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
expect(html).not.toContain('WHP-FORM');
expect(html).not.toContain('_gotcha');
expect(html).toContain('action="/legacy"');
expect(html).toContain('<input name="email">');
});
});
+34 -1
View File
@@ -2,10 +2,13 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
interface FormContainerProps {
action?: string;
method?: 'GET' | 'POST';
recipientEmail?: string;
thankYouUrl?: string;
style?: CSSProperties;
children?: React.ReactNode;
}
@@ -51,6 +54,31 @@ const FormContainerSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
<input
type="email"
value={props.recipientEmail || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.recipientEmail = e.target.value; })}
placeholder="you@example.com"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server. Leave blank to use the Form Action URL below instead.
</p>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
<input
type="text"
value={props.thankYouUrl || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.thankYouUrl = e.target.value; })}
placeholder="/thank-you (blank = hosted page)"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
@@ -110,6 +138,8 @@ FormContainer.craft = {
props: {
action: '#',
method: 'POST',
recipientEmail: '',
thankYouUrl: '',
style: {
padding: '24px',
backgroundColor: '#ffffff',
@@ -134,7 +164,10 @@ FormContainer.craft = {
padding: '24px',
...props.style,
});
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action);
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child
return {
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
};
};
+107 -6
View File
@@ -7,6 +7,30 @@ interface FeatureItem {
title: string;
description: string;
icon: string;
mediaType?: 'icon' | 'image'; // default behaves as 'icon' when undefined
image?: string;
imageAlt?: string;
buttonText?: string;
buttonUrl?: string;
}
/* ---------- Image upload helper (same as Navbar/ImageBlock) ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
interface FeaturesGridProps {
@@ -15,10 +39,13 @@ interface FeaturesGridProps {
anchorId?: string;
}
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
// the guided panel's generic array editor (which derives fields from the first
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
const defaultFeatures: FeatureItem[] = [
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
];
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
@@ -56,9 +83,26 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
border: '1px solid #e2e8f0',
}}
>
{feat.image ? (
<img
src={feat.image}
alt={feat.imageAlt || feat.title || ''}
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
/>
) : (
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
)}
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
{feat.buttonText ? (
<a
href={feat.buttonUrl || '#'}
onClick={(e) => e.preventDefault()}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
>
{feat.buttonText}
</a>
) : null}
</div>
))}
</div>
@@ -88,9 +132,14 @@ const FeaturesGridSettings: React.FC = () => {
});
};
const handleImageUpload = async (index: number, file: File) => {
const url = await uploadToWhp(file);
if (url) updateFeature(index, 'image', url);
};
const addFeature = () => {
setProp((p: FeaturesGridProps) => {
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }];
});
};
@@ -147,6 +196,52 @@ const FeaturesGridSettings: React.FC = () => {
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
{/* Icon / Image toggle */}
<div style={{ display: 'flex', gap: 4 }}>
{(['icon', 'image'] as const).map((mt) => {
const active = (feat.mediaType || 'icon') === mt;
return (
<button
key={mt}
onClick={() => updateFeature(i, 'mediaType', mt)}
style={{
flex: 1, padding: '3px 6px', fontSize: 11, cursor: 'pointer',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
}}
>
{mt === 'icon' ? 'Icon' : 'Image'}
</button>
);
})}
</div>
{(feat.mediaType || 'icon') === 'image' ? (
<>
<div style={{ display: 'flex', gap: 4 }}>
<label
style={{ padding: '3px 6px', fontSize: 11, cursor: 'pointer', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, flex: 'none', whiteSpace: 'nowrap' }}
>
Upload
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleImageUpload(i, f); }}
/>
</label>
<input type="text" value={feat.image || ''} onChange={(e) => updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
</div>
<input type="text" value={feat.imageAlt || ''} onChange={(e) => updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} />
</>
) : null}
{/* Button (optional) */}
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginTop: 2 }}>Button (optional)</label>
<input type="text" value={feat.buttonText || ''} onChange={(e) => updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} />
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} />
</div>
))}
</div>
@@ -190,10 +285,16 @@ FeaturesGrid.craft = {
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => {
const media = feat.image
? `<img src="${esc(feat.image)}" alt="${esc(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>`;
const button = feat.buttonText
? `\n <a href="${esc(feat.buttonUrl || '#')}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${esc(feat.buttonText)}</a>`
: '';
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
${media}
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>${button}
</div>`;
}).join('\n ');
+3 -2
View File
@@ -48,9 +48,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
data-zone-preview={zone}
style={{
width: '100%',
minHeight: 40,
// Slim hint bar, not a content-height band — an empty zone should not
// read as a stray spacer between the page and the header/footer.
padding: '5px 12px',
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
padding: '12px 24px',
color: zone === 'header' ? '#9ca3af' : '#64748b',
textAlign: 'center',
fontSize: 11,
@@ -0,0 +1,88 @@
import React, { useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { labelStyle, inputStyle, sectionGap } from './shared';
/* Upload helper (same contract as ImageBlock/Navbar): uploads to WHP if a
WHP_CONFIG is present, otherwise falls back to a local blob URL. */
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
const fd = new FormData();
fd.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST', headers: { 'X-CSRF-Token': cfg.csrfToken }, body: fd,
});
const data = await resp.json();
return data.success && data.url ? data.url : null;
} catch { return null; }
}
interface Feature {
title?: string; description?: string; icon?: string;
image?: string; imageAlt?: string; buttonText?: string; buttonUrl?: string;
}
/**
* Dedicated per-feature editor for the FeaturesGrid guided panel. Unlike the
* generic array editor (which only shows fields already present on the item),
* this always exposes icon / image (upload or URL) / button controls, so it
* works on template-created features that lack those keys. An image renders in
* place of the icon whenever `image` is set.
*/
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
const { actions } = useEditor();
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
const update = (i: number, field: keyof Feature, value: string) =>
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; });
const add = () =>
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
const onUpload = async (i: number, file: File) => { const url = await uploadToWhp(file); if (url) update(i, 'image', url); };
const smallBtn: React.CSSProperties = { padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7' };
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
</div>
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
{/* Media: image (upload/URL) takes precedence over the icon when set */}
{feat.image ? (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<img src={feat.image} alt="" style={{ width: 28, height: 28, objectFit: 'cover', borderRadius: 4, border: '1px solid #3f3f46' }} />
<input type="text" value={feat.image} onChange={(e) => update(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => update(i, 'image', '')} title="Use icon instead" style={smallBtn}>Icon</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
<button onClick={() => fileRefs.current[i]?.click()} style={{ ...smallBtn, flex: 1 }}> Upload image</button>
<input type="text" value={feat.image || ''} onChange={(e) => update(i, 'image', e.target.value)} placeholder="or image URL" style={{ ...inputStyle, flex: 1 }} />
</div>
)}
<input ref={(el) => { fileRefs.current[i] = el; }} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(i, f); e.target.value = ''; }} />
{/* Optional button */}
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={feat.buttonText || ''} onChange={(e) => update(i, 'buttonText', e.target.value)} placeholder="Button text (optional)" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => update(i, 'buttonUrl', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
))}
<button onClick={add} style={{ ...labelStyle, padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', textAlign: 'center' }}>
+ Add Feature
</button>
</div>
);
};
@@ -35,6 +35,24 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
return (
<>
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */}
{nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Send submissions to (email)</label>
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
</p>
</div>
)}
{nodeProps.thankYouUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Thank-you page URL (optional)</label>
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setProp('thankYouUrl', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
</div>
)}
{/* Form action/method */}
{nodeProps.action !== undefined && (
<div style={sectionGap}>
@@ -9,6 +9,8 @@ import {
PresetButtonGrid,
CollapsibleSection,
ColorPickerField,
navColorFields,
btnActiveStyle,
labelStyle,
inputStyle,
smallInputStyle,
@@ -56,6 +58,21 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
/* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
/* Color controls, derived from the props the selected component actually has */
const colorFields = navColorFields(nodeProps);
/* Menu layout controls (alignment/orientation/gap/font size). These props are
unique to the Menu component — Navbar uses navAlignment and has no
orientation/gap/fontSize — so guarding on their presence scopes this section
to the Menu without leaking into Navbar or a standalone Logo. */
const hasMenuLayout = !isStandaloneLogo && (
nodeProps.alignment !== undefined ||
nodeProps.orientation !== undefined ||
nodeProps.gap !== undefined ||
nodeProps.fontSize !== undefined
);
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
return (
<>
{/* Standalone Logo component settings */}
@@ -165,17 +182,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</CollapsibleSection>
)}
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
{!isStandaloneLogo && (
{/* Colors (not shown for standalone Logo - it has its own color picker).
Fields are derived from whichever color props the selected component
actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…)
each get the right controls instead of an empty section. */}
{!isStandaloneLogo && colorFields.length > 0 && (
<CollapsibleSection title="Colors">
{nodeProps.backgroundColor !== undefined && (
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
{colorFields.map((f) => (
<ColorPickerField
key={f.key}
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setProp(f.key, v)}
/>
))}
</CollapsibleSection>
)}
{nodeProps.textColor !== undefined && (
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
{/* Menu layout (alignment / orientation / gap / font size) */}
{hasMenuLayout && (
<CollapsibleSection title="Layout">
{nodeProps.alignment !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle((nodeProps.alignment || 'right') === a)}>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
)}
{nodeProps.ctaColor !== undefined && (
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
{nodeProps.orientation !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button key={o} onClick={() => setProp('orientation', o)} style={btnActiveStyle((nodeProps.orientation || 'horizontal') === o)}>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</div>
</div>
)}
{nodeProps.gap !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Gap</label>
<PresetButtonGrid presets={GAP_PRESETS} activeValue={nodeProps.gap} onSelect={(v) => setProp('gap', v)} />
</div>
)}
{nodeProps.fontSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
</div>
)}
</CollapsibleSection>
)}
@@ -18,6 +18,7 @@ import {
smallInputStyle,
sectionGap,
} from './shared';
import { FeaturesEditor } from './FeaturesEditor';
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
@@ -96,6 +97,15 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
{/* Array props (features, items, plans, testimonials, etc.) */}
{arrayProps.map(([key, items]) => {
// FeaturesGrid gets a dedicated editor (icon/image+upload/button per card),
// robust to template features that lack the image/button keys.
if (key === 'features') {
return (
<CollapsibleSection key={key} title="Features" defaultOpen>
<FeaturesEditor selectedId={selectedId} features={items} />
</CollapsibleSection>
);
}
const arrayItems = items as any[];
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
const sampleItem = arrayItems[0] || {};
@@ -83,7 +83,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
))}
</select>
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { navColorFields } from './shared';
import { Menu } from '../../../components/basic/Menu';
import { Navbar } from '../../../components/basic/Navbar';
/*
* Regression: the Menu component's color props (linkColor/linkHoverColor/
* ctaBgColor/ctaTextColor) differ from the Navbar's (backgroundColor/textColor/
* ctaColor). NavStylePanel's "Colors" section is shared across both, so it must
* derive its fields from the actual props present — otherwise a Menu shows an
* empty "Colors" dropdown with nothing to select.
*/
describe('navColorFields', () => {
it('surfaces the Menu color props (bug: was empty for Menu)', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).toContain('linkColor');
expect(keys).toContain('linkHoverColor');
expect(keys).toContain('ctaBgColor');
expect(keys).toContain('ctaTextColor');
});
it('never returns an empty list for a Menu (no empty Colors dropdown)', () => {
const menuProps = (Menu as any).craft.props;
expect(navColorFields(menuProps).length).toBeGreaterThan(0);
});
it('surfaces the Navbar color props (incl. hover/ctaText the panel never exposed before)', () => {
const navProps = (Navbar as any).craft.props;
const keys = navColorFields(navProps).map((f) => f.key);
// every field surfaced must be a real, consumed prop on the Navbar
expect(keys).toContain('backgroundColor');
expect(keys).toContain('textColor');
expect(keys).toContain('hoverColor');
expect(keys).toContain('ctaColor');
expect(keys).toContain('ctaTextColor');
// must NOT invent Menu-only props on a Navbar
expect(keys).not.toContain('linkColor');
expect(keys).not.toContain('ctaBgColor');
});
it('does not surface Navbar-only props on a Menu', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).not.toContain('backgroundColor');
expect(keys).not.toContain('hoverColor'); // Menu uses linkHoverColor
expect(keys).not.toContain('ctaColor');
});
it('only returns fields whose prop is actually present', () => {
// A bare Logo (text logo) has no link/cta color props -> should not invent them
const keys = navColorFields({ backgroundColor: '#fff' }).map((f) => f.key);
expect(keys).toEqual(['backgroundColor']);
});
});
+29
View File
@@ -247,6 +247,35 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
</div>
);
/* ---------- 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<string, any>): 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);
+55 -1
View File
@@ -60,6 +60,60 @@ const EMPTY_HEADER =
const EMPTY_FOOTER =
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
// Default header seed: a ROOT header Container holding a Navbar with default
// links. New sites previously opened with an EMPTY header (just a bare
// Container), so there was no menu to edit and the empty zone rendered as a
// stray band above the page. Seeding a real Navbar gives every new site an
// editable menu-with-links out of the box (and removes the empty-header gap).
// Node shape matches serializeTreeForCraft() / Craft's actions.deserialize().
export const DEFAULT_HEADER_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: { width: '100%' }, tag: 'header' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: ['header-navbar'],
linkedNodes: {},
},
'header-navbar': {
type: { resolvedName: 'Navbar' },
isCanvas: false,
props: {
logoType: 'text',
logoText: 'MySite',
logoImage: '',
logoWidth: '120px',
logoUrl: '/',
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
links: [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
],
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: { borderBottom: '1px solid #e4e4e7' },
},
displayName: 'Navbar',
parent: 'ROOT',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
const PageContext = createContext<PageContextValue>({
pages: [],
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
@@ -106,7 +160,7 @@ const DEFAULT_HEADER: PageData = {
id: HEADER_ID,
name: 'Header',
slug: '__header__',
craftState: null,
craftState: DEFAULT_HEADER_STATE,
headCode: '',
};
+30
View File
@@ -0,0 +1,30 @@
import { describe, test, expect } from 'vitest';
import { DEFAULT_HEADER_STATE } from './PageContext';
import { exportBodyHtml } from '../utils/html-export';
/**
* The default header is seeded as a hand-authored serialized Craft state. A
* malformed node map would not fail typecheck but would break the header on
* every new site at runtime, so we validate it deserializes + exports through
* the same path the editor preview and the WHP publish step use.
*/
describe('DEFAULT_HEADER_STATE seed', () => {
test('is valid JSON with a ROOT header + a Navbar child', () => {
const parsed = JSON.parse(DEFAULT_HEADER_STATE);
expect(parsed.ROOT).toBeDefined();
expect(parsed.ROOT.type.resolvedName).toBe('Container');
expect(parsed.ROOT.props.tag).toBe('header');
expect(parsed.ROOT.nodes).toContain('header-navbar');
expect(parsed['header-navbar'].type.resolvedName).toBe('Navbar');
expect(parsed['header-navbar'].parent).toBe('ROOT');
});
test('exports to header HTML containing the nav and its default links', () => {
const { html } = exportBodyHtml(DEFAULT_HEADER_STATE);
expect(html).toContain('<nav');
expect(html).toContain('MySite');
for (const link of ['Home', 'About', 'Services', 'Contact']) {
expect(html).toContain(link);
}
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Shared contact-form relay wiring for HTML export.
*
* Any form component that wants to deliver submissions by email (ContactForm,
* FormContainer, ...) emits the SAME marker/placeholder/honeypot shape so the
* WHP publish step (`fs_rewrite_contact_forms`) can provision a token, rewrite
* the action, and strip the marker. Keeping this in one place means the two
* components can't drift apart (a drift would leak the recipient into published
* HTML — see PR #47 review).
*/
const esc = (s: unknown): string =>
String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */
useRelay: boolean;
/** the `<!--WHP-FORM ...-->` marker (stripped at publish); '' when not relay */
marker: string;
/** value for the form's `action` attribute (placeholder when relay, else the escaped fallback) */
actionAttr: string;
/** hidden honeypot `<input>` to render as the form's first child; '' when not relay */
honeypot: string;
}
/**
* @param recipientEmail the "Send submissions to" address (empty/undefined = no relay)
* @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page)
* @param fallbackAction the form's existing action to use when no recipient is set
*/
export function relayFormWiring(
recipientEmail: string | undefined,
thankYouUrl: string | undefined,
fallbackAction: string | undefined,
): RelayWiring {
if (!recipientEmail) {
return { useRelay: false, marker: '', actionAttr: esc(fallbackAction || '#'), honeypot: '' };
}
const fid = 'F' + Math.random().toString(36).slice(2, 8);
return {
useRelay: true,
marker: `<!--WHP-FORM id="${fid}" recipient="${esc(recipientEmail)}" thankyou="${esc(thankYouUrl || '')}"-->`,
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
};
}
@@ -0,0 +1,75 @@
# Site Builder — feedback batch (2026-07-06)
Six items from user feedback on the Craft.js site builder (`/workspace/site-builder/craft/`).
Scope decisions were made with the user before writing this spec. Each item is
independently shippable.
## 1 & 2 — Spotify + Twitch social links
**File:** `craft/src/components/basic/SocialLinks.tsx`
Add `spotify → { icon: 'fa-spotify', label: 'Spotify' }` and `twitch → { icon:
'fa-twitch', label: 'Twitch' }` to `platformIcons` + `platformLabels`. The "Add
Platform" dropdown, editor render, and `toHtml` pick them up automatically.
Font Awesome 4.7.0 (loaded in `craft/index.html`) already ships both glyphs — no
CDN change. **Twitch live-status indicator is explicitly deferred** (needs a
Twitch app Client-ID + published-site JS) — see Follow-ups.
## 3 — Features: image option + optional button (structured, no canvas rewrite)
**File:** `craft/src/components/sections/FeaturesGrid.tsx`
Extend `FeatureItem`:
```ts
mediaType?: 'icon' | 'image'; // default 'icon'
image?: string; imageAlt?: string;
buttonText?: string; buttonUrl?: string;
```
- Per-feature settings gain an icon/image toggle. Image mode = upload + URL input,
reusing the `uploadToWhp(file)` helper pattern from `Navbar.tsx`/`ImageBlock.tsx`.
- Optional button fields (text + url); render a button under the description when
`buttonText` is set.
- Editor render + `toHtml` updated: `<img>` when `mediaType==='image'`, else the
glyph; button block when present. Backward compatible (missing `mediaType`
falls back to icon).
## 4 & 6 — Menu links in the header (seed a real default header)
**Files:** `craft/src/state/PageContext.tsx` (default header craftState), minor
`craft/src/panels/left/PagesPanel.tsx` hint.
Root cause of "no obvious way to add links to the menu": the default header
(`DEFAULT_HEADER` craftState) is an **empty `Container`** — new sites open with a
blank header and nothing to edit. Fix: seed the default header with a `Navbar`
(logo + page links + CTA) so every new site has an editable menu out of the box.
Add a one-line hint in the header-edit empty state pointing at the Navbar's Links
section. No new component.
## 5 — "Spacer" gap between page and header
**File:** `craft/src/components/layout/HeaderZone.tsx` (+ header default from #4/6)
Same root cause: the empty header zone's `minHeight` renders as a blank band that
reads as a stray spacer. Seeding real header content (item 4/6) removes it in the
common case. Additionally collapse the empty-state min-height so a deliberately
empty header leaves no gap. **Reproduce first** to confirm there is not also a
literal stray `Spacer` node before finalizing.
## 7 — Contact Form email delivery
**Files:** `craft/src/components/forms/ContactForm.tsx`,
`whp/web-files/api/site-builder.php` (publish/deploy path), self-contained
`contact-handler.php` dropped into the site docroot at publish.
- Add a **"Send submissions to" recipient email** field.
- Published sites live on the customer docroot (not the panel), so the working
form needs a handler at the site. At publish/deploy time, if any page contains
a ContactForm, write a self-contained `contact-handler.php` into the docroot
with the recipient stored server-side (sidecar config, kept out of client HTML
to avoid harvesting).
- `toHtml` emits `action="contact-handler.php"`, a hidden honeypot field, and a
thank-you redirect. Handler validates + sends via PHP `mail()`.
- SMTP hardening via the existing mailer noted as a Follow-up.
## Build & deploy
Frontend: `cd craft && npm run build` (runs `tsc && vite build`). Deploy is the
standard WHP release pipeline (copy `dist/` into the whp repo → build-release →
download-update); **not** auto-deployed here — customer-facing UI, deploy is a
separate authorized step.
## Follow-ups (not in this batch)
- Twitch live-status badge (Twitch Helix + published-site JS + Client-ID config).
- ContactForm SMTP delivery via the WHP mailer instead of PHP `mail()`.
## Build order
1&2 (trivial) → 3 → 4/5/6 (shared header work) → 7 (largest, touches deploy path).