- AssetsPanel: asset delete now requires an in-app two-step confirm
(tile-button turns into "Delete?" + cancel, auto-resets after 4s or on
click-elsewhere) instead of deleting with no confirmation at all.
- AssetsPanel: copyUrl uses a new copyToClipboard() helper that tries the
async Clipboard API and falls back to a hidden-textarea execCommand copy
in non-secure contexts, surfacing a visible "Copy failed" state instead
of silently doing nothing.
- SitesmithModal: replaced window.confirm(...) for "Clear chat" with the
same in-app two-step confirm pattern -- no native dialogs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two components of the det-id bug class were missed in the earlier migration:
NumberCounter's wrapper/counter ids and count-up script, and the shared
form-relay-wiring fid (used by ContactForm/FormContainer). Both used
Math.random() for exported HTML ids, breaking caching/diffing across exports.
Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId
through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and
FormContainer.toHtml, both now passing nodeId as their 3rd arg).
ContentSlider: autoplay's setInterval now pauses on mouseenter and on
document visibilitychange (tab hidden), resumes on mouseleave/visible,
and is always clearable (timer var + clearInterval). The aria-live
region is "off" while autoplay is silently auto-rotating and only
flips to "polite" on manual next/prev/dot navigation, so screen readers
aren't spammed with an announcement every `interval` ms (F-export
review Minor). Updates the one existing test that hard-coded
aria-live="polite" as always-on to match this intentional behavior
change.
(Countdown's ticking-interval-stops-at-zero nit shipped in the previous
commit alongside its node-id migration, since both touched the same
inline script.)
Adds regression tests locking in that MapEmbed/VideoBlock iframe src
attributes (built by string concatenation with literal `&` query
params) are HTML-entity-encoded via the existing escapeAttr(safeUrl())
pipeline -- verified already correct, no source change needed there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.
Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
produced identical aria-controls/aria-labelledby ids, so one instance's
arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
sharing a (often default) name produced duplicate <label for>/<input
id> pairs, breaking the for/id association for one of them.
Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.
All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderNode now passes the Craft node id as toHtml's 3rd argument
(props, childrenHtml, nodeId). Backward compatible: the resolver map is
untyped (any), so existing 2-arg toHtml implementations/tests are
unaffected. Adds scopeId()/stableHash() helpers in utils/escape.ts:
scopeId derives a scope string from the node id (deterministic AND
unique, since Craft node ids are unique per node and stable across
repeated exports of the same page) with a stableHash(seed) fallback for
legacy call sites without a node id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- InputField/TextareaField/ContactForm: every control gets a
deterministic id (slugId() in utils/escape.ts, derived from the
field's name/label + index for ContactForm's looped fields -- no
Math.random) with a matching <label for=>; fields with no visible
label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
navigation menu", aria-controls="navbar-links", and aria-expanded
wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
(generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
aria-hidden; SocialLinks' icon-only links get an aria-label naming
the platform alongside the existing title tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ContentSlider, Tabs, and Gallery toHtml exports and their inline scripts
now carry ARIA semantics and keyboard support:
- ContentSlider: prev/next arrows and dot buttons get aria-labels, the
slide stack is wrapped in an aria-live="polite" region, and the
decorative chevron icons are aria-hidden.
- Tabs: tablist/tab/tabpanel roles, aria-selected/aria-controls/
aria-labelledby, and Left/Right/Home/End arrow-key navigation in the
inline script. The tab<->panel id scope is now derived from a
deterministic hash of anchorId/labels instead of Math.random, since
the ARIA linking ids must be stable across export runs.
- Gallery: the lightbox overlay gets role="dialog"/aria-modal/aria-label,
Escape closes it, and thumbnails are keyboard-operable (role="button"
tabindex="0" plus Enter/Space handling in the existing delegated
listener).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
style: null fell through the shallow-merge branch's `value && typeof
value === 'object'` guard and hit the `p[key] = value` fallback, setting
p.style = null and losing every existing style key. Treat style: null (or
any other non-object style value) as "ignore this key" instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildNodeTree now delegates its structural walk (ColumnLayout linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) to flattenTreeForCraft,
materializing each flat node into a real Craft.js Node via
query.parseFreshNode — except the synthetic SHELL_INNER wrapper, which is
still hand-built (parseFreshNode would merge in Container's default
craft.props and change its look). sanitizeAiTree now lives in craft-tree.ts
and is re-exported here for existing callers.
Delete the dead serializeTreeForCraft (only its own tests called it) and
its now-orphaned tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
treeToState had no resolvedName allowlist, so an AI `replace` response
(scope site/page/header/footer, reaches this function via
actions.deserialize with no further validation downstream) containing an
unknown component could produce a state that throws at deserialize/render.
Extract the core transform into standalone treeToCraftState (directly
unit-testable without mounting a Craft.js Editor), route it through
sanitizeAiTree for the same resolvedName allowlist + id/ROOT repair
buildNodeTree already had, and fall back to the empty canvas if the tree's
own root is invalid. Delegate the structural walk to the shared
flattenTreeForCraft extracted in the previous commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PageContext.treeToState, apply-ai-response.buildNodeTree, and the dead
serializeTreeForCraft each re-implemented the same ColumnLayout-linkedNodes +
SHELL_INNER walk and had drifted (buildNodeTree gained guards the others
lacked). Move sanitizeAiTree (resolvedName allowlist + id/ROOT repair) here
too and add flattenTreeForCraft, the shared structural walk (linkedNodes,
SHELL_INNER wrapping, style:[]->{} normalization) both real callers will
consolidate onto.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Footer only committed edited text via onBlur, and its effect rewrote
innerText from the (stale) text prop whenever selected became false --
if selection cleared without a real DOM blur, the in-progress edit was
silently lost. Adopt Heading.tsx's exact mechanism: an editedTextRef
updated on onInput, committed to the prop via an effect keyed on the
selected->false transition (in addition to the existing onBlur commit).
Preserves the 500ms setProp debounce Footer already had for undo grouping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend detectVideoType to handle youtube.com/shorts/ID, youtube.com/live/ID,
youtube.com/watch?...&v=ID (v not the first query param), and Vimeo
private-hash URLs (vimeo.com/ID/HASH -> ?h=HASH player param). Existing
shapes (youtu.be/ID, youtube.com/embed/ID, youtube.com/watch?v=ID,
vimeo.com/ID, direct files) keep working. The emitted embed src still
passes through safeUrl unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
toHtml emitted only the wrapper's flex/gap style and passed childrenHtml
through untouched, so a non-default split percentage never showed up in
the export -- each column's width silently reverted to whatever was baked
into its own inline style at node-creation time. toHtml has no direct
handle on individual children to rewrite their styles (it only receives
the already-concatenated childrenHtml string), so emit a scoped <style>
block with nth-child rules (!important, to win over any stale per-child
inline flex) computed via the same getWidths(split, columns) mapping the
editor render uses. Precedent: Menu/Navbar toHtml already emit scoped
<style> blocks for hover CSS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The editor rendered layout="single" as an interactive carousel (prev/next
buttons, dot indicators, currentIndex state) but toHtml exported a static
stacked list of ALL testimonials -- a structural mismatch. This codebase's
static export has no published-JS interactivity for this component, so
static-parity is the lower-risk fix: the editor's single view now renders
just the first testimonial (no carousel chrome), and toHtml exports
exactly that one card. Grid layout is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FeatureItem.mediaType was never read anywhere -- the icon-vs-image choice
is (correctly) driven by feat.image truthiness, set via the AssetPicker in
FeaturesEditor. Neither the default items nor FeaturesEditor's "add item"
object ever set it. Remove the dead field; image-precedence render/export
behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
successMessage was collected in props (and FormStylePanel already had a
live "Success Message" input for it) but toHtml never emitted it anywhere.
The form-sender relay delivers success via a full-page 303 redirect to
thankYouUrl or a hosted thanks.php page -- there is no in-page JS today
that could reveal an inline success element. Emit it as
data-whp-success-message (escaped) on the <form>: forward-compatible for a
future AJAX submission mode, without implying a live mechanism that
doesn't exist yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Container.craft.props collected cssId/cssClass since before Phase E1 but
nothing rendered or exported them. Add live "CSS ID" / "CSS Class" text
inputs to ContainerStylePanel (guarded on nodeProps.cssId/cssClass
!== undefined) and emit id=/class= in both the editor render and toHtml.
cssId takes precedence over the existing anchorId prop when both are set
(only one id attribute can be emitted); anchorId is used as a fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PageData.headCode was never populated from anything but '' and never
read anywhere -- the real, live concept is SiteDesign.headCode (set
via the Code modal, wired into Preview/export in D8). Remove the dead
field from the PageData interface and its ~7 hardcoded headCode: ''
seeds/discards in PageContext.tsx (DEFAULT_PAGE, DEFAULT_HEADER,
DEFAULT_FOOTER, the context default value, addPage, setPagesCraftState,
replaceAllPages). Update the PageData literals in
useWhpApi.save.test.ts to match the trimmed interface.
Verified via grep that nothing reads page.headCode, and `npm run
build` (strict tsc) is clean -- no live reader broke.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two bugs in TopBar's Preview handler:
1. exportToHtml() was called without a headCode option, so Preview
silently ignored SiteDesign.headCode (custom <head> code the user
set via the Code modal). Pull `design.headCode` from useSiteDesign()
and pass it through, matching exportToHtml's ExportOptions.headCode
signature.
2. html.replace(bodyMatch[1], composedBody) used the string-replacer
overload of String.prototype.replace, which treats `$&`, `$1`,
`$$`, etc. in the replacement string as special patterns -- any
user content containing a literal `$` sequence would corrupt the
preview HTML. Switch to a function replacer
(`.replace(bodyMatch[1], () => composedBody)`) so the composed body
is inserted literally.
Verified by reading + `npm run build`; no existing TopBar test harness
to extend (dynamic import + window.open/Blob in the click handler).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
switchPage and deletePage ran side effects (loadState -> actions.deserialize,
setActivePageId, activePageIdRef mutation) INSIDE setPages/setHeaderPage/
setFooterPage updater callbacks -- using the functional-updater form purely
to peek at the latest `prev` value. React (in StrictMode dev builds)
double-invokes updater functions passed to setState to catch exactly this
kind of impurity, so every page switch deserialized the target's craft
state twice.
Add pagesRef/headerPageRef/footerPageRef mirroring the latest state on
every render (same pattern already used for activePageIdRef), so
switchPage/deletePage can read "what's current" synchronously in the
event-handler body and run their side effects there -- after the state
update is computed, not inside the updater. deletePage now passes a plain
next-value to setPages instead of a function updater. Switching/deleting
behavior (correct page loads, can't delete the last page) is unchanged.
Verified the added test fails against the pre-fix code (deserialize called
2x) and passes against the fix (1x).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The template-load sequence (clear canvas -> add components -> add
pages -> apply header/footer) ran as a chain of setTimeout callbacks,
but onClose() fired synchronously right after kicking it off. The
modal disappeared immediately, so "Loading..." never had a chance to
show, and the staged mutations kept running against a dialog the
caller had already dismissed.
Convert the staged steps to async/await (a promise-based `wait()`
replaces the setTimeout chaining) and only call onClose() in a
finally block once the whole sequence has settled. Add a mountedRef
guard so the trailing setLoading(false) is skipped if the component
happens to unmount mid-sequence. End result (pages/header/footer/
design applied) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two pages that slugify to the same string (e.g. both named "About")
previously both published to about.html, silently overwriting each
other on publish. Add a pure uniqueSlug(base, existingSlugs) helper
that appends -2, -3, ... on collision, and apply it in addPage,
renamePage, and replaceAllPages. The landing page's slug stays locked
to 'index' regardless of collisions, matching existing behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HeaderZone and FooterZone are absent from componentResolver (so they can
never be deserialized/rendered) and are not imported by PageContext,
Canvas, or any other live code -- verified via repo-wide grep returning
only their own self-references. The site-wide header/footer feature uses
Container with tag="header"/"footer", not these zone wrappers. Deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After removing the dead component settings UI, these ui/ controls have
zero importers: AdvancedTab, TypographyControl, BorderControl,
SpacingInput, AnchorIdField, SettingsTabs. Verified no live *StylePanel
references them (the style panels import only AssetPicker, which is
kept). Deleted the six orphans.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ad-hoc upload/browse/URL blocks in ImageStylePanel, HeroStylePanel
(bgImage + bgVideo), BackgroundSectionStylePanel, NavStylePanel (standalone
Logo imageSrc + Navbar logoImage), MediaStylePanel (Gallery/ContentSlider
array items), and FeaturesEditor (per-feature image) with the shared
AssetPicker component, dropping each panel's duplicated local
showBrowser/browserAssets/handleUpload state and inline asset-browser JSX.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduces src/ui/AssetPicker.tsx, a single component that replaces the
~6 copy-pasted image-source UIs (upload/browse/URL) with one that also
supports video via `mediaType`. Full variant mirrors ImageStylePanel's
existing look; compact variant is a tight single row for array-editor
item cards. Both share all state/handler logic, built on the C1
uploadAsset/listAssets util.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract uploadAsset/listAssets into utils/assets.ts, lifting the exact
uploadToWhp body and list_assets fetch pattern already duplicated across
shared.tsx/ImageBlock/Logo/Navbar/etc. shared.tsx's uploadToWhp is now a
thin re-export (`export const uploadToWhp = uploadAsset`) so its ~6
existing callers are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an AI-boundary validation/sanitization pass in apply-ai-response.ts so
malformed or malicious Sitesmith output can never crash addNodeTree or
corrupt live craft state:
- resolvedName allowlist: any node whose type.resolvedName is not a
registered component in componentResolver is dropped (with its subtree)
and a console.warn is logged; buildNodeTree throws before touching
Craft.js if the tree ROOT itself is invalid, which existing call sites
already catch and warn on (fail soft, never throws into addNodeTree).
- update_props protected-key list (node_id) — an AI-supplied node_id can no
longer overwrite the target node's real id; style is merged shallowly
instead of replaced wholesale so unrelated existing style keys survive.
- id policy: regenerate, never skip. Any AI-supplied node id that is
'ROOT', empty/non-string, or collides with an existing/already-used id is
replaced with a fresh ai-auto-N id; the node itself is kept.
Extends apply-ai-response.test.ts with coverage for all of the above plus
regression tests proving fully-valid AI responses still apply exactly as
before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Editing the Header/Footer sets activePageId to '__header__'/'__footer__',
which matches no entry in `pages`. save() was serializing the live canvas
into the top-level page slots (mislabeled as page content, matching no
page) while exporting header/footer from stale stored state — auto-save
every 30s silently dropped header/footer edits.
Extract buildSavePayload() as a pure, unit-tested helper: header/footer
craft state now comes from the live canvas when that zone is being
edited (else stored state), and the top-level page fields fall back to
the landing page's stored state when a header/footer zone is active,
so page content is never clobbered or mislabeled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
regenerateTreeIds shallow-copied each node's data, leaving data.props (and
data.custom) as the same object reference between the original node and its
duplicate/pasted copy. Craft.js's setProp mutates data.props in place, so
editing the duplicate's props silently mutated the original too. Deep-clone
data via structuredClone before applying id remaps so no mutable sub-object
is shared between original and regenerated nodes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Craft.js duplicate (ContextMenu + keyboard shortcut) and paste were reusing
the original node's toNodeTree() output verbatim, so addNodeTree() inserted
duplicate node ids into the editor tree. Added regenerateTreeIds() which
deep-clones a NodeTree and remaps rootNodeId, node map keys, node.id,
internal node.data.parent, node.data.nodes, and node.data.linkedNodes via
Craft.js's own getRandomId(). Also fixed pasteNode to insert as a sibling
of the right-clicked node (using its parent) instead of using a leaf node
as the new parent, which previously threw.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sanitizeCssValue's blanket `;` strip ran on the whole value AFTER url(...)
content was already safely re-wrapped, corrupting legitimate
data:image/png;base64,... URLs pasted into Background Image fields
(the MIME/base64 separator `;` was deleted, breaking the data URI in
exported/published HTML). Scope the `;`/`"` breakout sanitization to the
segments outside url(...) matches only -- the url() branch is already
fully safe via escapeAttr(safeUrl(...)) and must not be re-stripped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cssPropsToString() joined raw CSSProperties values into a style="..."
attribute with zero escaping, so any component spreading user-controlled
values into inline styles (background-image url(), etc.) could break
out of the attribute or inject a second declaration -- this is what
made BackgroundSection/HeroSimple/CallToAction/Section's bg-image
url() sites (flagged in the A3 brief) safe without needing a per-call-
site fix, since they already route through this helper.
Each string value is now sanitized: url(...) contents are validated
through safeUrl and re-wrapped escaped, stray `;` (the only way to
inject a second live declaration) is stripped, and any raw `"` is
entity-encoded so it can't terminate the attribute early. Legitimate
multi-part values (box-shadow, gradients) that contain none of these
characters pass through byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:
- HtmlBlock.toHtml exported props.code raw; now runs it through the
same purifyHtml (DOMPurify) config already used for the live editor
preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
`new Date("${targetDate}")` inside an inline <script> -- a value
like `2026-01-01");alert(1)//` broke out of the string literal. Now
validated against a strict date/datetime shape and JSON.stringify'd
before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
`onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
img.src could break out of. Replaced with a `data-lb-src` attribute
per thumbnail and one delegated click listener on the grid
(`e.target.closest('[data-lb-src]')`) instead of a per-item inline
handler string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds craft/src/utils/escape.ts as the single exported escaping/URL-safety
util (escapeHtml, escapeAttr, safeUrl) for later hardening tasks to
consolidate the 27 divergent local copies into. html-export.ts now
imports escapeHtml from it instead of keeping a private copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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).
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.
Fix non-relay form output to match pre-change byte-for-byte by conditionally omitting the honeypot and fields lines when empty. Add backward-compat regex assertion to catch extra blank lines.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>