Regression coverage for the safety-sensitive part of keyboard copy/paste:
nothing previously tested that Ctrl/Cmd+V regenerates node ids via
regenerateTreeIds before actions.addNodeTree -- the exact logic whose
absence caused a duplicate-id corruption bug. Also covers sibling-parent
targeting (with ROOT fallback), the empty-clipboard no-op, and the
existing input/contentEditable focus guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The context menu advertised Ctrl+C/Ctrl+V hints but useKeyboardShortcuts
only handled undo/redo/delete/duplicate/escape -- pressing them did
nothing. Add a tiny shared module-level clipboard (src/hooks/clipboard.ts)
used by both the keyboard hook and the context menu so copying via one
entry point and pasting via the other stay consistent.
Ctrl/Cmd+C stores the selected node id (skipping ROOT). Ctrl/Cmd+V
inserts a copy as a sibling of the current selection via
regenerateTreeIds, mirroring the existing context-menu paste behavior.
Both respect the existing "disabled while typing" guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
192.168.1.105 was decommissioned; local npm run dev save/load now
proxies /api to the current test server (whp-al10-test, .148).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the computed-but-unused resolvedName/resolverMap, anchor the hero
regex (/^hero/i) so it stops matching any type name containing "hero",
and remove the isContainer alternations for the now-deleted
HeaderZone/FooterZone components.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enter/Space on a clickableProps row unconditionally called
e.preventDefault() + onActivate(), even when the keydown bubbled up from a
nested interactive child (e.g. the Delete/Rename icon buttons in
AssetsPanel/PagesPanel). preventDefault() anywhere in the propagation path
cancels the browser's native click synthesis for the focused child button,
so its onClick never fired and the row's onActivate hijacked the action
instead. Guard on e.target !== e.currentTarget so only keydowns targeted at
the row itself are handled.
- 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>
Covers Critical (stored-XSS escaping cluster) + High (copy/paste id reuse,
header/footer save corruption, AI-boundary validation) findings from the
2026-07-12 audit. All claims verified against code.
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>
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 first page is now treated as the landing page: it always publishes to
index.html no matter what the user names it, and its slug is forced to
'index' in state so .htaccess clean-URL rewrites stay consistent.
- useWhpApi.ts: force pages[0].filename='index.html' at save time
- PageContext.tsx: heal pages[0].slug to 'index' on load and on rename
- PagesPanel.tsx: "LANDING" badge on first page, slug shown as '/',
rename hides slug input (locked), delete button hidden
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
replaceAllPages was slugifying every page name including the first, so
the home page landed at home.html. Apache resolves '/' to index.html, so
the published root URL appeared blank while the actual content was at
/home.html. First page now hard-codes slug='index'.
Apply path is stable end-to-end with the linkedNode pre-creation patch;
diagnostic shim + window.__sitesmithLastState dump are no longer earning
their footprint. Reverts:
- vite.config.ts: drops the tiny-invariant alias
- src/utils/tiny-invariant-shim.ts: deleted
- PageContext.tsx: removes the post-walk dump/scan block
The Invariant 'component type (undefined) does not exist in the resolver'
was Craft.js's toNodeTree choking on the linkedNode that <Element id="X">
auto-creates at render time inside Section / BackgroundSection /
FormContainer. The auto-created node stores its type as the Container
React component class itself, not as {resolvedName:'Container'}, so the
later type.resolvedName lookup returns undefined.
For each shell, treeToState (and apply-ai-response's buildNodeTree) now
synthesizes the linkedNode container up-front with a proper serialized
type, moves the AI's direct children into it, and reparents them. This
matches the canonical shape Craft.js writes when the user manually builds
a site, so Craft.js never has to materialize the linkedNode itself.
Craft.js uses several invariants as try/catched control-flow checks
(notably isDraggable -> 'A top-level Node cannot be moved' for ROOT and
linkedNode children). These fire on every render and are NOT errors —
they're how Craft.js asks 'should I attach drag to this node?'. Filter
them out of the shim's console.error so only genuinely-broken invariants
show up.
The prod build of tiny-invariant strips all failure messages, leaving
us with bare 'Error: Invariant failed' and no actionable info. Aliasing
the package to a shim that always emits the message + a stack-trace
console.error before throwing — so the next Craft.js invariant we hit
tells us which assertion (ERROR_NOT_IN_RESOLVER, ERROR_NOPARENT,
ERROR_INVALID_NODE_ID, etc.) is actually failing.
Temporary; will revert once the Sitesmith apply flow is stable.
The canonical Craft.js state from real saves shows that layout shells
(Section, BackgroundSection, HeroSimple, FeaturesGrid, ColumnLayout,
CTASection, FormContainer, Navbar, Footer) all serialize with
isCanvas:false. Only Container instances are canvases. The shells use
internal <Element canvas id="..."> linkedNodes for their drop targets.
Our previous CANVAS_TYPES set claimed all those shells were canvases,
which made Craft.js's toNodeTree walker hit an uncaught Invariant —
the shell asserted "I'm a canvas" but its render ignores data.nodes,
so the walker would chase phantom children.
ColumnLayout's render uses <Element id="col-0" is={Container} canvas>
which expects the columns to live in linkedNodes, not data.nodes. The
AI nests its column containers as direct children, so they'd land in
data.nodes — Craft.js's render ignores them (the layout draws fresh
empty Elements), but the orphaned children remain in state with
parent: <columnlayout-id>. Any subsequent toNodeTree walk then trips
on this inconsistency and the uncaught Invariant kills the editor.
Normalizer added in two places — treeToState (for scope=site/page
replaces) and buildNodeTree (for scope=section inserts and patch ops):
when we see a ColumnLayout with direct children, move them into
linkedNodes keyed col-0/col-1/col-2..., clear data.nodes, set the
column nodes' isCanvas to true (they hold content), and sync the
"columns" prop to the actual count.
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.
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.
Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.
Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
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>
Image radii need to be visibly larger than the radius scale that works for
buttons/containers — at typical photo dimensions, 16px reads as nearly
square. Add an image-specific scale at 3x the shared values (S=24px,
M=48px, L=96px) and route ImageStylePanel through it. Other components
(buttons, sections, containers) keep RADIUS_PRESETS unchanged.
Note: this commit also bundles unrelated pre-existing working-tree changes
in the legacy GrapesJS site-builder root (CLAUDE.md, index.html,
css/editor.css, js/assets.js, js/editor.js, js/whp-integration.js) that
were inadvertently picked up by an earlier `git add -u`. The image-radius
change is the only intentional content of this commit; the rest is
in-progress legacy work that happened to be sitting uncommitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The actual radius picker shown to users for images, sections, and
containers comes from ImageStylePanel etc. via the shared
RADIUS_PRESETS — not from each component's own settings panel. Earlier
fix only bumped ImageBlock's local scale, which is a different control.
Bump shared scale: S=8px, M=16px, L=32px, Full=9999px (unchanged).
Existing saved sites are unaffected — only future preset clicks pick
up the new values.
Co-Authored-By: Claude Opus 4.7 (1M context) <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>
Linked Craft.js nodes (column children of ColumnLayout, section-inner of
Section, etc.) are structurally non-deletable — actions.delete throws and
the error was silently swallowed. Empty layouts ended up undeletable from
the canvas because clicks always landed on the linked children that fill
the layout's visible area.
Adds findDeletableTarget(): when target is a linked node and ALL its
linked siblings are also empty (i.e., the layout itself is empty),
redirect deletion to the owning parent. Refuses to redirect when any
sibling has content, to protect against nuking a 3-col layout that has
content in other cols.
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>
Includes new page templates (fitness-gym, nonprofit, online-course,
photography-studio, real-estate, startup-company, travel-blog,
wedding-invitation) with thumbnail SVGs, test specs, documentation
files, and minor updates to index.html, router.php, and playwright config.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PDF files continue to embed in an iframe. Non-PDF files (DOC, DOCX, XLS,
etc.) now show a download card with the file name and download icon instead
of relying on Google Docs Viewer, which often fails with "No preview
available."
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Visual drag-and-drop website builder using GrapesJS with:
- Multi-page editor with live preview
- File-based asset storage via PHP API (no localStorage base64)
- Template library, Docker support, and Playwright test suite
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>