PresetButtonGrid rendered every preset set into a fixed 4-column
.preset-grid, so 5-item sets (RADIUS_PRESETS, SPACING_PRESETS,
IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, NavStylePanel's GAP_PRESETS)
wrapped a single lone button onto its own row, and the 6-item
TEXT_SIZES split unevenly (4+2).
PresetButtonGrid now derives a column count from presets.length via
defaultPresetGridColumns() -- 5-item sets get a single row of 5,
6-item sets split into two even rows of 3, and anything else keeps
the classic 4-column grid -- with an optional `columns` prop for
explicit overrides. This fixes every existing call site automatically
rather than threading an explicit count through each one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unicode emoji/glyphs (Sitesmith's sparkle, lock, close X) render as
tofu on systems without an emoji font. Swap for the FA4 glyphs the
rest of the chrome already uses:
- SitesmithButton/SitesmithModal: sparkle -> fa-magic, lock -> fa-lock
- ContextMenu "Ask Sitesmith" entry: sparkle -> fa-magic (via new
optional MenuItem.icon field)
- TemplateModal/SitesmithModal close buttons, PagesPanel delete,
AssetsPanel delete/cancel: ✕ -> fa-times / fa-trash
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BlocksPanel dropped a new Image block with an explicit `src=""` prop, which
overrides ImageBlock's `src = PLACEHOLDER_SRC` default parameter (defaults
only apply when a prop is undefined, not when it's an empty string). Craft
then persisted `src:''`, and the canvas rendered a broken-image icon instead
of the placeholder.
- ImageBlock render now falls back to PLACEHOLDER_SRC whenever src is falsy
(belt-and-braces: also recovers any legacy saved src:'' state).
- BlocksPanel no longer passes src="" when dropping a new Image block, so
the craft default applies.
- ImageStylePanel now restores the placeholder (instead of blanking to '')
when the URL field is cleared.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
New src/ui/Modal.tsx ({ open, onClose, title?, children, width?,
closeOnEscape?, closeOnBackdropClick?, backdropStyle?, backdropProps? })
owns the backdrop, Escape-to-close, backdrop-click-to-close, and a new
body-scroll-lock while open. Adopted by TemplateModal, HeadCodeModal, and
SitesmithModal; each keeps its own panel styling/header/footer as children
since those differ per modal.
- TemplateModal: Escape/backdrop-click still close the confirm-template
sub-dialog first via a wrapped onClose passed to Modal; the header's X
button keeps using the raw onClose prop (always fully closes), matching
prior asymmetric behavior.
- HeadCodeModal: straightforward adoption, no prior custom close logic.
- SitesmithModal: previously had no `open` prop, no Escape-to-close, and no
backdrop-click-to-close. Preserved via open (always mounted-open by its
parent already), closeOnEscape={false}, closeOnBackdropClick={false}; role
and aria-modal are passed through via backdropProps to keep them on the
same backdrop element as before.
Body scroll-lock while a modal is open is a small new addition (requested by
the task) applied uniformly; it has no visible effect since each modal's
opaque fixed-position backdrop already fully covers the viewport.
Added a light Modal.test.tsx (renders children, Escape/backdrop-click close
behavior, scroll-lock) using the existing react-dom/client + act harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both panels had a ~90-line near-byte-identical per-item field editor
(boolean->checkbox, number->number, /color/->color, long->textarea, else
text, fields derived from Object.keys(items[0])). Moved into a new
ArrayItemFieldsEditor component in ArrayItemFields.tsx used by both.
SectionTypePanel keeps its own FeaturesGrid special-case (key === 'features'
-> FeaturesEditor) and its empty-array guard ahead of the shared call.
Behavior-preserving (task E4.2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopted in BackgroundSectionStylePanel, ButtonStylePanel, ContainerStylePanel,
FormStylePanel, GenericPropsEditor, HeroStylePanel, ImageStylePanel,
MediaStylePanel, NavStylePanel, SectionTypePanel, SocialStylePanel,
TextStylePanel — only where the inline setProp/setPropStyle were behaviorally
identical to the hook. PricingStylePanel and FeaturesEditor keep their own
array-mutation logic since it differs. Behavior-preserving (task E4.1).
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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).
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>
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.
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>
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>
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>