Canvas renders plain text via textToInlineHtml (newline -> <br>); toHtml used escapeHtml,
so line breaks typed in the editor vanished on publish (TTM about page: ~100px shorter).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
HeroSimple emitted 'background' together with backgroundImage/Size/Position
that were undefined. React assigns '' for those on first render and Chromium
and Firefox then wipe the shorthand's gradient (background-image: none), so
the canvas hero rendered white. Build the style with only the relevant keys
(shared by render and toHtml); exported HTML is byte-identical.
Adds a real-browser (Chromium+Firefox) regression test: npm run test:browser:bg,
and toHtml byte-identity assertions.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
relayFormWiring always emits captcha="1" for relay-wired forms (legacy captcha prop ignored,
old projects with captcha:false load fine). CaptchaMock always shown for relay-wired forms;
FormStylePanel toggle replaced by a read-only note.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgKV8v9BUrQWXSUW4P
client_id per editor load (memory only), already_saved, save warnings via notices,
upload_max_bytes enforced client-side with the server error text on 413,
project_max_bytes warning, headCode in the Preview font scan, pinned backend heads.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Published mobile stacking is the global RESPONSIVE_CSS rule matching the
DESKTOP inline style, so un-stacking Columns on mobile under a stacked tablet
(styleMobile.flexDirection 'row') publishes un-stacked. The editor's
device-emulation copy matched the resolved inline style and showed it stacked.
RenderNode now marks nodes with a flexDirection/display override with
data-wsb-base-stack (baseStacksOnMobile) and editor.css keys off it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syncInstances rebuilds an instance's subtree from the master, so a node
dropped, pasted, duplicated or moved into an instance vanished from every page
on the next master edit. Chosen approach: BLOCK (not preserve-on-sync):
- every resolver component's Craft canMoveIn/canMoveOut refuses a canvas inside
an instance (canvas DnD, Layers DnD, actions.move); synthetic shell-inner
nodes now carry the component's rules instead of hardcoded allow-all
- insertBlock (BlocksPanel / EmptyContainerAdd / palette), paste (menu +
shortcut), duplicate (menu + shortcut) and Sitesmith patch/section replace
redirect to after the instance or refuse; no empty-container '+' inside one
The instance root still moves/duplicates as a whole; content overrides are
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Legacy Navbar nodes store backgroundColor '#ffffff' + textColor '#3f3f46'; the
editor took the logo colour from the Site Design text (light on a dark design)
while the export already uses logoColor || textCol. Same dark-palette class as
finding 8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tablet/mobile Alignment and Vertical-alignment overrides published only the
raw text-align/justify-content declarations, while the editor (which renders
from the device-resolved style) also applied the derived flex layout
(display:flex;flex-direction:column;align-items). Desktop-centred containers
stayed centred on mobile, mobile-only centring/vertical-align did nothing, and
in boxed mode the flex sits on the inner div which had no scope at all.
- Container/Section: single containerLayout()/sectionLayout() helper used by
render, toHtml and a new static responsiveLayout hook.
- responsiveCssForNode takes an optional DerivedLayoutFn: per device it diffs
the layout derived from the resolved style against what already applies
(desktop for tablet, tablet for mobile), emits changes (and resets for
dropped keys), and scopes boxed-mode inner rules as [data-wsb-r=x]>div.
- html-export passes the component's responsiveLayout hook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes saved before the component-tokens work (9ab0102) store light surface
hexes -- ContactForm inputBg '#ffffff', SubscribeForm style.backgroundColor
'#f8fafc', Testimonials cardBg '#f8fafc' -- while the text on those surfaces
became var(--wsb-text)/var(--wsb-muted). On a dark Site Design palette (light
text token, e.g. Agency #fafafa) that rendered white-on-white.
Text on a STORED light surface (hex #rgb/#rrggbb or rgb()) now keeps the
pre-token dark hex (input #1f2937, subscribe heading #1f2937, testimonial
quote #374151 / name #18181b / title #64748b); absent, token or dark surfaces
keep the token text. Stored props are never rewritten. Applied in both the
editor render and toHtml export.
Generalises Navbar's isLightNavBg into utils/color.ts (isLightColor /
textOnSurface, plus parseColor moved out of publish-checks and re-exported);
Navbar and autoTextColor now share it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Component render/toHtml now use var(--wsb-*) instead of hex so Site Design
palette changes reach FeaturesGrid, Testimonials, Accordion, PricingTable,
CTA/CallToAction, forms, Gallery, Tabs and others. Exemption lists removed.
Refs #44
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Make a top-level section global (context menu), place instances from the
Section library's new Global tab, edit the master once in its own zone
("Editing global section" banner, Done - update N instances) and every
instance on every page/header/footer is rebuilt from it. Instances are real
inlined subtrees (custom.global on the root, custom.gkey on every node), so
export and published output are unchanged static HTML.
- utils/global-sections.ts: pure model (createMaster, instantiate,
syncInstances, collectOverridesFromInstance, applyOverrides, detach,
findInstanceRoot, master zone round trip). Unchanged input returns the
identical string; sync keeps the instance root id and the instance's own
inner ids per gkey.
- PageContext: __global__:<id> zone (editGlobal / finishGlobal); leaving it
commits the master (rev + 1 only when it changed), syncs every stored
slot and applies the sync to the returned-to canvas as ONE undo step.
- GlobalSectionsContext: makeGlobal / insertInstance / detach / setOverride
/ resetOverride / remove (refused while used), one undo step each.
- Inline text edits inside an instance record an override (text + html)
via useGlobalOverrideCommit; GuidedStyles shows a Global section panel
(Edit master, Detach, content override fields) instead of style panels.
- RenderNode: Global badge, dashed outline, lock icon on inner nodes.
- buildSavePayload: a master zone on the canvas is never saved into a page.
Refs #57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
Search + type chips in the Assets panel and picker library; Stock photos
entry in the picker (feature-detected, hidden when stock_disabled/absent);
bgPosition focal point for Hero and Background Section.
Refs #55
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>
Frame gets data-device; RenderNode mirrors hide flags and selection onto the
dom; editor.css emulates the exported breakpoints inside the narrowed frame.
Also: data-wsb-overridden counts only set values, responsive scope ids are
zone-prefixed (h-/b-/f-) so ROOT no longer collides, and comment delimiters
are stripped from responsive declarations.
Refs #37
I-1: WebhookSecretField now mounts with key={selectedId}. GuidedStyles renders
FormStylePanel with no key, so a selection change re-rendered rather than
remounted it and React kept `draft`/`status`. Since a failed store deliberately
retains the draft, clicking a second contact form showed node A's raw secret in
node B's field, and the next blur assigned the returned secret_id to the wrong
form (burning a slot against the per-site cap). The status banner leaked the
same way. Both pinned by the reviewer's repro sequence.
I-2: the button is "Clear", not "Remove", and says so -- nothing deletes a
stored key file, so the 50-per-site cap counts stores-ever. Labelling it Remove
told the customer they had reclaimed a slot right up until the 429 that said
otherwise. Actually deleting the file is Task 8's territory.
M-1: inline warning when the webhook URL is blank or not absolute https. The
publish step does refuse these, but into an error_log the customer never reads.
Warning only -- isHttpsWebhookUrl never edits the value or blocks the publish,
since either would trade a loud server-side refusal for a silently inert form.
M-2: the BYTE-IDENTITY test now compares against a literal marker captured by
executing the emitter at 071f3447, not against another head-revision output. The
self-comparison could only catch a drift affecting one side; a uniform one
passed it. Verified: a uniform `<!-- WHP-FORM` drift now fails this assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ContactForm gains four craft props (destinationType, webhookUrl,
webhookSecretId, webhookAuthMode), all present in craft.props defaults so
FormStylePanel's `nodeProps.X !== undefined` gates actually render their
controls. The controls live in FormStylePanel (RightPanel renders only
GuidedStyles, so related.settings would be dead UI).
relayFormWiring widens the marker to optionally carry type/url/secret/
authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's
parser looks. A marker with no type is byte-identical to what shipped before
-- pinned by a test that diffs an explicit-email form against one with no
destination props at all, since every already-published site depends on that
shape continuing to provision an email endpoint.
Every optional attribute value goes through one escaping site (markerAttr ->
escapeAttr); type and authmode are additionally allowlisted, so a case-drifted
"Bearer" reaches the relay as the exact literal it compares against instead of
being silently downgraded to unsigned.
The raw shared secret is never a prop: it is held in WebhookSecretField's
local state, POSTed to /api/form-webhook-secret.php on blur, and only the
returned opaque id is persisted. The field is write-only (set / replace /
remove, never view) because the endpoint has no read route, and the endpoint's
429 cap message is surfaced verbatim so a customer can act on it.
Co-Authored-By: Claude Opus 5 (1M context) <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>