Text/number/hex inputs keep local state and commit on 150ms idle, blur or
Enter through the continuous setters; native colour pickers get an
onContinuous path; ButtonStylePanel colour routes through the shared
setters. LayersPanel collectors return structural signatures/primitives
so hover and unrelated prop edits no longer rebuild the tree.
Refs #40
- utils/breakpoints.ts: single source for 767/768/991/992; head
VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from
768 to the 767 mobile boundary).
- utils/responsive-style.ts: styleTablet/styleMobile partial overrides,
cascade resolution, pure setters, scoped @media CSS per node.
- state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile
forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and
Canvas read useDevice() instead of props.
- editor/RenderNode.tsx: resolves the effective style for the current
device in one place and marks overridden nodes with data-wsb-overridden.
Craft passes an opaque <DefaultRender/> to onRender, so the component
element is rebuilt with the resolved style (cloneElement cannot work).
- utils/html-export.ts: hidden nodes skipped; overrides emitted as one
body-level <style data-wsb-responsive> block + data-wsb-r scope attr;
injectAttrs skips leading <style> elements.
- test-utils/editorHarness.tsx: optional onRender/wrap options.
Refs #45
Refs #37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CollapsibleSection persists open/closed per section title via local-prefs.
Container/Section/Columns drop the shorthand padding preset grid; the
per-side Padding control expands a legacy shorthand and writes all four
longhands in one undo step (new SpacingControl onChangeAll).
Refs #35
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Make var(--wsb-*) a first-class style value in the editor:
- utils/tokens.ts: TokenKey, TOKEN_VARS (moved from html-export.ts, same
order/content as the backend), COLOR_TOKENS palette order, tokenRef,
isTokenRef, tokenKeyOf, resolveTokenValue and buildCanvasTokenCss, which
defines the 16 vars + token base CSS scoped to .canvas-device-frame
(never :root) with every value through cssValue().
- editor/CanvasTokenStyle.tsx: <style data-wsb-canvas-tokens> driven by
useSiteDesign(), mounted in App inside SiteDesignProvider, so design
edits recolour token-backed values on the canvas instantly.
- panels/right/styles/TokenSwatchRow.tsx: palette-first swatch row that
emits token references; .token-swatch styles appended to editor.css.
- html-export.ts imports TOKEN_VARS; export output pinned byte-identical
to the pre-move baseline by hash in html-export.test.ts.
Refs #44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add an exported block registry (keywords, search, insertBlock) consumed by
BlocksPanel, a NoticeProvider/NoticeHost toast stack, a guarded localStorage
prefs helper, a ProjectExtras store persisted as project.json 'extras', and a
CanvasOverlays mount point.
Refs #38
Refs #41
Refs #49
Refs #48
Refs #57
Refs #58
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A manual Save during Publish sent a second save with the same base_rev; the
loser 409'd against its own tab (and could abort the publish).
- useWhpApi: every save runs through one in-flight promise (SaveGuard
saveInFlight). A save requested while one is out waits, then sends only
if still dirty, with the rev the first returned; payload is read from the
editor at send time. Forced (empty/overwrite) saves always send.
- Save buttons are disabled while publishing.
- Pill: a conflict reads 'Unsaved changes' (modal open) from Save and from
Publish alike; a never-saved site (load rev null) reads 'Not saved yet'.
- Autosave interval reads runSave through a ref, so editing can't keep
restarting it and starve autosave.
- Fix stale 'Throws like exportBodyHtml' comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Craft records actions.deserialize as an undoable step and nothing cleared
history, so Undo right after load returned the canvas to the blank pre-load
Frame (autosave would then save it), and Undo after a page switch put the
previous page's content into the current slot.
- clear history after the load-time deserialize and after every page/
header/footer switch (in-place AI replacement of the same slot keeps it)
- continuousSetProp opens a burst only when its first call actually
changed something, so it never merges into an entry it did not create
- the empty-save banner's Undo is disabled when there is nothing to undo
Real-editor tests: load, page/header switch, no-op-first burst, banner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Track dirty from Craft node changes and page/design/header/footer state, cleared
only by a successful save. Persistent Unsaved/Saving/Saved/Published/Save failed
pill, beforeunload prompt while dirty or saving, one persistent error notice,
resp.ok/non-JSON handling for save and publish, and publish refuses to run
after a failed save. Draft badge no longer flips on a no-op autosave.
Refs #30
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ties every collaborator's Claude Code to the same shared-memory project
automatically, instead of each machine inferring a key from the directory
name. Read by project.identify at session start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the webhook destination option to the contact form. Legacy markers stay byte-identical; the raw secret never reaches a Craft prop.
Pairs with the whp-side branch of the same name.
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>
Custom HTML block renders arbitrary markup on the published site.
Scripts and event handlers are stripped, but forms, iframes, and
images survive and can send data elsewhere. Add a plain, always-shown
advisory (no content detection) alongside the existing wrapper-styling
note.
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>
Review findings on the Report an Issue modal:
1. Revert the description textarea from onInput back to onChange. The
onInput swap sidestepped a real React value-tracker dedup gotcha (a
test harness's raw `el.value = x` assignment looks like a no-op change
to a controlled onChange input), but this codebase already has the
correct fix for exactly that gotcha: bypass the tracker's patched
setter via the native prototype descriptor, as HeadCodeModal.test.tsx,
SiteDesignPanel.reset.test.tsx, shared-controls.test.tsx, and both
MediaStylePanel.*.test.tsx already do. Rewrote typeDescription() in
ReportIssueModal.test.tsx to use that idiom instead of bending the
component to fit the test.
2. The include-contents opt-out copy said unchecking it still sends "your
description, the page name and your browser details" but omitted
console errors, which buildReportPayload always includes regardless of
the checkbox and which can incidentally echo page content. Copy now
lists console errors explicitly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>