Commit Graph

91 Commits

Author SHA1 Message Date
shadowdao 25e674badd Fix I-1: landing-page edits lost when activePageId goes dangling
buildSavePayload matched the active page by page.id === activePageId,
but activePageId defaults to the hardcoded 'home' and load() never
updated it. Deleting the original Home page (its replacement gets id
page_<ts>), reloading, editing, and saving would then match no page --
the live edit only reached the legacy top-level craft_state, while the
authoritative pages[]/pages_craft_state[0] for index.html fell back to
stale state.

Belt-and-suspenders fix:
- buildSavePayload now falls back to pages[0] as the effective active
  page when a real page is active but activePageId matches nothing.
- useWhpApi's load() now points activePageId at the freshly restored
  first page via a new PageContext setActivePageIdDirect setter
  (bookkeeping only -- no re-serialize/deserialize), so activePageId
  stays valid after every load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:29:32 -07:00
shadowdao e5f30a4a56 Fix C1/C2: XSS via unsanitized style keys and animation attrs
C1: cssPropsToString emitted the camelToKebab'd style-object KEY
unsanitized while only sanitizing the VALUE. A malicious style key
containing a quote (reachable via AI update_props or deserialized
saved state) could close the style="..." attribute and inject a live
element. Now validates each key against a CSS property/custom-prop
allowlist and drops anything that doesn't match.

