MediaStylePanel's Slides array editor guarded on item.image !== undefined
and wrote `image` via AssetPicker's onChange, but ContentSlider (render
+ toHtml) reads slide.imageSrc. Default slides (imageSrc:'', no `image`
key) never showed an image picker at all, and any `image` value written
was a silent no-op on render/export.
Editor now guards/reads/writes `imageSrc` throughout, and the
"add slide" emptyItem matches defaultSlides' exact shape
(type/imageSrc/heading/text/bgColor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.
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>
- 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>
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>
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>
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>
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>
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>
Three related features:
1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
New shared ctas[] array (text + href + variant + target) replaces the
primary/secondary pair. Settings panel gets add/remove/reorder controls.
Legacy fields stay readable for backwards compat — first user edit
migrates the section onto the new array.
2. Anchor IDs on all layout/section components (Container, Section,
BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
settings panel with an "auto from heading" button that walks the
subtree for the first Heading.text. Renders as id="..." on the
outermost element so #anchor URLs resolve.
3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
and a button at the top of the right-side settings panel both open the
modal pre-targeted at the selected node. The node's serialized subtree
is sent to the server; system prompt is augmented to require a patch
with replace_node. Editor lifts modal state into a new SitesmithContext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior null-safe esc patch only matched 'const esc =' declarations;
Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped
through. Patched those three to coerce non-strings the same way.
Added "Clear chat" button in the modal header that appears when there's
any message history. Confirms with the user before posting to the new
clear_history endpoint, which deletes all messages + the thread row
for the current site (usage rows are preserved for billing).
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.
Patched every local esc() to coerce non-strings:
const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.
Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4px and 8px were imperceptible on typical image sizes. New scale
0/8/16/32/50% gives visible steps for None/S/M/L and keeps Full as
round.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
textAlign only affects inline content, so block children like ImageBlock
(display:block, width:100%) ignored it. Switch to flex-column with
align-items mapped from textAlign whenever alignment is set; layout is
unchanged when alignment is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>