Commit Graph
243 Commits
Author SHA1 Message Date
shadowdaoandClaude Sonnet 5.5 045643d570 feat(site-builder): Section background and dividers default to bg token
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 488f208262 feat(site-builder): section blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 af43af7dc1 feat(site-builder): form blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdaoandClaude Sonnet 5.5 d70f4933af feat(site-builder): basic blocks default to palette tokens
Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:24:52 -07:00
shadowdao 2180f9a776 Merge W0-registry-notices (Refs #38 #41 #49 #48 #57 #58)
# Conflicts:
#	craft/src/state/NoticeContext.tsx
#	craft/src/styles/editor.css
2026-10-07 11:20:30 -07:00
shadowdao ae871c9cdc Merge W0-commands (Refs #39 #49 #34)
# Conflicts:
#	craft/src/styles/editor.css
2026-10-07 11:20:25 -07:00
shadowdao 42700f911f Merge W0-tokens-core (Refs #44) 2026-10-07 11:20:21 -07:00
shadowdaoandClaude Opus 5.5 f8ac1fbe42 feat(site-builder): token module, canvas token variables, palette swatch row
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>
2026-10-07 11:16:29 -07:00
shadowdaoandClaude Sonnet 5.5 73aa35ac96 feat(site-builder): command registry, shortcut matcher, tree walkers and top-bar command menu
Library only; nothing is mounted. Adds minimal DeviceContext/NoticeContext
stubs (owned by W0-breakpoints / W0-registry-notices) that expose the
agreed interfaces.

Refs #39 #49 #34

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:14:52 -07:00
shadowdaoandClaude Sonnet 5.5 21c2776c88 feat(site-builder): block registry, notices, local prefs, project extras, overlay slot
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>
2026-10-07 11:14:51 -07:00
shadowdaoandClaude Opus 5.5 97ccbee461 fix(site-builder): serialise all saves; honest pill for conflicts and new sites
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>
2026-10-07 10:35:11 -07:00
shadowdaoandClaude Opus 5.5 572e736f1f fix(site-builder): Undo can no longer cross the load or a page switch
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>
2026-10-07 10:32:15 -07:00
shadowdao eb134a4743 Merge polish/save-pipeline: load gate, honest save state, idle autosave, conflict handling (Refs #28 #30 #31 #33) 2026-10-07 10:21:51 -07:00
shadowdaoandClaude Sonnet 5.5 34c932fc67 fix(site-builder): disarm Keep mine on empty-banner Undo; autosaveDue honours conflict
Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:20:45 -07:00
shadowdaoandClaude Sonnet 5.5 a6db5438b2 feat(site-builder): detect two-tab save conflicts; Reload latest / Keep mine modal
Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:19:07 -07:00
shadowdaoandClaude Sonnet 5.5 a20c9bae28 fix(site-builder): don't cache swallowed export failures; load-bearing autosave gate test
Refs #33

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:15:24 -07:00
shadowdaoandClaude Sonnet 5.5 c863767283 fix(site-builder): autosave only when dirty; memoise per-state page export
Refs #33

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:12:31 -07:00
shadowdaoandClaude Sonnet 5.5 86b82ed154 fix(site-builder): publish racing an edit stays a draft; no autosave during publish
Refs #30

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:09:26 -07:00
shadowdaoandClaude Sonnet 5.5 9418444916 fix(site-builder): honest save state (dirty tracking, status pill, beforeunload, error notice)
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>
2026-10-07 10:07:03 -07:00
shadowdao 8537cacd01 Merge polish/undo-merging: one undo step per edit (Refs #32) 2026-10-07 10:01:59 -07:00
shadowdaoandClaude Sonnet 5.5 2c85e33b38 fix(site-builder): continuous-edit bursts via history.merge; plain setters are single steps
Refs #32

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 10:00:29 -07:00
shadowdao a406ff8548 fix(site-builder): fail load on unreadable page state; disable editing controls until loaded
Refs #28
2026-10-07 10:00:11 -07:00
shadowdao 66e669fa01 fix(site-builder): never save before a successful load; guard empty overwrites
Refs #28
2026-10-07 09:57:11 -07:00
shadowdaoandClaude Sonnet 5.5 ac490215ce fix(site-builder): one undo step per edit (throttle continuous inputs, single setProp for multi-prop toggles)
Refs #32

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 09:56:12 -07:00
shadowdaoandClaude Opus 5 9ab01021be chore: pin the shared-memory project key
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>
2026-08-23 08:09:04 -07:00
jknapp a684d51996 Merge PR #27: webhook destination controls for contact forms
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.
2026-08-11 00:42:02 +00:00
shadowdaoandClaude Opus 5 422697acec fix(site-builder): review findings on the webhook destination controls
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>
2026-08-10 15:57:38 -07:00
shadowdaoandClaude Opus 5 d1c57db967 feat(site-builder): webhook destination controls on the contact form
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>
2026-08-10 15:45:05 -07:00
shadowdaoandClaude Opus 5 071f3447fd Add static security advisory to HTML block style panel
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>
2026-08-10 08:37:42 -07:00
shadowdaoandClaude Opus 5 98f2ebf118 Fix: HtmlBlock sanitizer strips select/meter presentation attrs
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>
2026-08-10 07:23:36 -07:00
shadowdaoandClaude Opus 5 916a568e9f fix(site-builder): address Task 25 review findings on <style> scoping
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>
2026-08-09 17:36:12 -07:00
shadowdaoandClaude Opus 5 32f4092156 feat(site-builder): add block-scoped <style> support to Custom HTML block
<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>
2026-08-09 17:10:57 -07:00
shadowdaoandClaude Opus 5 6a9b227dda fix(site-builder): repair dead-code data:image clause in HTML block URI regex
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>
2026-08-09 16:50:32 -07:00
shadowdaoandClaude Opus 5 156c5bae35 fix(site-builder): widen Custom HTML block sanitiser allow-list
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>
2026-08-09 16:36:01 -07:00
shadowdaoandClaude Opus 5 69e61ab4b2 fix(site-builder): final whole-branch review fixes
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>
2026-08-09 12:47:23 -07:00
shadowdaoandClaude Opus 5 3dd6b54a35 fix(site-builder): address Task 19 review — onChange convention, opt-out copy
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>
2026-08-09 11:39:22 -07:00
shadowdaoandClaude Opus 5 d7eeff3a68 feat(site-builder): add in-builder Report an Issue modal
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>
2026-08-09 11:29:26 -07:00
shadowdaoandClaude Opus 5 f43a1ef872 fix(site-builder): enforce the payload cap unconditionally, bound description
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>
2026-08-09 11:14:12 -07:00
shadowdaoandClaude Opus 5 fd7f883d6a feat(site-builder): add pure issue-report payload builder
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:08:46 -07:00
shadowdaoandClaude Opus 5 4cfcccd272 fix(site-builder): swap build-stamp console.log for a window global, add test
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>
2026-08-09 11:05:01 -07:00
shadowdaoandClaude Opus 5 024f9fdd46 feat(site-builder): stamp git sha + date into the editor bundle
package.json's version is hand-maintained and never changes between
builds, so a bug report can't identify which bundle produced it.
Vite's `define` injects __EDITOR_BUILD__ (short git SHA + build date)
at compile time; editorBuild() in build-stamp.ts is the only safe way
to read it, falling back to 'dev' since vitest does not apply Vite's
`define` and the identifier is otherwise undeclared. The execSync
call falls back to 'nogit' when building outside a git checkout
(release tarballs), verified by building from a directory with no
git ancestry at all.

Also wires editorBuild() into a startup console.log in main.tsx --
without any reference to it, Vite tree-shakes the unused module out
of the bundle entirely and __EDITOR_BUILD__ never gets substituted,
silently leaving every bug report saying 'dev'. Task 19 will add the
real call site when it assembles the report payload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:59:38 -07:00
shadowdaoandClaude Opus 5 fc8918c1f9 fix(site-builder): stop the console-error marker from re-deriving "original" from a live wrapper
Review found a gap in the idempotency marker added for Task 16: if
external code wraps our patched console.error between two of our own
installs, the marker sees an unmarked function and treats it as
virgin, capturing the external wrapper itself as "the original".
That both double-records (the old patch is still reachable inside the
wrapper's closure) and makes __resetConsoleErrorBuffer() restore to
the wrapper instead of the real original.

Fix: stash the true original exactly once, directly on the `console`
object (not module scope, so it survives HMR too), and always
re-wrap that stashed reference rather than whatever console.error
currently is. Reinstalling after an external wrap now discards that
wrapper instead of guessing whether it still chains to us -- a
deliberate, documented trade-off, since there is no safe way to tell
those two cases apart from the outside.

Also: window error/rejection listeners now catch exceptions from a
hostile e.reason the same way the console.error patch already did,
and the module doc comment now notes the known HMR buffer-orphan
wrinkle. Adds two tests covering the external-wrapper and
module-re-execution scenarios; both were mutation-verified to fail
against the prior implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:08 -07:00
shadowdaoandClaude Opus 5 9be71e8fd1 feat(site-builder): capture recent console errors for issue reports
Ring buffer of the most recent console.error/window-error messages
(20 max, 500 chars each, message text only) for Task 19's report
payload. installConsoleErrorBuffer() is idempotent via a marker
stamped on the patched console.error itself (not just a module-scoped
flag), so React 18 StrictMode double-invocation or HMR re-running this
module's top level can't wrap an already-patched console.error and
build a growing chain. The patch always chains to the original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:37:32 -07:00
shadowdaoandClaude Opus 5 aba0d187d7 fix(site-builder): make Reset Entire Site's guard load-bearing in the handler
Two Important review findings on the previous commit (1a01834):

1. handleResetSite ran unconditionally -- the confirm button's `disabled`
   attribute was the only thing standing between a mismatched/empty typed
   value and a full site wipe. Extracted the match check into a single
   exported pure predicate, siteResetConfirmMatches(typed, domain), used
   for the button's disabled/cursor/opacity (previously three duplicated
   inline comparisons) AND as the first line of handleResetSite itself,
   which now returns early if it doesn't hold. An empty domain is rejected
   outright (`!!domain &&` short-circuits) so the guard holds even if the
   handler were ever reached with no configured domain, independent of the
   entry point being hidden.

2. The dialog said "design tokens" but resetToDefaults() also wipes
   headCode (analytics/search-console/third-party scripts) and favicon --
   neither is one of the 17 documented design properties, so a user had no
   reason to read them as included. Copy now names both explicitly.
   Re-verified every remaining claim in the paragraph against what the
   handler actually does (page/header/footer replacement, no undo, no
   publish call, images untouched, 30000ms auto-save) -- all still hold.

Verified load-bearing by temporarily reverting each guard in place (no git
stash -- shared across worktrees/sessions per review feedback) and
confirming the corresponding test fails: dropping the !!domain check broke
the empty-domain unit test; removing the handleResetSite check broke a new
test that invokes the confirm button's React onClick directly (bypassing
both the disabled attribute and react-dom's own disabled-click suppression,
which independent investigation confirmed blocks a plain DOM `.disabled =
false; .click()`/dispatchEvent bypass -- pulling onClick off the element's
stashed __reactProps$ key was the only way to actually exercise the
handler's own guard).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:30:20 -07:00
shadowdaoandClaude Opus 5 1a01834068 feat(site-builder): add domain-confirmed Reset Entire Site
Adds a danger-zone escape hatch below SiteDesignPanel's existing "Reset to
Defaults": blanks every page down to one empty Home, blanks the header and
footer, and resets all design tokens. Guarded by typing the exact site
domain to arm the confirm button.

The brief's setHeader('')/setFooter('') calls were wrong -- both take a
SerializedTreeNode (same tree shape as replaceAllPages), not a craft-state
string; passing '' would have hit treeToCraftState's sanitizeAiTree and
silently fallen back to a generic div-shaped empty canvas instead of a
proper header/footer. Built BLANK_HEADER_TREE/BLANK_FOOTER_TREE (tagged
header/footer) alongside the brief's page tree, and dropped the brief's
literal's extraneous flat-state fields (isCanvas/displayName/custom/hidden/
linkedNodes) that made it need an `as any` cast -- sanitizeAiTree/
flattenTreeForCraft only ever read type/props/nodes.

In standalone mode (no WHP_CONFIG) siteDomain is '', so the entire danger
zone -- not just the button -- is hidden behind `siteDomain &&`, closing off
the empty-string-trivially-matches guard bypass.

Dialog copy states the reset is undoable... is NOT undoable, and that
auto-save (confirmed exactly 30s via TopBar.tsx's setInterval) turns the
blank canvas into the saved draft shortly after, so no false safety-net
claim is made.

Verified load-bearing: reverted the implementation via git stash and
confirmed 3 of 4 new tests fail (the 4th, an absence-only standalone-mode
check, passed vacuously on first draft -- rewritten into a same-test
contrast against the non-standalone case, which does fail on revert).
Split into a second mock-free integration test file
(SiteDesignPanel.reset.integration.test.tsx) after vi.mock's file-scoped
hoisting made a same-file vi.doUnmock silently keep using the mocks --
it exercises the real PageProvider/SiteDesignProvider/treeToCraftState
pipeline end to end via the real editorHarness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:17:53 -07:00
shadowdaoandClaude Opus 5 bfcf6278e9 feat(site-builder): add Reset Page to the Pages panel
Adds a "Reset to blank" control to each page row in PagesPanel, behind an
inline confirmation matching the existing delete-confirmation UI. Blanks
the target page's canvas to EMPTY_CANVAS (Task 8) via actions.deserialize.

Since deserialize() acts on the live Frame, resetting a page that isn't
on screen switches to it first (switchPage), then defers the blank via
its own setTimeout(0) -- same-delay setTimeout callbacks fire in
registration order, so switchPage's own deferred load (also setTimeout(0),
registered first) always resolves before the blank does. Verified this
ordering empirically by injecting the reversed-order regression and
confirming the new ordering-sensitive integration test catches it.

Ran the brief's undo-characterization test first: actions.deserialize() IS
recorded in this @craftjs/core version's undo stack, so the confirmation
dialog's "Ctrl+Z undoes this." claim is accurate and was kept.

The reset button lives only in the per-page action row (not the separate
Header/Footer zone-row block), verified structurally and by test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:02:59 -07:00
shadowdaoandClaude Opus 5 e5fd74d63d fix(site-builder): repair orphan nodes on initial page load too
useWhpApi's load() called actions.deserialize() directly on the first
page's stored craftState, bypassing repairOrphanNodes -- unlike
PageContext.loadState, which runs it on every subsequent page switch.
An orphaned node (unreachable from ROOT, invisible to Layers/selection)
in a saved project would get silently repaired on the next page switch
but not on the load that actually renders it first. Route the initial
deserialize through the same repair call, with the same console.warn,
so both paths behave identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 09:53:36 -07:00
shadowdaoandClaude Opus 5 cf38fdb245 feat(site-builder): array editors scroll to the item picked in Layers
Wires ArrayItemFieldsEditor and FeaturesEditor up to useLayerFocus() so
clicking a virtual row in the Layers tree scrolls the matching item's
card into view in the right-hand array editor. scrollIntoView is
optional-chained on both the queried element and the method itself so
a miss or an environment without it degrades silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:21:27 -07:00
shadowdaoandClaude Opus 5 2438777462 feat(site-builder): Layers shows array-prop items, unplaced nodes, and scrolls
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:15:17 -07:00
shadowdaoandClaude Opus 5 f9561c8c54 feat(site-builder): add LayerFocusContext for layers-to-array-editor focus
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:08:26 -07:00