event-conference, resume-cv, blog and wedding now reference var(--wsb-*)
tokens instead of repeating their design palette/fonts in component props.
Refs #44
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
Wires the Task 18 payload builder and Task 16/17 diagnostics into a
user-facing modal, reachable from the topbar bug icon (desktop) and
overflow menu (mobile). Two gaps in the task brief's draft, not called
out there, are handled explicitly: buildReportPayload() can throw when
the payload is still oversized after canvas_state is dropped, so
submission is wrapped in try/catch with an actionable "too large" error
that preserves the user's typed text; and the textarea maxLength is
sourced from MAX_DESCRIPTION_CHARS (with a proximity character count)
instead of a hardcoded number, so the UI limit can't drift from the
payload limit.
Also portals the modal to document.body for the same stacking-context
reasons TemplateModal/HeadCodeModal already do, and guards the
useEditor() selection collector with optional chaining so it degrades
gracefully under TopBar's existing test harness (a minimal @craftjs/core
stub with no events/nodes on its collector state).
The textarea uses onInput rather than onChange: React's onChange dedup
(via its DOM value-tracker) treats a test harness's raw `el.value = x`
assignment as a no-op change, matching this repo's existing pattern
(HeadCodeModal.test.tsx, shared-controls.test.tsx, etc. all work around
the same gotcha) -- onInput is a plain passthrough with no such check,
and is behaviorally identical for real typing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found the 512KB cap on buildReportPayload only ever measured on the
includeCanvas+canvasState branch -- opt-out and null-canvas paths returned
early without checking size at all, and an oversized non-canvas field
(description straight from a user's textarea) could slip through with a
canvas_state_omitted: 'size' marker that falsely claimed the drop had fixed
things.
- Truncate description to 5000 chars (matches the server-side validator's
future limit), silently: unlike canvas_state, a truncated free-text
description is exactly what it looks like, not a misleadingly-plausible
partial structure.
- Route every return path through finalize(), which measures the actual
candidate payload and throws rather than returning an oversized body --
so 'size' can never be attached to a payload that's still over cap.
- Keep the opt-out early return structurally separate so canvas_state is
never populated from input on that path, regardless of the cap check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found two real issues in the initial build-stamp commit:
- A permanent, unconditional console.log on every editor load for every
customer is production noise. Replaced with a window.__WHP_EDITOR_BUILD__
assignment -- same load-bearing effect (keeps build-stamp.ts from being
tree-shaken out before Task 19 wires in the real call site), but prints
nothing. Support can ask a user to type __WHP_EDITOR_BUILD__ in the
console on request. Commented as load-bearing so it isn't later "cleaned
up" as a stray global.
- editorBuild()'s 'dev' fallback was never actually exercised by any test
in the suite, despite the previous report claiming otherwise. Added
build-stamp.test.ts asserting editorBuild() === 'dev' under vitest.
Also silences the expected-failure stderr git prints on the successful
'nogit' fallback path (stdio: ['ignore', 'pipe', 'ignore']), so a
release-tarball build log doesn't show a misleading fatal: line for an
intentional, handled case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>