C2: buildDataAttrs (html-export.ts) interpolated props.animation and
props.animationDelay directly into data-animation="..."/
data-animation-delay="..." with no escaping, for every exported node.
Now routes both through escapeAttr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:27:22 -07:00
shadowdao 36c3b2f503 fix(builder): sanitize CSS-value sinks to prevent style/<style> breakout XSS
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>
2026-07-12 15:56:25 -07:00
shadowdao 5acf172511 docs(builder): update CLAUDE.md for hardening branch (remove dead settings/zones, asset picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:16 -07:00
shadowdao ebb8fe1027 refactor(builder): type the AI-response/patch boundary
applyPatch's `ops` param was typed `any[]`, discarding the SitesmithPatchOp
discriminated union that SitesmithResponse.ops already carried at the call
site. Type it ops: SitesmithPatchOp[] so op.props/op.tree/op.op narrow
correctly per-variant in the switch instead of being `any`, and update the
unit tests' inline op literals to SitesmithPatchOp[] so tsc checks them too.

SerializedTreeNode/SitesmithPatchOp themselves, and buildNodeTree/
sanitizeAiTree's tree params, were already typed from a prior task -- this
closes the one remaining any at the op-handling site. Runtime validation
(protected-key guard, style-merge guard, unknown-resolvedName soft-skip) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:30:56 -07:00
shadowdao b88b242b6a refactor(builder): extract shared Modal component for the three editor modals
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>
2026-07-12 15:22:05 -07:00
shadowdao 8f51b5144a refactor(builder): extract shared ArrayItemFieldsEditor from SectionTypePanel/GenericPropsEditor
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>
2026-07-12 15:15:34 -07:00
shadowdao 4733fe84b5 refactor(builder): extract useNodeProp hook, dedup StylePanel setProp boilerplate
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>
2026-07-12 15:13:35 -07:00
shadowdao 3cd4ad0154 test(builder): cover Ctrl+C/V shortcut id-regeneration and guards
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>
2026-07-12 15:08:46 -07:00
shadowdao 6421306849 feat(builder): implement Ctrl+C/V copy-paste shortcuts
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>
2026-07-12 15:01:30 -07:00
shadowdao e40d55b2f2 chore(builder): point dev proxy at current test server
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>
2026-07-12 14:59:42 -07:00
shadowdao 1958b8e0b5 chore(builder): remove GuidedStyles classification cruft
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>
2026-07-12 14:59:39 -07:00
shadowdao 99cc4c79f2 fix(builder): clickableProps must ignore keydown bubbled from nested controls
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.
2026-07-12 14:58:13 -07:00
shadowdao 4b8dd8baee ux: in-app confirm for asset/sitesmith delete + safe copy
- 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>
2026-07-12 14:51:00 -07:00
shadowdao 46ebd253f3 a11y: keyboard-operable editor chrome + topbar aria-labels
Clickable <div> rows/tiles (page list, layer tree, template cards, asset
picker grid) now expose role="button", a tab stop, and Enter/Space
activation via a shared clickableProps() helper, matching their existing
onClick behavior. TopBar icon-only controls (device switcher, undo/redo,
save, publish, templates, code, preview, back) gain aria-label alongside
their existing title tooltips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:50:50 -07:00
shadowdao a036843728 fix(builder): deterministic ids for NumberCounter + form-relay marker
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).
2026-07-12 14:43:31 -07:00
shadowdao 177eda93d0 script nits: slider hover/visibility pause + silent autoplay + iframe amp guard
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>
2026-07-12 14:35:27 -07:00
shadowdao 8029126ab7 deterministic + unique ids in 6 components (node-derived scope)
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>
2026-07-12 14:35:12 -07:00
shadowdao c2aac870e7 thread node id through toHtml for deterministic+unique export ids
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>
2026-07-12 14:34:53 -07:00
shadowdao 9969adca72 a11y: exported semantics (forms/rating/nav/iframe/icons)
- 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>
2026-07-12 14:19:01 -07:00
shadowdao 9b532e36e8 a11y: exported widget ARIA (slider/tabs/gallery)
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>
2026-07-12 14:18:49 -07:00
shadowdao c83db99ae4 fix(builder): update_props no longer wipes style on style: null
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>
2026-07-12 14:05:59 -07:00
shadowdao 605a6ba9f3 refactor(builder): consolidate buildNodeTree onto shared flattener; delete dead serializeTreeForCraft
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>
2026-07-12 14:05:37 -07:00
shadowdao f3c175436a fix(builder): guard PageContext.treeToState against unknown resolvedNames
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>
2026-07-12 14:04:57 -07:00
shadowdao b2b9278d3c refactor(builder): extract shared tree-flattener into craft-tree.ts
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>
2026-07-12 14:04:43 -07:00
shadowdao 71e675489c fix(builder): Footer commits in-progress edits on deselect, not just blur (D7)
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>
2026-07-12 13:46:52 -07:00
shadowdao fdd088b4bf fix(builder): VideoBlock parses more YouTube/Vimeo URL shapes (D4)
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>
2026-07-12 13:46:48 -07:00
shadowdao cdcc3969bc fix(builder): ColumnLayout.toHtml derives column widths from split (D2b)
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>
2026-07-12 13:46:44 -07:00
shadowdao 2887cc9bdf fix(builder): Testimonials single-layout static-parity export (D2a)
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>
2026-07-12 13:46:38 -07:00
shadowdao a0cdbd1d82 refactor(builder): remove dead FeaturesGrid mediaType field (D1c)
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>
2026-07-12 13:46:33 -07:00
shadowdao f0728a9070 fix(builder): emit ContactForm successMessage as a data attribute (D1b)
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>
2026-07-12 13:46:29 -07:00
shadowdao 36ce256760 fix(builder): emit Container cssId/cssClass with live panel controls (D1a)
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>
2026-07-12 13:46:23 -07:00
shadowdao cf56f2a388 refactor(builder): remove dead per-page headCode field (D9)
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>
2026-07-12 13:30:17 -07:00
shadowdao e1d381dc8a fix(builder): Preview includes site headCode and is \$-safe (D8)
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>
2026-07-12 13:29:02 -07:00
shadowdao d4ee09e54c fix(builder): make switchPage/deletePage state updaters pure (D6)
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>
2026-07-12 13:28:27 -07:00
shadowdao 7973ee9ba8 fix(builder): await TemplateModal's load sequence before closing (D5)
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>
2026-07-12 13:26:46 -07:00
shadowdao 2ee66ebae9 fix(builder): dedupe page slugs on add/rename/replaceAll (D3)
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>
2026-07-12 13:25:41 -07:00
shadowdao a7c00119e6 refactor(builder): remove dead HeaderZone/FooterZone components
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>
2026-07-12 13:17:03 -07:00
shadowdao 232dc3c980 refactor(builder): remove orphaned ui/ settings controls
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>
2026-07-12 13:16:57 -07:00
shadowdao 65a10a1ef9 refactor(builder): remove dead component settings UI
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>
2026-07-12 13:16:53 -07:00
shadowdao 4674a99ec9 feat(builder): unify StylePanel image fields on AssetPicker
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>
2026-07-12 13:00:10 -07:00
shadowdao c50b385661 feat(builder): reusable AssetPicker (full/compact, media-typed)
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>
2026-07-12 12:49:34 -07:00
shadowdao 296c10d019 feat(builder): shared asset upload/list util
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>
2026-07-12 12:43:59 -07:00
shadowdao b46b6915a5 fix(builder): validate AI response before deserializing into craft state
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>
2026-07-12 12:37:25 -07:00
shadowdao 4b36ce0d6a fix(builder): route live header/footer edits correctly on save
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>
2026-07-12 12:28:38 -07:00
shadowdao 1a88baa95d fix(builder): deep-clone node data on id regeneration to avoid shared props
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>
2026-07-12 12:25:22 -07:00
shadowdao 97123c4c58 fix(builder): regenerate node ids on duplicate/paste to prevent state corruption
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>
2026-07-12 12:18:44 -07:00
shadowdao 4c001e1af4 fix(builder): preserve data-URI semicolons in css value sanitizer
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>
2026-07-12 12:15:23 -07:00
shadowdao fb4e9f87be fix(builder): sanitize style-string emission
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>
2026-07-12 12:06:16 -07:00
shadowdao 7179287087 fix(builder): sanitize HtmlBlock/Countdown/Gallery JS contexts
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>
2026-07-12 12:06:07 -07:00