- utils/nav-links.ts: shared NavLink type (source/pageId/children),
pageHref, buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks
- PageData.nav {show, parentId, label} + updatePageNav; a nav-sync effect in
PageProvider materialises page-following links into the stored header
(or the live header nodes while it is being edited); string/identity
guards keep it a no-op when nothing changed
- Navbar/Menu: followPages prop (default false; the default header seed is
true), one-level dropdowns (hover/focus-within + inline click toggle,
indented in the hamburger layout), hover/CTA colours default to the
primary token
- NavStylePanel: Follow pages toggle (adopts matching manual links, keeps
custom text as the page's menu label), read-only page rows with Hide from
menu, sub-link editor for manual links, LinkPicker records pageId
- Page Settings: Show in menu / Menu label / Nest under; Pages panel badge
- useWhpApi: nav passes through pages_craft_state on save and load
Legacy navs (no followPages) are never rewritten.
Refs #53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Select text inside a selected Heading or Text block and a floating
toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is
prevented so the selection survives; mod+b/i/u handled on the editable).
Storage: new `html` prop holding sanitised inline HTML, with `text` kept
as the plain mirror for AI summaries, search and legacy content.
Unmarked edits store html '' so legacy nodes keep their shape. The
allowlist (b strong i em u a br span; style=color only, hex/rgb/
var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links
unwrapped; target only _blank with forced rel=noopener) runs on commit,
on canvas render and again at export. If `text` was changed without
`html` (Sitesmith update_props), the stale html is ignored.
Each edit commits once (blur, or deselect if no blur came first) as one
setProp = one undo step; the old blur+deselect double commit is gone.
Heading colour default now tokenRef('text'). Sitesmith canvas summary
skips `html`.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
size, low, high, and optimum were missing from PURIFY_CONFIG.ALLOWED_ATTR
even though <select> and <meter> are already in ALLOWED_TAGS, so
<select size="4"> rendered at default height and <meter low/high/optimum>
lost its threshold-based gauge colouring. All four are pure
presentation/semantic attributes with no URL/script/event-handler
surface, so no security implication.
Also regenerates the pinned pre-Task-25 output fixture: its source
(html-block-test-body.html) already exercises size/low/high/optimum, so
the byte-identity test's expected output legitimately changes to include
them; verified the regenerated fixture's only diff from the prior one is
those four attributes now surviving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four issues from adversarial review of the block-scoped <style> feature:
1. (Critical) transformBlock() recursed once per @media/@supports/@container
nesting level with no cap -- ~7000 nested rules blew the call stack, and
nothing between a Custom HTML block's toHtml() and the publish pipeline
catches exceptions, so this took down the whole page's publish and
crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
(pass the body through unscoped beyond it) and wrapped scopeCss() so it
never throws on any input, matching repairOrphanNodes's existing
contract. Caught and fixed a variable-shadowing bug in my own first pass
at this: the new depth parameter was silently shadowed by a pre-existing
`let depth` used for brace-matching in the same block, which would have
defeated the cap with no type error.
2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
style-free input: it also changes how the parser preserves whitespace
after a LEADING html comment, which this repo's own fixture starts with.
Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
verbatim, run standalone against real dompurify+jsdom) that the fixture
gained bytes. Fixed by applying FORCE_BODY only when the input has a
real (non-comment) <style> tag to rescue -- confirmed empirically that
this is a true no-op for every other input. Pinned the old output as a
checked-in regression fixture and added a raw toBe() diff test.
3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
published/exported output into a fresh block nested a second wrapper
and re-prefixed every selector. Added isAlreadyScoped(), which detects
a lone root wrapper whose <style> content is already a no-op under
scopeCss for that wrapper's own class (reusing scopeCss's own
idempotency guarantee) and leaves it untouched.
4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
brute-forceable (CSS-only impact, same trust tier as other accepted
risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
<style> block when its content merely looks tag-like (e.g.
content: "<Read More>").
1155/1155 tests passing (was 1141), tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
<style> was previously in FORBID_TAGS and stripped entirely. It's now
allowed, but its CSS is rewritten by a new hand-rolled scoper
(src/utils/scope-css.ts) so a customer's rules only match inside their own
block's wrapper -- never leak out and restyle the rest of the page. The
wrapper div (class="whp-html-<hash>") is only emitted when a block actually
has surviving <style> content, so blocks that don't use it stay
byte-identical to before this change.
Key findings, both covered by tests:
- DOMPurify's body-only serialization silently drops a <style> tag that
appears before any other content in a block (the HTML5 parser implicitly
places it in <head>, which DOMPurify never looks at). Fixed with
FORCE_BODY: true.
- DOMPurify does not sanitize CSS declaration values at all (expression(),
behavior:, url() to any host all pass through verbatim) -- @import is
stripped explicitly by scopeCss() since it's the one CSS-level
exfiltration/fetch vector in scope here.
Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts
(already used for this exact class of problem) over the block's own `code`
string -- deterministic, no node id, no Math.random/Date.now.
1141/1141 tests passing (was 1077), tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up on the Task 24 sanitiser widening (approved, no bypass
found). Two Important findings to close:
1. ALLOWED_URI_REGEXP's data:image/...;base64, arm sat inside the group
that appends a trailing `:` to every alternative, so it required a
second colon no real data URI has -- the clause could never match.
Confirmed dead before the fix (poster/cite/href all stripped a valid
base64 PNG data URI) and working after (all three now survive), while
javascript:/data:text/html stay blocked. Pulled the arm out into its
own top-level alternative.
2. Corrected an inaccurate comment/report claim that every allowed
attribute value goes through this regex -- `src` on
img/video/audio/source/image/track is additionally covered by
DOMPurify's own DATA_URI_TAGS allow-list, which is mimetype-blind and
bypasses the regex entirely (acceptable: none of those tags execute
src as a document; iframe is correctly excluded from that list).
Adds two regression tests: the regex fix actually working, and the
DATA_URI_TAGS bypass pinned so a future DOMPurify change surfaces as a
failing test rather than a surprise. Fixture byte figures unchanged
(15,815 -> 14,899; fixture has no data:image URIs).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A customer's broad HTML fixture showed 38% of it silently deleted by
the shipped DOMPurify config: colspan/rowspan/scope, <dl>, <sub>/<sup>,
<details>/<summary>, inline <svg>, <video>/<audio>, lang/dir/role, and
<ol start/reversed> were all stripped. The site owner's call: be
generous, this block is an explicit escape hatch, allow forms too.
Widens PURIFY_CONFIG in HtmlBlock.tsx (45->119 tags, 16->108 attrs;
form/input/button/select/textarea removed from FORBID_TAGS) while
keeping the four non-negotiables intact: no <script>, no on*, no
javascript: URLs, iframes stay sandboxed. <style> stays blocked
(separate task adds scoped support later), including inside the newly
allowed inline SVG. SVG support is an explicit tag list mirroring
DOMPurify's own SVG vocabulary rather than USE_PROFILES, which turned
out to silently discard ALLOWED_ATTR entirely and pull in unaudited
tags (dialog, template, marquee, ...) not in scope here.
Fixture survival goes from 61.6% (9,739/15,815 bytes) to 94.2%
(14,899/15,815 bytes). Adds a fixture-driven regression + security
test file (HtmlBlock.security.test.ts) plus a checked-in copy of the
reference fixture, loaded via Vite's ?raw import so tests need no new
dependencies and can't silently drift from the thing being tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the
toolbar colour picker added in this branch was silently deleted by
DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table
tags, with tests pinning the markup path in both render and toHtml.
I3: PagesPanel's three confirmation states were not mutually exclusive;
cancelling delete revealed an unbidden reset prompt on a destructive action.
I5: orphan repair logged at console.warn, which the new console buffer
cannot see -- the reporter would never capture the most diagnostic signal
for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load
failure handling with loadState's fallback.
I7: corrects comments (and the design spec) that asserted an orphan
"renders somewhere on the canvas", which a mid-plan audit disproved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NavStylePanel (Navbar/Menu/Logo/Footer):
- LinkPicker: dropdown of the site's pages (read-only via usePages()) plus
manual URL / #anchor / tel: / mailto: entry, wired into every link-href
field (standalone Logo href, Navbar logoUrl, Navbar/Menu link items).
- "Sync links with Pages" button in the Links section: repopulates the
links array from the current pages list (label = page name, href = '/'
for the landing page else '/{slug}'), preserving any existing CTA link.
Regression-fix vs the legacy GrapesJS builder, which had this.
- `download` checkbox per link (Navbar/Menu links, standalone Logo href)
emits the `download` attribute on export for links to files.
- Links/Colors sections now gate on the component actually carrying a
`links`/color prop, so Footer (no links array) no longer shows a dead
"Add Link" editor.
- Box-model (Margin/Padding via SpacingControl, Border & Effects via
BorderControl + box-shadow presets + opacity), AnimationControl, and
VisibilityControl added for all four owned components, backed by new
animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props
(with blank/default values in each component's .craft.props).
Tests: NavStylePanel.test.tsx (new, 17 tests: LinkPicker modes, sync
preserves CTA, download toggle, box-model/animation/visibility wiring) +
extended Navbar/Menu/Logo/Footer .toHtml.test.ts (download attribute,
craft.props defaults). Full suite: 683/683 passing. `npm run build` green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/
CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and
SocialStylePanel all gain a Spacing & Border section (margin/padding
per-side, border, box-shadow, opacity), an Animation section, and a
Visibility section, wired to the shared SpacingControl/BorderControl/
AnimationControl/VisibilityControl.
PricingTable's per-card colors (cardBg/textColor/subColor/featColor/
checkColor/btnBg/btnColor) were previously hard-coded literals computed
from featuredBg inside toHtml -- promoted to real optional props (each
falling back to the exact prior literal when unset) and exposed via
ColorPickerField in PricingStylePanel.
SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built
props with no control), plus Icon's bgColor/bgShape/bgSize/link and
StarRating's filledColor/emptyColor, which the panel's generic
iconBgColor/starColor checks never matched since those aren't Icon's or
StarRating's real prop names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- FormStylePanel: ContactForm field editor (add/remove/reorder via
ArrayPropEditor + manual move up/down) covering label/name/placeholder/
type (full sanitizeInputType allowlist + textarea/select)/required/
options.
- SubscribeForm.toHtml: was a dead `<form method="POST">` with no action at
all -- wired through the same relayFormWiring contract as ContactForm/
FormContainer so a recipientEmail makes it actually submit (marker +
placeholder action + honeypot), falling back to action="#" otherwise.
- SearchBar: was purely decorative (no action/method/input name) -- now a
real GET form (configurable target, default "/") with input name="q",
safeUrl-guarded against javascript:/vbscript: breakout.
- Box-model (margin/padding per-side, border, shadow, opacity), entrance
animation, and hide-on-device controls added to FormStylePanel and
rolled out (blank/false craft.props defaults) across ContactForm,
FormContainer, InputField, TextareaField, FormButton, SubscribeForm,
SearchBar. No toHtml changes needed for animation/visibility --
html-export.ts's buildDataAttrs() already emits data-animation/
data-hide-* generically from these prop names.
- Extended toHtml tests for all 7 components: field-type rendering
(incl. textarea/select), type-attribute XSS sanitization, relay/GET
functional wiring, box-model style passthrough, craft.props defaults.
npx vitest run: 689/689 passed. npm run build: green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
text-transform (none/uppercase/lowercase/capitalize), italic + underline
toggles (fontStyle/textDecoration), and a custom NumericUnitInput
font-size alongside the existing preset row. All write to component
`style`; no toHtml changes needed (style already flows through
cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
existing `target` prop ('_self'/'_blank' -- toHtml already emitted
rel="noopener noreferrer" for _blank). New Hover State section
(hoverBg/hoverColor via ColorPickerField) renders a scoped
`<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
two buttons on one page don't collide; both values sanitized through
cssValue against <style>-element breakout. Editor canvas gets a live
hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
approach), since there's no way to preview a CSS :hover rule directly
on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
Text only)/border/box-shadow/opacity style defaults + AnimationControl/
VisibilityControl-backed animation/animationDelay/hideOnDesktop/
hideOnTablet/hideOnMobile props, each panel gaining collapsible
Spacing / Border & Effects / Animation & Visibility sections. Button
keeps its existing padding-shorthand preset row rather than adding a
redundant per-side padding control; only margin got the new
per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
emission (line-height/letter-spacing/text-transform/font-style/
text-decoration/custom font-size), button target+rel, scoped hover
style emission + two adversarial style-breakout cases (</style><script>
and rule-injection via `;}selector{`), and craft.props assertions for
every new prop on all three components. 674 tests green, tsc + vite
build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.
Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.
Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
for HeroSimple/BackgroundSection/Section/CallToAction)
Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but
allowed data:image/svg+xml, which can execute inline <script>/onload=
when loaded as a document/navigation target despite its "image" MIME
type (defense in depth -- not currently reachable to execution via
this sink, but closing it). Added `data:image/svg+xml` to the existing
DANGEROUS_SCHEME_PREFIXES check, so it's caught after the same
entity-decode/whitespace-strip/lowercase normalization used for the
other blocked schemes (obfuscated variants included). Other
data:image/* types (png/jpeg/gif/webp, ...) remain allowed unchanged.
M-6: HtmlBlock's purifyHtml() allowed <iframe src> through with no
`sandbox` attribute -- a clickjacking/phishing vector even with
DOMPurify already stripping script/on*=. Added a DOMPurify
afterSanitizeAttributes hook, scoped tightly to each purifyHtml() call
(added right before sanitize(), removed in a finally right after) so
it can't leak onto other DOMPurify uses or accumulate duplicates
across repeated calls, that force-sets a restrictive sandbox
(allow-scripts allow-same-origin allow-popups allow-forms -- no
allow-top-navigation) and referrerpolicy=no-referrer on every iframe
that survives sanitization.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped
.navbar-link/.navbar-cta :hover selectors -- two Navbars on one page
collided on the duplicate id and cross-applied each other's hover
colors (later <style> block wins in the cascade). Scope both on the
Craft node id via scopeId(), matching the Menu/Tabs pattern: the links
container gets a unique id, aria-controls/the hamburger toggle script
reference it, and the hover rules are prefixed with a per-instance
class on the <nav> root.
M-2: Gallery lightbox had no focus management -- opening it left focus
wherever it was (behind the now-visible overlay) and closing it never
restored it. The inline script now stashes document.activeElement on
open, moves focus to a new accessible close button, traps Tab on the
close button while the dialog is open, and restores the saved focus on
close (Escape, backdrop click, or the close button).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.
502 tests green (npx vitest run), tsc + vite build green (npm run build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial re-review found the C1 fix incomplete plus an adjacent
same-class XSS, both reachable via the AI update_props path and
deserialized saved state:
- cssPropsToString only ran sanitizeCssValue on typeof-string values, so a
non-string style value (array/object) with a valid key skipped
sanitization entirely and was template-coerced raw into style="...",
e.g. { color: ['red', '"><img src=x onerror=alert(1)>'] }. Now every
non-number value is coerced with String() and sanitized; numbers stay
raw. sanitizeBreakoutChars also now escapes < and > (previously only ;
and ") as defense-in-depth, since values can reach it from non-string
sources.
- props.tag (Container) and props.level (Heading) were interpolated raw
into the tag position of exported HTML (`<${tag}`, `<${level}`) with no
runtime validation, letting a malicious value break out of the tag
entirely. Both are now allowlisted/clamped against their known-safe sets
(div/section/article/header/footer/main; h1-h6), falling back to
div/h2. Applied in Container's live render + toHtml, Heading's live
render + toHtml, and the typeName==='div' fallback branch in
html-export.ts's renderNode (hit for unresolved/legacy node types).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates Menu, ColumnLayout, Countdown, Gallery, Tabs, InputField, and
TextareaField from Math.random()/content-hash scope ids to the threaded
Craft node id (via scopeId()), keeping every emitted element id,
aria-controls/aria-labelledby/for, and inline <script> function
name/getElementById() call consistently scoped per component.
Resolves the two Important id-collision review findings:
- Tabs: tabId was djb2(anchorId||labels) -- two default Tabs instances
produced identical aria-controls/aria-labelledby ids, so one instance's
arrow-key script clobbered the other's tab/panel wiring.
- InputField/TextareaField: fieldId was `field-${name}` -- two fields
sharing a (often default) name produced duplicate <label for>/<input
id> pairs, breaking the for/id association for one of them.
Also eliminates the remaining Math.random() scope ids in Menu (hover CSS
class scope) and ColumnLayout (nth-child width CSS class scope), so no
export component is non-deterministic anymore.
All fall back to a stable content hash (never Math.random) for legacy
2-arg toHtml() call sites without a node id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- InputField/TextareaField/ContactForm: every control gets a
deterministic id (slugId() in utils/escape.ts, derived from the
field's name/label + index for ContactForm's looped fields -- no
Math.random) with a matching <label for=>; fields with no visible
label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
navigation menu", aria-controls="navbar-links", and aria-expanded
wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
(generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
aria-hidden; SocialLinks' icon-only links get an aria-label naming
the platform alongside the existing title tooltip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:
- HtmlBlock.toHtml exported props.code raw; now runs it through the
same purifyHtml (DOMPurify) config already used for the live editor
preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
`new Date("${targetDate}")` inside an inline <script> -- a value
like `2026-01-01");alert(1)//` broke out of the string literal. Now
validated against a strict date/datetime shape and JSON.stringify'd
before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
`onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
img.src could break out of. Replaced with a `data-lb-src` attribute
per thumbnail and one delegated click listener on the grid
(`e.target.closest('[data-lb-src]')`) instead of a per-item inline
handler string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every user-controlled URL emitted by a component's static toHtml (href,
src, action, and CSS url()) now runs through escapeAttr(safeUrl(...))
before hitting the exported HTML string, closing the XSS gaps flagged
in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/
HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a
few more found via a grep sweep of href=/src=/action=/url( inside
toHtml template strings: MapEmbed's iframe src and the shared
form-relay-wiring fallback form action (a javascript: form action
executes on submit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace divergent, buggy local esc/escapeHtml helpers across 26 files with
imports from src/utils/escape (escapeHtml/escapeAttr). Attribute call sites use
escapeAttr, text-content sites use escapeHtml. Several toHtml outputs now
correctly escape & where old local escapers omitted it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The prior null-safe esc patch only matched 'const esc =' declarations;
Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped
through. Patched those three to coerce non-strings the same way.
Added "Clear chat" button in the modal header that appears when there's
any message history. Confirms with the user before posting to the new
clear_history endpoint, which deletes all messages + the thread row
for the current site (usage rows are preserved for billing).
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.
Patched every local esc() to coerce non-strings:
const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.
Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>