Compare commits

..
Author SHA1 Message Date
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
shadowdaoandClaude Opus 5 f30fc6efec fix(site-builder): add Hero/CTA components to Layers virtual-rows registry
A grep across every component under src/components/ (not just the 12
files the original registry hardcoded) turns up three more leaf
components with the identical array-prop-as-content pattern: Hero
(HeroSimple.tsx), Call to Action (CallToAction.tsx) and CTA Section
(CTASection.tsx) all render a shared `ctas?: CtaButton[]` prop whose
items are `{ text, href, variant?, target? }`. Without this, a Hero's
CTA buttons still wouldn't appear in the Layers tree.

Verified displayName, prop name and label field against each
component's craft.props defaults and the shared CtaButton type in
sections/_cta-helpers.tsx -- all three matched exactly, no corrections
needed. A follow-up sweep for any further array-prop leaf components
found none: the only other array fields in the tree are nested one
level inside already-covered items (ContactFormField.options,
PricingPlan.features), not top-level component props.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 07:04:54 -07:00
shadowdaoandClaude Opus 5 eb4290a45e feat(site-builder): derive Layers rows from array-prop composites
FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm
store their content in array props rather than Craft child nodes, so
the Layers tree showed nothing underneath them. This adds the pure
deriveVirtualRows() function and VIRTUAL_CHILD_PROPS registry the
Layers panel will consume in a later task.

Verified the registry against each component's actual item interface:
corrected Tabs (label field is `label`, not `title` -- TabItem has no
`title`) and Content Slider (label field is `heading`, not `title` --
Slide has no `title`). Both would have silently fallen back to
"Tab N" / "Slide N" for every existing site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:59:23 -07:00
shadowdaoandClaude Opus 5 38c8d22e5d test(site-builder): cover loadState's orphan-repair wiring via PageProvider
Review found Task 8's prescribed test never mounts PageProvider, so it
never exercises loadState -- deleting the repairOrphanNodes call would
still leave that suite green. Adds a PageProvider-mounted test driving
switchPage into a page with a stored orphaned node, asserting on what
loadState hands to actions.deserialize and that console.warn fires.
Verified load-bearing: temporarily neutering the repair call fails the
new test (ROOT.nodes missing 'stray'), then restored.

Also documents the fallback-branch invariant that `fallback` must
always be a known-safe constant, per review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:53:55 -07:00
shadowdaoandClaude Opus 5 9885b37af5 fix(site-builder): reattach unreachable nodes when loading a page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:48:19 -07:00
shadowdaoandClaude Opus 5 86aacbe1a8 fix(site-builder): repair cyclic orphan clusters in repairOrphanNodes
Review found that a cluster of orphan nodes referencing only each other
(no member's parent points outside the orphan set) made the reattach
loop find zero tops and silently no-op, leaving the cluster unreachable
while reporting repaired: []. Replaced the single-pass reattach with a
loop that re-derives the unreachable set each round and force-reattaches
one representative when no ordinary top exists, guaranteeing
findUnreachableNodeIds is empty after repair. Adds 2-node/3-node cycle
and mixed ordinary-subtree-plus-cycle tests; the original 11 tests are
unchanged and still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:43:46 -07:00
shadowdaoandClaude Opus 5 f0a1508acd feat(site-builder): add pure orphan-node detection and repair
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:36:39 -07:00
shadowdaoandClaude Opus 5 66db1db507 feat(site-builder): add insert/colour/format toolbar to the Edit HTML modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:31:55 -07:00
shadowdaoandClaude Opus 5 b670b436c3 fix(site-builder): stop CodeEditor fallback insert from being lost on CodeMirror mount
insertAtCursor's textarea-fallback branch advanced lastEmittedRef to the
post-insertion value. The mount effect's dynamic import() closes over
`value` as of initial render, so if CodeMirror finishes loading after a
fallback-mode insertion, it mounts with the pre-insertion doc. The only
repair mechanism -- the value-sync effect -- is gated on `value !==
lastEmittedRef.current`, so advancing that ref made the gate see them as
already equal and skip the repair, silently dropping the insertion.

Also clamps caretOffset to [0, text.length] defensively, and adds coverage
for a non-empty-selection replace and for the fallback-to-CodeMirror-mount
transition itself (using the real @codemirror/* packages, no mocks).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 06:26:27 -07:00
shadowdaoandClaude Opus 5 d89930e218 feat(site-builder): expose insertAtCursor/getValue handle on CodeEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:24:29 -07:00
shadowdaoandClaude Opus 5 45ee004672 fix(site-builder): discriminate formatHtml whitespace by block/inline boundary, not newline
Round 3 fixed a phantom-space idempotency bug by dropping any boundary
whitespace containing a newline, but that also stripped ordinary
hand-wrapped text like "hello\n<strong>", merging words on a single pass --
directly contradicting this formatter's own "does not reflow text"
contract. The correct discriminator is what the whitespace borders, not
whether it contains a newline: a run between two inline-level things (text,
<strong>, <a>, ...) is always significant and must survive regardless of
newlines, while a run touching a block-tag boundary carries no rendered
meaning and is always dropped. Since this formatter's own emitted
indentation is only ever inserted next to a block tag, that rule also
resolves the original phantom-space bug without any newline special-casing.
tokenize() now peeks each upcoming tag's name once (reused for both the
preceding text run's decision and the tag's own processing) so pushTextToken
can see what's on both sides of a whitespace run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:19:32 -07:00
shadowdaoandClaude Opus 5 536e4e9f86 fix(site-builder): make formatHtml close-tag mismatch recovery self-draining
Round 2's tag-name check on close popping fixed misattribution but could
wedge the stack permanently: a mismatch with a real, still-open ancestor
(e.g. an unclosed <p> before a later </div>, a normal optional-end-tag
slip) never drained, so everything after it inherited the stuck depth and
could print out of source order. Close handling now searches the whole
stack for a matching tag, not just the top; frames above a found match are
popped and implicitly closed (no fabricated close tag, just ending their
indentation) before the match itself closes normally. A close with no
match anywhere is still left in place untouched, since it has nothing to
pair with. Also fixes a related idempotency bug in text-run whitespace
collapsing surfaced while verifying this: boundary whitespace containing a
newline (formatter-introduced structural gap) is now dropped entirely
instead of being collapsed to a preserved space like same-line boundary
spaces are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 19:10:25 -07:00
shadowdaoandClaude Opus 5 51f3fe81b6 fix(site-builder): make formatHtml quote- and raw-text-aware
Code review found two Important bugs from only <pre> being exempted from
the naive </> tag-boundary scan: a > inside a quoted attribute value split
tags and broke idempotency, and <script>/<style> (declared BLOCK_TAGS but
never given raw-text treatment) let JS/CSS < and > desync sibling nesting.
Adds a quote-aware tag-end scanner, generalizes verbatim handling to
<script>/<style>, and makes close-tag stack popping verify the tag name
before popping instead of blindly popping by position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:59:57 -07:00
shadowdaoandClaude Opus 5 321a193b83 feat(site-builder): add dependency-free formatHtml prettifier
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:48:41 -07:00
shadowdaoandClaude Opus 5 4ac57e1c4c feat(site-builder): HTML blocks get a code-only style panel
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:42:28 -07:00
shadowdaoandClaude Opus 5 0d0d722dd7 refactor(site-builder): extract HtmlCodeField out of GenericPropsEditor
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:38:38 -07:00
shadowdaoandClaude Opus 5 a30e82accf fix(site-builder): HtmlBlock render stops applying style so editor matches published output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:35:12 -07:00
shadowdaoandClaude Opus 5 6791345f77 docs: implementation plan for the five user-reported site-builder issues
23 TDD tasks across five phases: HTML block (render/export mismatch, code-only
panel, editor toolbar), tree integrity (orphan repair + Unplaced recovery),
reset page/site, Layers virtual rows for array-prop composites, and in-builder
issue reporting (endpoint, table, root-only admin page). Off-canvas drop
prevention is scoped as an investigation whose acceptance criterion is a
written mechanism, not a guessed fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:27:02 -07:00
shadowdaoandClaude Opus 5 fb68fa6485 docs: design spec for five user-reported site-builder issues
Covers off-canvas drops (reachability invariant: prevent, repair on load,
recover via Layers), the HTML block's dead style props (render/export
mismatch plus a code-editor toolbar), page/site reset, Layers virtual rows
for array-prop composites, and in-builder issue reporting backed by a new
whp.site_builder_reports table and a root-only admin page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:39:00 -07:00
shadowdaoandClaude Opus 5 c6840db0bb chore(branding): rename "Web Hosting Panel" to "Web Hosting Platform"
Matches the rename in the whp repo now that the Web Hosting Platform
domain is registered. Doc line and a JS comment; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:17:27 -07:00
jknapp f5d2a23a5f Merge PR #26: surface publish warnings in the editor 2026-07-16 12:34:32 +00:00
shadowdaoandClaude Opus 4.8 c712a69c4a test(topbar): cover publish() warnings wiring into PublishWarnings banner
PublishWarnings.tsx had unit tests for the presentational banner, but
nothing asserted that result.warnings from publish() actually flows
through TopBar's handlePublish into it. That 3-line seam is exactly what
this feature exists to fix -- the backend always returned warnings, and
TopBar discarded them by only checking result.success, so the
contact-form-relay warning was dead code for its entire life.

Adds TopBar.test.tsx asserting: warnings render after a successful
publish with warnings, no banner renders when warnings is absent, a
warning doesn't present as a publish failure, warnings survive the 3s
"Published" flash (fake timers, advanced past 3000ms), and a fresh
publish clears stale warnings from the previous one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:12:31 -07:00
shadowdaoandClaude Opus 4.8 d460e8ac33 topbar: surface publish warnings instead of discarding them
handlePublish's JSON response has always included a `warnings` array
(e.g. the contact-form relay's "submissions will not be delivered"
notice), but nothing in the editor ever read it. Adds a PublishWarnings
banner, held in its own state independent of the 3s publishStatus
flash so the customer has time to read it, rendered in both the
desktop and mobile TopBar branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:52:11 -07:00
jknapp 2b1569202a Merge PR #25: bounce + image crop/resize fix 2026-07-14 23:07:58 +00:00
shadowdaoandClaude Opus 4.8 5c44dd545c fix(site-builder): bounce stays visible + springier; image/video crop fills (cover) + resize shrinks footprint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:04:50 -07:00
jknapp 2ac62c4e9e Merge PR #24: fix animation delay unit 2026-07-14 19:36:51 +00:00
shadowdaoandClaude Opus 4.8 25dfcbb725 fix(site-builder): coerce bare-number animation delay to a valid CSS time (2 -> 2s)
data-animation-delay is stored as a plain seconds string (e.g. '2'); the reveal
script assigned it raw to el.style.animationDelay, which is invalid CSS and no-ops.
Suffix 's' onto bare numbers (leaving '2s'/'200ms' alone) so entrance-animation
delays actually apply. Backend generateCompiledHTML gets the byte-identical change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:34:51 -07:00
jknapp 9bf78fd72d Merge PR #23: fix entrance-animation output 2026-07-14 19:16:59 +00:00
shadowdaoandClaude Opus 4.8 2dcc2b4d21 fix(site-builder): entrance-animation reveal script survives Preview + well-formed void-tag attrs + no-JS fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:12:01 -07:00
jknapp 4e0fc78a30 Merge PR #22: enh pages productivity + cross-page clipboard 2026-07-14 14:48:25 +00:00
shadowdaoandClaude Opus 4.8 85dfe181aa fix(pages): duplicatePage must save outgoing active canvas before teardown (avoid dropping live edits)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:47:44 -07:00
shadowdaoandClaude Opus 4.8 a698f014b0 feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:36:00 -07:00
jknapp 204ea5e078 Merge PR #21: enh output/seo/tokens (frontend) 2026-07-14 14:21:10 +00:00
shadowdaoandClaude Opus 4.8 0291ddce9a feat(site-builder): per-page SEO meta + favicon + design-token CSS-var wiring + published-output a11y/perf
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:14:56 -07:00
jknapp f7da654c11 Merge PR #20: enh nav 2026-07-14 13:58:11 +00:00
jknapp d6666f6f79 Merge PR #19: enh containers 2026-07-14 13:56:11 +00:00
shadowdaoandClaude Opus 4.8 4a426e3513 fix(containers): only flex-convert Container/Section when vertical-align set (avoid blockifying inline-block children)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:55:20 -07:00
jknapp 6e628d68dd Merge PR #18: enh sections 2026-07-14 13:54:52 +00:00
jknapp 8f17b74e2b Merge PR #17: enh forms 2026-07-14 13:49:57 +00:00
shadowdaoandClaude Opus 4.8 54572f648a feat(builder): nav/menu link-to-page picker, page sync, download attr, box-model rollout
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>
2026-07-14 06:47:59 -07:00
jknapp d07bc7789d Merge PR #16: enh code editor 2026-07-14 13:47:46 +00:00
jknapp b03425ac39 Merge PR #15: enh text+button 2026-07-14 13:46:08 +00:00
shadowdaoandClaude Opus 4.8 9750a6c2bf feat(builder): containers package -- vertical alignment + box-model/anim/vis rollout
- ColumnLayout: exposes style.alignItems on its flex ROW (aligns uneven
  columns) -- render/toHtml already spread `style` onto the row div, so this
  is a craft.props default + panel control addition, no structural change.
- Container/Section: root element is now unconditionally display:flex;
  flex-direction:column (both editor render and toHtml), so the new
  Vertical Alignment control maps to style.justifyContent, paired with a
  Min Height (NumericUnitInput) control on style.minHeight. Default
  justify-content/align-items reproduce ordinary block-flow stacking, so
  this is a visual no-op for existing published content. Works in both
  normal and "boxed" (contentWidth) modes -- the boxed inner wrapper's own
  margin:0-auto horizontal centering is preserved via flex auto-margin
  override semantics.
- ContainerStylePanel (serves Container/Section/Columns) distinguishes the
  Columns case from Container/Section via nodeProps.columns/split presence
  (no typeName plumbing needed) to pick align-items vs justify-content for
  the shared Vertical Alignment control.
- All 3 owned components: added margin/padding (per-side)/border/box-shadow/
  opacity style defaults + AnimationControl/VisibilityControl-backed
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props.
  New containerBoxModel.tsx (package-local, not shared.tsx) DRYs the
  box-model + border/effects + animation/visibility panel sections across
  the single shared ContainerStylePanel, mirroring the sibling media
  package's mediaBoxModel.tsx.
- Tests: extended all 3 *.toHtml.test.ts files (align-items/justify-content/
  min-height emission, box-model style emission, craft.props presence).
  673 tests green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:37 -07:00
shadowdaoandClaude Opus 4.8 5b19ae97af feat(builder): FORMS package -- field editor, functional Subscribe/Search, box-model+anim rollout
- FormStylePanel: ContactForm field editor (add/remove/reorder via
  ArrayPropEditor + manual move up/down) covering label/name/placeholder/
  type (full sanitizeInputType allowlist + textarea/select)/required/
  options.
- SubscribeForm.toHtml: was a dead `<form method="POST">` with no action at
  all -- wired through the same relayFormWiring contract as ContactForm/
  FormContainer so a recipientEmail makes it actually submit (marker +
  placeholder action + honeypot), falling back to action="#" otherwise.
- SearchBar: was purely decorative (no action/method/input name) -- now a
  real GET form (configurable target, default "/") with input name="q",
  safeUrl-guarded against javascript:/vbscript: breakout.
- Box-model (margin/padding per-side, border, shadow, opacity), entrance
  animation, and hide-on-device controls added to FormStylePanel and
  rolled out (blank/false craft.props defaults) across ContactForm,
  FormContainer, InputField, TextareaField, FormButton, SubscribeForm,
  SearchBar. No toHtml changes needed for animation/visibility --
  html-export.ts's buildDataAttrs() already emits data-animation/
  data-hide-* generically from these prop names.
- Extended toHtml tests for all 7 components: field-type rendering
  (incl. textarea/select), type-attribute XSS sanitization, relay/GET
  functional wiring, box-model style passthrough, craft.props defaults.

npx vitest run: 689/689 passed. npm run build: green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:21 -07:00
shadowdaoandClaude Opus 4.8 88df4f2888 feat(builder): CodeMirror 6 code editor for HTML/CSS/JS with lazy loading
Replaces the plain single-line input (HtmlBlock's `code` prop, via
GenericPropsEditor) and the plain <textarea> (HeadCodeModal's site-wide
head code) with a proper syntax-highlighted, tab-completing code editor.

- New src/ui/CodeEditor.tsx: reusable CodeMirror 6 editor (state/view/
  commands/autocomplete/language/lang-html/lang-css/lang-javascript/
  theme-one-dark). All @codemirror/* packages are pulled in via a single
  dynamic import() inside the component so they land in separate lazy
  chunks instead of the main bundle -- confirmed via `npm run build`:
  main editor.js grew by only ~5.5KB (657KB -> 663KB raw) while ~570KB of
  CodeMirror source split into index*.js chunks that only load when a
  code editor modal is actually opened. While that import is in flight,
  or if it ever fails, the component renders a plain <textarea> so typing
  never breaks.
- GenericPropsEditor.tsx: special-cases the `code` prop (used only by
  HtmlBlock today) into an "Edit HTML" button that opens the CodeEditor
  in a modal (language="html"), instead of rendering it as a single-line
  text input alongside the component's other string props.
- HeadCodeModal.tsx: swaps its <textarea> for CodeEditor (language="html"
  -- head code is HTML with embedded <script>/<style>), keeping the
  existing SiteDesignContext.updateDesign({ headCode }) wiring.

GuidedStyles.tsx untouched: HtmlBlock's displayName ("HTML") already
matches GuidedStyles' `isUtility` regex and routes to GenericPropsEditor,
so no dispatcher change was needed.

HtmlBlock.tsx untouched: purifyHtml/toHtml sanitization is unchanged, as
specified. Skipped the optional AnimationControl/VisibilityControl
addition -- HtmlBlock has no dedicated StylePanel (it shares
GenericPropsEditor with Divider/Spacer/every unmatched type), and doing
it well would mean adding real Animation/Visibility widgets to that
shared editor for every consumer, which is a bigger change than "trivial"
for this package's scope.

Tests: CodeEditor.test.tsx and HeadCodeModal.test.tsx both exploit the
fact that dynamic import() always resolves on a later microtask, so
asserting against the DOM immediately after the initial synchronous
render deterministically exercises the <textarea> fallback path (value
display, onChange wiring, language prop plumbing) without needing to
mock @codemirror/*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:42:59 -07:00
shadowdaoandClaude Opus 4.8 e1b4ab735c feat(builder): text+button package -- typography depth, button target/hover, box-model+anim/vis rollout
- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
  text-transform (none/uppercase/lowercase/capitalize), italic + underline
  toggles (fontStyle/textDecoration), and a custom NumericUnitInput
  font-size alongside the existing preset row. All write to component
  `style`; no toHtml changes needed (style already flows through
  cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
  existing `target` prop ('_self'/'_blank' -- toHtml already emitted
  rel="noopener noreferrer" for _blank). New Hover State section
  (hoverBg/hoverColor via ColorPickerField) renders a scoped
  `<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
  toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
  two buttons on one page don't collide; both values sanitized through
  cssValue against <style>-element breakout. Editor canvas gets a live
  hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
  approach), since there's no way to preview a CSS :hover rule directly
  on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
  Text only)/border/box-shadow/opacity style defaults + AnimationControl/
  VisibilityControl-backed animation/animationDelay/hideOnDesktop/
  hideOnTablet/hideOnMobile props, each panel gaining collapsible
  Spacing / Border & Effects / Animation & Visibility sections. Button
  keeps its existing padding-shorthand preset row rather than adding a
  redundant per-side padding control; only margin got the new
  per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
  emission (line-height/letter-spacing/text-transform/font-style/
  text-decoration/custom font-size), button target+rel, scoped hover
  style emission + two adversarial style-breakout cases (</style><script>
  and rule-injection via `;}selector{`), and craft.props assertions for
  every new prop on all three components. 674 tests green, tsc + vite
  build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:41:53 -07:00
jknapp ff1e8fc096 Merge PR #14: enh media package 2026-07-14 13:37:55 +00:00
shadowdaoandClaude Opus 4.8 0419291259 feat(builder): media package -- image crop/perf, video size+picker, gallery cols/lightbox, box-model+anim/vis rollout
- ImageBlock/ImageStylePanel: SizeControl width(absorbs old maxWidth
  presets)/height, AspectRatioControl + OBJECT_FIT grid + FocalPointGrid for
  CSS framing crop (aspect-ratio/object-fit/object-position on the <img>).
  Exported <img> always gets loading="lazy" decoding="async", plus width/
  height attrs when the style has a plain px length (pxAttr helper).
- VideoBlock/MediaStylePanel: Video URL text input replaced with
  AssetPicker(mediaType="video") writing videoUrl (paste-URL still handles
  YouTube/Vimeo, upload/browse handle files). Added SizeControl(width) +
  AspectRatioControl so the video frame honors real size/aspect instead of
  a hardcoded 16:9 (padding-bottom hack replaced with CSS aspect-ratio).
  New optional `poster` prop (image AssetPicker) + preload="metadata" on
  file-type <video>.
- Gallery: surfaced the existing-but-unexposed `columns` and `lightbox`
  props with panel controls.
- All 5 owned components (ImageBlock, VideoBlock, Gallery, ContentSlider,
  MapEmbed): added margin/padding (per-side)/border/box-shadow/opacity style
  defaults + AnimationControl/VisibilityControl-backed animation/
  animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props. New shared
  mediaBoxModel.tsx (package-local, not shared.tsx) DRYs the box-model +
  border/effects + animation/visibility panel sections across
  ImageStylePanel and MediaStylePanel.
- Tests: extended *.toHtml.test.ts for all 5 components (crop/perf attrs,
  video size/aspect/poster/preload, gallery columns/lightbox, box-model
  style emission, craft.props presence) + new MediaStylePanel.video.test.tsx
  verifying the AssetPicker wiring writes videoUrl/poster and the video-only
  size controls are gated on videoUrl. 694 tests green, tsc + vite build
  clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:30:50 -07:00
142 changed files with 18970 additions and 290 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview ## Overview
This site builder integrates with WHP (Web Hosting Panel) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account. This site builder integrates with WHP (Web Hosting Platform) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
## Architecture ## Architecture
@@ -52,3 +52,69 @@ describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', ()
expect(html).toContain('>Click Me</a>'); expect(html).toContain('>Click Me</a>');
}); });
}); });
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
test('no hover props -- no <style> block, no class added', () => {
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
expect(html).not.toContain('<style>');
expect(html).not.toContain('class=');
});
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
});
test('two different node ids produce different scope classes (no collision)', () => {
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
expect(scopeOf(a)).toBeTruthy();
expect(scopeOf(a)).not.toBe(scopeOf(b));
});
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
const malicious = '</style><script>alert(1)</script>';
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
expect(html).not.toContain('</style><script>');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
const malicious = 'red;}body{background:red';
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
expect(html).not.toContain('}body{');
expect(html).not.toContain(';}');
// The whole export is still exactly one <style> element -- no new rule
// or element was opened by the malicious value.
expect((html.match(/<style>/g) || []).length).toBe(1);
expect((html.match(/<\/style>/g) || []).length).toBe(1);
});
});
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
test('target defaults to _self, hoverBg/hoverColor blank', () => {
const props = (ButtonLink as any).craft.props;
expect(props.target).toBe('_self');
expect(props.hoverBg).toBe('');
expect(props.hoverColor).toBe('');
});
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ButtonLink as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (ButtonLink as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+53 -3
View File
@@ -1,13 +1,24 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
interface ButtonLinkProps { interface ButtonLinkProps {
text?: string; text?: string;
href?: string; href?: string;
target?: '_self' | '_blank'; target?: '_self' | '_blank';
style?: CSSProperties; style?: CSSProperties;
/** Background color applied on `:hover` via a scoped `<style>` block
* (editor preview does not show hover state -- only the published
* export). Blank means "no hover background override". */
hoverBg?: string;
/** Text color applied on `:hover`, same scoped `<style>` block. */
hoverColor?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const ButtonLink: UserComponent<ButtonLinkProps> = ({ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
@@ -15,6 +26,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
href = '#', href = '#',
target = '_self', target = '_self',
style = {}, style = {},
hoverBg = '',
hoverColor = '',
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -23,6 +36,8 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
selected: node.events.selected, selected: node.events.selected,
})); }));
const [hovered, setHovered] = React.useState(false);
return ( return (
<a <a
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
@@ -32,12 +47,16 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
// Prevent navigation inside editor // Prevent navigation inside editor
e.preventDefault(); e.preventDefault();
}} }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ style={{
display: 'inline-block', display: 'inline-block',
textDecoration: 'none', textDecoration: 'none',
cursor: 'pointer', cursor: 'pointer',
outline: selected ? '2px solid #3b82f6' : 'none', outline: selected ? '2px solid #3b82f6' : 'none',
...style, ...style,
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
...(hovered && hoverColor ? { color: hoverColor } : {}),
}} }}
> >
{text} {text}
@@ -53,6 +72,8 @@ ButtonLink.craft = {
text: 'Click Me', text: 'Click Me',
href: '#', href: '#',
target: '_self', target: '_self',
hoverBg: '',
hoverColor: '',
style: { style: {
backgroundColor: '#3b82f6', backgroundColor: '#3b82f6',
color: '#ffffff', color: '#ffffff',
@@ -61,7 +82,15 @@ ButtonLink.craft = {
fontWeight: '600', fontWeight: '600',
fontSize: '16px', fontSize: '16px',
border: 'none', border: 'none',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
boxShadow: 'none',
opacity: '1',
}, },
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -72,7 +101,7 @@ ButtonLink.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => { (ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
const styleStr = cssPropsToString({ const styleStr = cssPropsToString({
display: 'inline-block', display: 'inline-block',
textDecoration: 'none', textDecoration: 'none',
@@ -80,7 +109,28 @@ ButtonLink.craft = {
}); });
const escapedText = escapeHtml(props.text || ''); const escapedText = escapeHtml(props.text || '');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''; const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
// Scoped hover style -- same pattern as Navbar/Menu: a deterministic,
// per-node class (via scopeId) avoids two ButtonLink instances on the
// same page colliding on a shared `.btn-link:hover` rule. hoverBg/
// hoverColor are sanitized through cssValue -- they land inside a
// `<style>` element, the worst-case XSS sink (an unescaped `<`/`>` or
// `{`/`}` could close the rule/element and open a `<script>`).
const hoverBg = cssValue(props.hoverBg);
const hoverColor = cssValue(props.hoverColor);
let hoverCss = '';
let cls = '';
if (hoverBg || hoverColor) {
const scope = scopeId(nodeId, (props.href || '') + (props.text || ''), 'btn');
cls = ` class="${scope}"`;
const decls = [
hoverBg ? `background-color:${hoverBg}` : '',
hoverColor ? `color:${hoverColor}` : '',
].filter(Boolean).join(';');
hoverCss = `<style>.${scope}:hover{${decls}}</style>`;
}
return { return {
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`, html: `${hoverCss}<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${cls}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
}; };
}; };
@@ -20,3 +20,14 @@ describe('Footer.toHtml text escaping (attacker-controlled `text` prop)', () =>
expect(html).toContain('© 2026 MySite. All rights reserved.'); expect(html).toContain('© 2026 MySite. All rights reserved.');
}); });
}); });
describe('Footer (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Footer as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
interface FooterProps { interface FooterProps {
text?: string; text?: string;
style?: CSSProperties; style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
export const Footer: UserComponent<FooterProps> = ({ export const Footer: UserComponent<FooterProps> = ({
@@ -92,6 +97,11 @@ Footer.craft = {
fontSize: '14px', fontSize: '14px',
padding: '24px 20px', padding: '24px 20px',
}, },
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -56,3 +56,54 @@ describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () =>
expect(html).toBe('<h2>Hello world</h2>'); expect(html).toBe('<h2>Hello world</h2>');
}); });
}); });
describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
const { html } = toHtml({
text: 'x',
level: 'h2',
style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' },
}, '');
expect(html).toContain('line-height:1.25');
expect(html).toContain('letter-spacing:0.05em');
expect(html).toContain('text-transform:uppercase');
});
test('italic + underline toggles emit font-style and text-decoration', () => {
const { html } = toHtml({
text: 'x',
level: 'h2',
style: { fontStyle: 'italic', textDecoration: 'underline' },
}, '');
expect(html).toContain('font-style:italic');
expect(html).toContain('text-decoration:underline');
});
test('a custom font-size (not one of the presets) still flows through', () => {
const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, '');
expect(html).toContain('font-size:42px');
});
});
describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Heading as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + typography-depth keys', () => {
const style = (Heading as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style).toHaveProperty('lineHeight');
expect(style).toHaveProperty('letterSpacing');
expect(style).toHaveProperty('textTransform');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+15
View File
@@ -100,7 +100,22 @@ Heading.craft = {
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
color: '#1f2937', color: '#1f2937',
marginBottom: '16px', marginBottom: '16px',
lineHeight: '',
letterSpacing: '',
textTransform: '' as CSSProperties['textTransform'],
fontStyle: '' as CSSProperties['fontStyle'],
textDecoration: '',
marginTop: '', marginRight: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
}, },
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -0,0 +1,51 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useNode: (collect?: (node: any) => any) => {
const node = { events: { selected: false } };
return {
connectors: { connect: (el: any) => el, drag: (el: any) => el },
actions: { setProp: vi.fn() },
...(collect ? collect(node) : {}),
};
},
}));
import { HtmlBlock } from './HtmlBlock';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
render(
React.createElement(HtmlBlock as any, {
code: '<p>hello</p>',
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
}),
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.backgroundColor).toBe('');
expect(wrapper.style.color).toBe('');
expect(wrapper.style.padding).toBe('');
});
test('the editor affordances survive: minHeight is kept, content still renders', () => {
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.minHeight).toBe('40px');
expect(wrapper.innerHTML).toContain('hello');
});
});
@@ -0,0 +1,332 @@
import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock';
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
// copy of the reference acceptance fixture used for Task 24 (widening the
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
// external fixture used to drive this task so these tests cannot silently
// drift from the thing they are supposed to be testing against.
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
/**
* Task 24: the site owner tested a broad HTML fixture against the shipped
* sanitiser config and found 38% of it silently deleted -- merged table
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
* reference fixture through the *actual* purifyHtml() and assert the
* previously-broken constructs now survive with their meaningful
* attributes intact, while re-confirming (with attack payloads spliced
* into the newly-widened surface -- forms, media, inline svg) that the
* four non-negotiable security properties still hold.
*/
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
const out = purifyHtml(fixtureHtml);
test('table merged cells keep colspan/rowspan/scope', () => {
expect(out).toContain('<td colspan="2">');
expect(out).toContain('<td rowspan="2">');
expect(out).toContain('<th scope="col">');
expect(out).toContain('<th scope="row">');
});
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
});
test('menu list survives with nested buttons', () => {
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
});
test('sub/sup survive (was flattened to "H2O")', () => {
expect(out).toContain('H<sub>2</sub>O');
expect(out).toContain('x<sup>2</sup>');
});
test('details/summary survive with the open attribute (was flattened)', () => {
expect(out).toContain('<summary>Collapsed disclosure</summary>');
expect(out).toContain('<details open="">');
});
test('hgroup survives', () => {
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
});
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
});
test('picture/source with media+srcset survive', () => {
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
});
test('video/audio survive with source/track children (was deleted entirely)', () => {
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
expect(out).toContain('<source src="clip.webm" type="video/webm">');
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
});
test('canvas survives with its fallback text', () => {
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
});
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
expect(out).toContain('<mark>mark</mark>');
expect(out).toContain('<small>small</small>');
expect(out).toContain('<del>del</del>');
expect(out).toContain('<ins>ins</ins>');
});
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
expect(out).toContain('lang="ar" dir="rtl"');
expect(out).toContain('lang="he" dir="rtl"');
});
test('role attribute preserved alongside aria-* (role was stripped)', () => {
expect(out).toMatch(/<nav aria-label="Primary">/);
expect(out).toMatch(/role="img"/);
});
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
expect(out).toContain('<ol start="5" reversed="">');
});
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
expect(out).toContain('<kbd>Ctrl</kbd>');
expect(out).toContain('<samp>output text</samp>');
expect(out).toContain('<var>variable</var>');
expect(out).toContain('<dfn>definition term</dfn>');
expect(out).toContain('<address>');
expect(out).toContain('<bdi>');
expect(out).toContain('<bdo dir="rtl">');
expect(out).toContain('<ruby>');
expect(out).toContain('<rt>kan</rt>');
expect(out).toContain('<time datetime="2026-08-09">');
expect(out).toContain('<data value="42">');
});
test('wbr survives (word-break opportunity)', () => {
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
});
test('hidden attribute survives', () => {
expect(out).toContain('<p hidden="">');
});
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
expect(out).toContain('<form action="#" method="get">');
expect(out).toContain('<fieldset>');
expect(out).toContain('<legend>Text inputs</legend>');
expect(out).toContain('<label for="f-text">Text</label>');
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
expect(out).toContain('<input id="f-email" type="email" required="">');
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
expect(out).toContain('<select id="f-select" name="select">');
expect(out).toContain('<optgroup label="Group one">');
expect(out).toContain('<option value="1" selected="">One</option>');
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
expect(out).toContain('<datalist id="suggestions">');
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
expect(out).toContain('<output name="result" for="f-num f-range">');
expect(out).toContain('<progress id="f-prog" value="0.6">');
expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
expect(out).toContain('<button type="submit">Submit</button>');
});
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
expect(out).toContain('low="30" high="80" optimum="90"');
});
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
});
});
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
const out = purifyHtml(fixtureHtml);
test('style tag never survives', () => {
expect(out).not.toMatch(/<style[\s>]/i);
});
test('script tag never survives', () => {
expect(out).not.toMatch(/<script[\s>]/i);
});
test('dialog/template stay excluded (not in the widened allow-list)', () => {
expect(out).not.toContain('<dialog');
expect(out).not.toContain('<template');
});
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
// The fixture's dialog/close buttons carry onclick specifically to
// prove this; their text content should still come through once the
// handler is stripped and (for dialog) the wrapping tag is dropped.
expect(out).toContain('Open dialog');
});
});
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
test('script inside a newly-allowed <form> still never survives', () => {
const out = purifyHtml('<form><script>alert(1)</script></form>');
expect(out).not.toContain('<script');
});
test('on* handlers never survive on newly-allowed form controls', () => {
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
expect(out).not.toMatch(/onfocus/i);
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
expect(out2).not.toMatch(/onchange/i);
});
test('javascript: blocked in <form action>', () => {
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
expect(out).not.toContain('javascript:');
});
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
expect(out).not.toContain('formaction');
expect(out).not.toContain('javascript:');
});
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
expect(out).not.toContain('javascript:');
expect(out).not.toContain('xlink:href');
});
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
expect(out).not.toContain('javascript:');
});
test('javascript: still blocked in plain href alongside the widened surface', () => {
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
expect(out).not.toContain('javascript:');
});
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
expect(sandbox).not.toMatch(/allow-top-navigation/);
expect(out).toContain('referrerpolicy="no-referrer"');
});
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
expect(out).not.toMatch(/onload/i);
});
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
// comment), including copies nested inside inline SVG:
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
// namespace/nesting depth, because CSS itself doesn't respect SVG
// subtree boundaries -- an unscoped <style> inside <svg> would still
// apply page-wide, so it needs the same scoping as a top-level one.
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
expect(out).toMatch(/<style/i);
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
expect(out).toContain('<rect');
});
test('contenteditable does not smuggle an event handler in alongside it', () => {
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
expect(out).not.toMatch(/onblur/i);
expect(out).toContain('contenteditable="true"');
});
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
expect(out).not.toContain('<dialog');
expect(out).not.toMatch(/onclick/i);
expect(out).toContain('<p>hi</p>');
});
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
// data: is only allow-listed for data:image/*;base64, -- confirm the
// regex is not accidentally satisfied by a text/html or bare data:
// payload on any of the newly URI-checked attributes.
const out = purifyHtml(
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
'<blockquote cite="data:text/html,x">q</blockquote>' +
'<form action="data:text/html,x"></form>',
);
expect(out).not.toContain('data:text/html');
});
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
// that gets a trailing `:` appended to every alternative, requiring a
// second colon after the one already in "base64," -- which no real
// data URI has, so the clause could never match anything. Confirm the
// fixed regex actually allows a real base64 image data URI through on
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
// src ones tested below).
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const out = purifyHtml(
`<video poster="data:image/png;base64,${b64}"></video>` +
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
`<a href="data:image/png;base64,${b64}">img</a>`,
);
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
});
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
// image, track) that accepts ANY data: URI on the `src` attribute of
// those tags regardless of declared mimetype, before our regex is ever
// consulted. Acceptable because none of those tags execute their src
// as a document/script context in mainstream browsers -- the sink
// doesn't execute. Pinned here so a future DOMPurify version change to
// DATA_URI_TAGS shows up as a failing test, not a surprise in
// production. <iframe> -- the one tag where this WOULD be dangerous --
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
expect(iframeOut).not.toContain('data:');
});
});
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
test('<select size> survives with its value intact (multi-select row count)', () => {
const out = purifyHtml('<select size="4"><option>a</option></select>');
expect(out).toContain('size="4"');
});
test('<input size> survives with its value intact', () => {
const out = purifyHtml('<input type="text" size="10">');
expect(out).toContain('size="10"');
});
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
expect(out).toContain('low="1"');
expect(out).toContain('high="9"');
expect(out).toContain('optimum="5"');
});
});
+336 -2
View File
@@ -1,5 +1,17 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { purifyHtml } from './HtmlBlock'; import { purifyHtml } from './HtmlBlock';
import { stableHash } from '../../utils/escape';
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
// immediately before Task 25 -- `git show
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
// the "byte-diff against 6a9b227" describe block below -- this is the
// literal regression check the Task 25 review asked for, after the first
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
// silently changing output for a comment-led, style-free fixture.
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
describe('purifyHtml', () => { describe('purifyHtml', () => {
test('strips script tags', () => { test('strips script tags', () => {
@@ -17,8 +29,47 @@ describe('purifyHtml', () => {
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>'); const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
expect(out).toContain('youtube.com/embed/abc'); expect(out).toContain('youtube.com/embed/abc');
}); });
test('strips form/input', () => { test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form'); const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
expect(out).toContain('<form');
expect(out).toContain('<input name="x">');
expect(out).not.toContain('onfocus');
expect(out).not.toContain('<script');
});
});
describe('purifyHtml markup path (C1 review finding)', () => {
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
expect(out).toBe('<p style="color: #ff0000">red text</p>');
});
test('an id attribute survives sanitization (anchor targets)', () => {
const out = purifyHtml('<a href="#section" id="section">link</a>');
expect(out).toContain('id="section"');
});
test('a pasted table survives sanitization', () => {
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(input)).toBe(input);
});
test('script tags still do not survive alongside a style attribute', () => {
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script');
expect(out).toContain('style="color:#ff0000"');
});
test('on* handlers still do not survive on an element that also carries style', () => {
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
expect(out).not.toContain('onclick');
expect(out).toContain('style="color:#ff0000"');
});
test('javascript: URLs still do not survive on an element that also carries style', () => {
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
expect(out).not.toContain('javascript:');
expect(out).toContain('style="color:#ff0000"');
}); });
}); });
@@ -64,3 +115,286 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
expect(out).toContain('<p>hi</p>'); expect(out).toContain('<p>hi</p>');
}); });
}); });
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
test('a block with no <style> at all is untouched: no wrapper div added', () => {
const out = purifyHtml('<p>hello</p>');
expect(out).toBe('<p>hello</p>');
expect(out).not.toContain('<div');
});
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
// Same representative inputs the Task 24 suite already pins to an exact
// string -- re-asserted here under the Task 25 name so a future change
// that starts wrapping every block (not just style-bearing ones) fails
// loudly and obviously, not just as a Task 24 side-effect.
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(table)).toBe(table);
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
});
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
// The fixture is the broadest real-world stand-in this repo has for
// "a customer's actual pasted block". It contains no <style>, so this
// is the closest thing to a real before/after diff over a large,
// realistic input: the only lever Task 25 pulled (allowing <style> +
// FORCE_BODY) must produce PRECISELY the same output as before for
// content that never touches that lever.
const out = purifyHtml(fixtureHtml);
expect(out).not.toContain('<div class="whp-html-');
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
});
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style></style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a whitespace-only <style> does not trigger a wrapper', () => {
const out = purifyHtml('<p>hi</p><style> \n </style>');
expect(out).not.toContain('<div class="whp-html-');
});
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
expect(out).toContain('<h1>Hi</h1>');
});
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
// The bare, unscoped rule must not appear anywhere in the output --
// that's exactly the leak this feature exists to close.
expect(out).not.toContain('<style>h1 { color: red; }</style>');
});
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
const code = '<style>p { color: blue; }</style><p>x</p>';
const out1 = purifyHtml(code);
const out2 = purifyHtml(code);
expect(out1).toBe(out2);
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(class1).toBe(class2);
});
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
// If this ever needs to change, it means the hash function itself
// changed -- which would silently churn every stored site's HTML on
// next save and desync already-published pages from a fresh Preview.
// That should be a loud, deliberate decision, not a side-effect of an
// unrelated refactor -- hence pinning the literal output here.
const code = '<style>h1{color:red}</style>';
expect(stableHash(code)).toBe('5fwbyn');
const out = purifyHtml(code);
expect(out).toContain('class="whp-html-5fwbyn"');
});
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
// purifyHtml's signature only ever takes the code string -- there is no
// node id parameter it could even reach for. This test documents that
// invariant so a future refactor threading a node id through here (as
// html-export.ts's renderNode already does for OTHER components, see
// its `scopeId` comment) doesn't silently get wired into this path too.
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
expect(codeA).toBe(codeB); // sanity: truly identical strings
const outA = purifyHtml(codeA);
const outB = purifyHtml(codeB);
expect(outA).toBe(outB);
});
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
// HTML document and serializes only <body>. Per the HTML5 parsing
// algorithm, a <style> tag with nothing before it is implicitly placed
// in the parser's <head>, which DOMPurify's body-only serialization
// never looks at -- the whole block would silently vanish. Confirmed
// empirically against dompurify+jsdom directly before this fix existed.
const out = purifyHtml('<style>h1{color:red}</style>');
expect(out).toContain('<style>');
expect(out).toContain('color:red');
});
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
expect(out).toContain('<h1>Hi</h1>');
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
});
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
});
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
expect(out).not.toContain('@import');
expect(out).not.toContain('evil.example');
expect(out).toContain('color:red');
});
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
const out = purifyHtml(
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
);
expect(out).toContain('@keyframes spin');
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
});
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
expect(classes.length).toBeGreaterThanOrEqual(1);
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
});
});
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
test('</style> inside a CSS comment cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
expect(out).not.toMatch(/on[a-z]+\s*=/i);
});
test('</style> inside a CSS string cannot break out into executable markup', () => {
const out = purifyHtml(
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
);
expect(out).not.toContain('<script');
});
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
const out = purifyHtml(
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
);
expect(out).not.toMatch(/onclick/i);
expect(out).not.toContain('<script');
expect(out).not.toContain('javascript:');
});
test('iframe sandboxing still applies alongside a styled block', () => {
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test(
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
'not exploitable there, but not filtered by this pipeline either)',
() => {
const out = purifyHtml(
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
);
expect(out).toContain('expression(alert(1))');
expect(out).toContain('behavior:url(evil.htc)');
expect(out).toContain('-moz-binding:url(evil.xml#x)');
},
);
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
expect(out).toContain('tracker.example');
});
});
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
// assertions for the "no <style> => unchanged" guarantee. Those all
// passed while FORCE_BODY: true (applied unconditionally at the time)
// was silently changing the ACTUAL bytes for any style-free block that
// starts with a multi-line HTML comment -- including this repo's own
// fixture, which is exactly that shape. `.not.toContain` can't catch an
// extra leading newline; only a raw diff against the real old output
// can. These tests do that: `preTask25FixtureOutput` is
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
// dompurify+jsdom, not re-derived from reading the code -- see that
// fixture file's own header comment.
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
});
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
// Confirmed independently against 6a9b227's exact code: a multi-line
// leading comment followed by blank-line whitespace, with no <style>
// anywhere, produces "<p>hi</p>" -- both the comment AND the
// whitespace between it and <p> are dropped by the parser's
// "before head" insertion-mode rules (unrelated to this task; that's
// the pre-existing, unconditional behavior with FORCE_BODY off). The
// point of this test is that the NEW code must reproduce that exact
// old quirk byte-for-byte for style-free input, not "improve" on it.
const commentLed =
'<!-- ============================================================\n' +
' HTML test fixture header\n' +
' ============================================================ -->\n' +
'\n<p>hi</p>';
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
});
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
});
});
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
function buildDeeplyNestedMedia(count: number): string {
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
// through the REAL purifyHtml() call, not just scopeCss() in isolation
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
// not merely in the unit-tested function.
let css = 'h1{color:red}';
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
return `<style>${css}</style><h1>x</h1>`;
}
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
const code = buildDeeplyNestedMedia(7000);
expect(() => purifyHtml(code)).not.toThrow();
const out = purifyHtml(code);
expect(out).toContain('<h1>x</h1>');
expect(out).toContain('@media');
});
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
const code = buildDeeplyNestedMedia(7000);
const out = purifyHtml(code);
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
});
});
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
// Specifically: no second wrapper div, no double-prefixed selector.
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('idempotent for a block using :root/media too', () => {
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
const once = purifyHtml(code);
const twice = purifyHtml(once);
expect(twice).toBe(once);
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
});
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
const gen1 = purifyHtml(code);
const gen2 = purifyHtml(gen1);
const gen3 = purifyHtml(gen2);
expect(gen3).toBe(gen1);
});
});
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { HtmlBlock } from './HtmlBlock'; import { HtmlBlock, purifyHtml } from './HtmlBlock';
const toHtml = (HtmlBlock as any).toHtml; const toHtml = (HtmlBlock as any).toHtml;
@@ -23,3 +23,47 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
expect(html).toBe('<p>hi</p>'); expect(html).toBe('<p>hi</p>');
}); });
}); });
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
const out = (HtmlBlock as any).toHtml(
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
'',
);
expect(out.html).toBe('<p>hi</p>');
expect(out.html).not.toContain('background');
expect(out.html).not.toContain('40px');
});
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
expect(html).toBe('<p style="color: #ff0000">red text</p>');
});
test('a table inside `code` reaches exported output', () => {
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
const { html } = toHtml({ code }, '');
expect(html).toBe(code);
});
});
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
// Published export) both call the exact same purifyHtml(code) -- this
// is the byte-parity invariant this project treats as a hard
// requirement. Proven here by calling purifyHtml directly (as the
// canvas's useMemo does) and toHtml (as export does) on the identical
// code string and asserting the two never diverge.
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
const { html } = toHtml({ code }, '');
expect(html).toBe(purifyHtml(code));
});
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
const code = '<p>hello</p>';
const { html } = toHtml({ code }, '');
expect(html).toBe('<p>hello</p>');
expect(html).not.toContain('<div');
});
});
+313 -7
View File
@@ -1,6 +1,8 @@
import React, { CSSProperties, useMemo } from 'react'; import React, { CSSProperties, useMemo } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { stableHash } from '../../utils/escape';
import { scopeCss } from '../../utils/scope-css';
interface HtmlBlockProps { interface HtmlBlockProps {
code: string; code: string;
@@ -9,28 +11,207 @@ interface HtmlBlockProps {
node_id?: string; node_id?: string;
} }
// Task 24: widening the allow-list after a customer's broad HTML fixture
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
// be generous -- this block is an explicit escape hatch and customers
// reasonably expect it to render ordinary HTML, including forms. The four
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
// sandboxed) are unaffected by the widening and are covered by dedicated
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
const PURIFY_CONFIG = { const PURIFY_CONFIG = {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
'a','p','br','hr','div','span','section','article', 'a','p','br','hr','div','span','section','article',
'header','footer','main','aside','nav', 'header','footer','main','aside','nav',
'ul','ol','li', 'ul','ol','li',
'h1','h2','h3','h4','h5','h6', 'h1','h2','h3','h4','h5','h6','hgroup',
'em','strong','b','i','u','s', 'em','strong','b','i','u','s',
'blockquote','code','pre', 'blockquote','code','pre',
'img','figure','figcaption', 'img','figure','figcaption',
'iframe', 'iframe',
// Tables: pasted content commonly includes these; dropping them
// silently ate customer-pasted tables (see C1 review finding).
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
// Text semantics (Task 24).
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
// Lists (Task 24).
'dl','dt','dd','menu',
// Disclosure widget (Task 24). Note: <dialog> and <template> are
// deliberately NOT added -- the fixture exercises them wrapped in
// on*= handlers specifically to prove they still get neutralized/
// dropped by staying outside the allow-list.
'details','summary',
// Media (Task 24). URL-bearing attributes on these (poster, srcset,
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
// everything else -- see _isValidAttribute in dompurify, which
// URI-checks every allowed attribute value except a small fixed
// "inert" list (alt, class, id, style, title, ...) that never includes
// src/poster/srcset. The one exception: `src` itself on img/video/
// audio/source/image/track is additionally covered by DOMPurify's own
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
// tag/attribute pairs regardless of mimetype, bypassing this regex --
// see the ALLOWED_URI_REGEXP comment below and
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
// none of those tags execute their src as a document.
'picture','source','video','audio','track','canvas',
// Forms (Task 24). Site owner's explicit decision: allow the full
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
// same as href/src, so `javascript:` still cannot survive here either.
'form','input','button','select','option','optgroup','textarea',
'label','fieldset','legend','datalist','output','progress','meter',
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
// neighbor below for why this is an explicit tag list rather than
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
// <image> (both need xlink:href, an external-reference vector DOMPurify
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
'line','polyline','polygon','path','text','tspan',
'lineargradient','radialgradient','stop','clippath','mask','marker',
'pattern','switch','view',
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
// (stripped entirely). Now allowed through sanitisation -- its CSS is
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
// DOMPurify runs, so it can only match inside this block's own wrapper
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
// why allowing the tag alone is not sufficient.
//
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
// element -- not just the offending part -- if its text content
// contains anything that merely LOOKS tag-like (a `<` followed by a
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
// that isn't specific to <style>. So `.x::after{content:"<Read
// More>"}` -- a plausible, entirely benign real-world CSS content
// string -- makes the whole style block vanish with no error, the same
// way a `<script>` would. This is a GOOD security property (better
// paranoid than exploitable), but it's an undocumented interaction
// with this newly-widened surface that will otherwise confuse whoever
// debugs the inevitable "my CSS just disappeared" report -- confirmed
// empirically against dompurify+jsdom directly, not guessed at.
'style',
], ],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
// allowlist rather than extending it, so anything the product needs
// (style, id, ...) must be listed explicitly here even though DOMPurify
// would allow it by default.
ALLOWED_ATTR: [ ALLOWED_ATTR: [
'href','src','alt','title','target','rel', 'href','src','alt','title','target','rel',
'width','height','class', 'width','height','class','id','style',
'allowfullscreen','allow','frameborder', 'allowfullscreen','allow','frameborder',
'sandbox','referrerpolicy', 'sandbox','referrerpolicy',
// Task 24 additions.
'colspan','rowspan','scope','headers','span','start','reversed',
'type','value','name','placeholder','required','disabled','readonly',
'checked','selected','multiple','size','min','max','step','minlength',
'maxlength','pattern','rows','cols','accept','action','method','for',
'list','label','datetime','cite','lang','dir','role','srcset','media',
'sizes','loading','controls','poster','loop','muted','autoplay',
'preload','playsinline','kind','srclang','default','open','download',
'hidden','contenteditable',
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
// were still being stripped even though <select>/<meter> are already in
// ALLOWED_TAGS -- only these four attribute names were missing here.
// Effect: a multi-select rendered at default height instead of the
// requested row count, and <meter> lost its threshold-based gauge
// colouring. Pure presentation/semantic attributes -- no URL, no
// script, no event-handler surface -- so no security weight added.
'low','high','optimum',
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
// comment on the SVG tag list). Covers the fixture's <svg viewBox
// role>/<rect>/<circle>/<text> block plus the common presentation
// attributes for the shapes/gradients allowed above. Deliberately
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
// legitimate to attach to) and the SMIL/animation attributes (begin,
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
// for the same reason on* handlers are excluded.
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
'points','d','fill','stroke','stroke-width','stroke-linecap',
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
'font-family','font-size','font-weight','transform','offset',
'stop-color','stop-opacity','gradientunits','gradienttransform',
'preserveaspectratio',
], ],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, // Review fix (Task 24 follow-up): the data:image arm used to sit inside
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'], // the group that gets a trailing `:` appended for every alternative
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
// a SECOND colon after the one already in "base64,figure" -- no real
// data URI has that, so the clause could never match. It is now its own
// top-level alternative. NOTE: this regex is not the only thing gating
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
// (img/video/audio/source/image/track) that accepts ANY data: URI on
// those tag/attribute pairs regardless of declared mimetype, bypassing
// this regex entirely. See HtmlBlock.security.test.ts for a regression
// test documenting that (acceptable: none of those tags execute their
// src as a document in mainstream browsers, and <iframe> -- which would
// be dangerous -- is correctly not in that DOMPurify list).
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
// they are now deliberately allowed above. script/object/embed/link/meta
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
// comment above and scopeStyleBlocks() below; it survives sanitisation
// here but its CSS gets scoped afterwards, including copies nested inside
// the newly-allowed inline <svg> (querySelectorAll('style') in
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
FORBID_TAGS: ['script','object','embed','link','meta'],
FORBID_ATTR: [/^on/i], FORBID_ATTR: [/^on/i],
// NOTE: FORCE_BODY is deliberately NOT set here -- see
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
// call, only when the input actually has a real <style> tag to rescue.
}; };
// Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
// HTML document via DOMParser and only serializes <body>'s contents. Per
// the HTML5 parsing algorithm, a tag that can only legally appear in
// <head> -- and now that <style> is allowed, that includes <style> --
// gets implicitly placed in <head> when it appears before any other real
// content, and is silently lost (DOMPurify never looks at <head>). A block
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
// plausible paste, style-before-markup is a common snippet shape -- would
// vanish with no error anywhere, despite <style> sitting right there in
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
// the parser is already in body-insertion-mode by the time it reaches the
// customer's first tag, keeping a leading <style> (or anything else) in
// <body> where DOMPurify's body-only serialization actually looks.
// Confirmed empirically against dompurify+jsdom directly (not just this
// app's behavior) -- see the "leading <style> with nothing before it" test
// in HtmlBlock.test.ts.
//
// Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
// that has no <style> tag at all. It also changes how the HTML parser
// treats character content sitting between a LEADING comment and the next
// real tag -- normal parsing (before <body> is established) silently drops
// pure-whitespace text runs there per the HTML5 "before head" insertion
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
// first token) preserves that whitespace as a real text node. Concretely:
// a block starting with a multi-line HTML comment -- this repo's own
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
// preserved newline) once FORCE_BODY was unconditionally on, which
// silently broke the "blocks without <style> are byte-identical to
// pre-Task-25 output" guarantee (confirmed with a raw diff against
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
// the one and only case that needs it -- so every other input takes
// exactly the pre-Task-25 code path, unchanged.
//
// "Real" deliberately excludes a `<style` substring that only appears
// inside an HTML comment (e.g. a customer's own code-sample text
// mentioning `<style>`) -- that text can never become an actual <style>
// element, but naively substring-matching it would still flip FORCE_BODY
// on and reintroduce the exact same whitespace-preservation side effect
// for a block that never had, and never needed, real style scoping.
const STYLE_TAG_RE = /<style[\s>/]/i;
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
function needsForceBody(input: string): boolean {
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
}
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case) // M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/ // but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
// phishing vector (DOMPurify already strips <script>/on*=, but an // phishing vector (DOMPurify already strips <script>/on*=, but an
@@ -48,6 +229,120 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
} }
}; };
/**
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
* DOMPurify output) so their CSS only matches inside this block's own
* wrapper element, then wrap the whole thing in that wrapper.
*
* Deliberately does the LEAST work possible when there's nothing to scope:
* a cheap substring check bails out before touching the DOM at all, so a
* block that doesn't use <style> -- i.e. every block saved before this task
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
* no re-serialization round-trip that could subtly reformat attributes).
* That byte-for-byte identity is a hard requirement: published pages
* already contain `toHtml()` output with NO wrapper element, and adding one
* unconditionally would silently change the DOM/box-model of every
* existing customer block. See HtmlBlock.test.ts's
* "blocks without <style> are byte-identical" tests, which run this
* against real fixture content and diff the exact string.
*
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
* the existing djb2 hash from utils/escape.ts (already used for this exact
* class of problem, see `scopeId` in that file), applied to `rawCode` --
* the block's own `code` prop, nothing else. Pure function of the block's
* own content: no Math.random, no Date.now, no counter, and deliberately
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
* depends on anything outside `code` would make the editor canvas preview
* (which calls purifyHtml(code) on render) and the published output (which
* calls the same purifyHtml(code) at publish time) diverge whenever that
* outside thing differs between the two call sites, and would make the
* stored HTML churn on every save even when the block's own content didn't
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
* on its own -- same code in, byte-identical output out, every time, in
* both places it's called.
*/
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
/**
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
* reachable-with-its-own-output through any CURRENT code path, but nothing
* stops a customer from pasting previously-published or exported HTML from
* this exact feature into a fresh Custom HTML block -- at which point
* `code` already contains our own `<div class="whp-html-OLD">...<style>
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
* to recognise the embedded selectors as already scoped (they're prefixed
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
* checks against), and nest a second wrapper div around the first while
* re-prefixing every selector under the new class on top of the old one.
*
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
* wrappers": a single root element, a <div>, whose class matches our own
* naming convention, and whose `<style>` descendant(s) are each already a
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
* change nothing. That last check reuses `scopeCss`'s own idempotency
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
* scope-css.test.ts) rather than re-implementing "is this CSS already
* scoped" as a second parser: if scoping again under the div's own class
* is a no-op, the CSS is already confined to that div, regardless of
* whether this app was the one that put it there -- which is the actual
* safety property this function exists to guarantee, not merely a proxy
* for it.
*/
function isAlreadyScoped(container: HTMLElement): boolean {
if (container.children.length !== 1) return false;
const root = container.children[0];
if (root.tagName !== 'DIV') return false;
const cls = root.getAttribute('class') || '';
if (!SCOPE_CLASS_RE.test(cls)) return false;
const scopeSelector = `.${cls}`;
const styleEls = Array.from(root.querySelectorAll('style'));
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
return styleEls.every((el) => {
const text = el.textContent || '';
if (text.trim() === '') return true;
return scopeCss(text, scopeSelector) === text;
});
}
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
if (!sanitized.includes('<style')) return sanitized;
const container = document.createElement('div');
container.innerHTML = sanitized;
if (isAlreadyScoped(container)) return sanitized;
const styleEls = Array.from(container.querySelectorAll('style'));
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
if (nonEmpty.length === 0) return sanitized;
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
// could deliberately craft a second block's `code` to collide onto the
// same `whp-html-<hash>` class as an existing block on the same page, at
// which point the two blocks' <style> rules apply to (and override) each
// other, since they'd share one wrapper class. Impact is CSS-only --
// visual breakage, never script execution or data exposure -- the same
// trust tier as other accepted risks in this file (e.g. remote url() in
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
// would mean either a wider hash (cheap, but every existing scope class
// set with THIS Task 25 code would silently reshuffle -- a similar
// "changing the hash function reshuffles stored HTML" cost the pinned
// hash test above already guards against happening BY ACCIDENT) or a
// collision-checked/salted scheme, either of which is a bigger design
// decision than a follow-up-review fix.
const scopeClass = `whp-html-${stableHash(rawCode)}`;
for (const el of nonEmpty) {
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
}
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
}
export function purifyHtml(input: string): string { export function purifyHtml(input: string): string {
// Hook is added immediately before sanitize() and removed immediately // Hook is added immediately before sanitize() and removed immediately
// after, scoped tightly to this single call -- so it can never leak onto // after, scoped tightly to this single call -- so it can never leak onto
@@ -56,22 +351,33 @@ export function purifyHtml(input: string): string {
// multiple copies of the same hook. // multiple copies of the same hook.
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK); DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
try { try {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string; const raw = input || '';
// See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
// applied only when there's a real <style> tag to rescue, so every
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
return scopeStyleBlocks(sanitized, raw);
} finally { } finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any); DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
} }
} }
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => { export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected })); const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
const clean = useMemo(() => purifyHtml(code), [code]); const clean = useMemo(() => purifyHtml(code), [code]);
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }; const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
// purified `code`, so anything styled here would show in the editor and
// vanish on the live page -- the exact bug this block was reported for.
// Wrapper styling belongs in the user's own markup (see the Edit HTML
// toolbar's colour control). `style` stays on the props interface so
// already-saved sites keep deserializing cleanly.
return React.createElement('div', { return React.createElement('div', {
ref: setRef, ref: setRef,
style: { style: {
minHeight: '40px', minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none', outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}, },
dangerouslySetInnerHTML: { __html: clean }, dangerouslySetInnerHTML: { __html: clean },
}); });
@@ -43,6 +43,29 @@ describe('Logo.toHtml image src/alt sanitization (type="image")', () => {
}); });
}); });
describe('Logo.toHtml download attribute (F3: link-to-file toggle)', () => {
test('download:true emits the download attribute', () => {
const { html } = toHtml({ href: '/resume.pdf', download: true, text: 'Resume' }, '');
expect(html).toMatch(/<a href="\/resume\.pdf" download/);
});
test('no download prop -> no download attribute emitted', () => {
const { html } = toHtml({ href: '/', text: 'MySite' }, '');
expect(html).not.toContain('download');
});
});
describe('Logo (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Logo as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Logo.toHtml text-logo styling sanitization', () => { describe('Logo.toHtml text-logo styling sanitization', () => {
test('a quote-breakout color does not escape the span style attribute', () => { test('a quote-breakout color does not escape the span style attribute', () => {
const malicious = 'red" onmouseover="alert(1)'; const malicious = 'red" onmouseover="alert(1)';
+17 -1
View File
@@ -12,11 +12,18 @@ interface LogoProps {
imageSrc?: string; imageSrc?: string;
imageWidth?: string; imageWidth?: string;
href?: string; href?: string;
/** Adds the `download` attribute to the exported anchor (F3: link to a file). */
download?: boolean;
fontFamily?: string; fontFamily?: string;
fontSize?: string; fontSize?: string;
fontWeight?: string; fontWeight?: string;
color?: string; color?: string;
style?: CSSProperties; style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Component ---------- */ /* ---------- Component ---------- */
@@ -27,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
imageSrc = '', imageSrc = '',
imageWidth = '120px', imageWidth = '120px',
href = '/', href = '/',
download = false,
fontFamily = 'Inter, sans-serif', fontFamily = 'Inter, sans-serif',
fontSize = '20px', fontSize = '20px',
fontWeight = '700', fontWeight = '700',
@@ -44,6 +52,7 @@ export const Logo: UserComponent<LogoProps> = ({
<a <a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href} href={href}
download={download || undefined}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
style={{ style={{
textDecoration: 'none', textDecoration: 'none',
@@ -87,7 +96,13 @@ Logo.craft = {
fontSize: '20px', fontSize: '20px',
fontWeight: '700', fontWeight: '700',
color: undefined, color: undefined,
download: false,
style: {}, style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as LogoProps, } as LogoProps,
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -122,8 +137,9 @@ Logo.craft = {
flexShrink: '0', flexShrink: '0',
...props.style, ...props.style,
}); });
const downloadAttr = props.download ? ' download' : '';
return { return {
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`, html: `<a href="${escapeAttr(safeUrl(href))}"${downloadAttr}${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
}; };
}; };
@@ -31,6 +31,29 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
}); });
}); });
describe('Menu.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Menu (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Menu as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => { describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
test('a linkHoverColor value containing </style><script> is neutralized', () => { test('a linkHoverColor value containing </style><script> is neutralized', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{'; const malicious = '#fff}</style><script>alert(1)</script><style>{';
+15 -1
View File
@@ -10,6 +10,8 @@ interface MenuLink {
href: string; href: string;
isExternal?: boolean; isExternal?: boolean;
isCta?: boolean; isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
} }
interface MenuProps { interface MenuProps {
@@ -23,6 +25,11 @@ interface MenuProps {
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
fontSize?: string; fontSize?: string;
style?: CSSProperties; style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Defaults ---------- */ /* ---------- Defaults ---------- */
@@ -75,6 +82,7 @@ export const Menu: UserComponent<MenuProps> = ({
href={link.href} href={link.href}
target={link.isExternal ? '_blank' : undefined} target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined} rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)} onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)} onMouseLeave={() => setHoveredLink(null)}
@@ -114,6 +122,11 @@ Menu.craft = {
orientation: 'horizontal', orientation: 'horizontal',
fontSize: '14px', fontSize: '14px',
style: {}, style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as MenuProps, } as MenuProps,
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -162,6 +175,7 @@ Menu.craft = {
const linksHtml = links.map((link) => { const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`; const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
const linkStyle = cssPropsToString({ const linkStyle = cssPropsToString({
textDecoration: 'none', textDecoration: 'none',
@@ -173,7 +187,7 @@ Menu.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
const hoverCss = `<style> const hoverCss = `<style>
@@ -83,6 +83,29 @@ describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not
}); });
}); });
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Navbar as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => { describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => { test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{'; const malicious = '#fff}</style><script>alert(1)</script><style>{';
+15 -1
View File
@@ -11,6 +11,8 @@ interface NavLink {
href: string; href: string;
isExternal?: boolean; isExternal?: boolean;
isCta?: boolean; isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
} }
interface NavbarProps { interface NavbarProps {
@@ -33,6 +35,11 @@ interface NavbarProps {
isSticky?: boolean; isSticky?: boolean;
showMobileMenu?: boolean; showMobileMenu?: boolean;
style?: CSSProperties; style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Defaults ---------- */ /* ---------- Defaults ---------- */
@@ -149,6 +156,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
href={link.href} href={link.href}
target={link.isExternal ? '_blank' : undefined} target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined} rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)} onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)} onMouseLeave={() => setHoveredLink(null)}
@@ -200,6 +208,11 @@ Navbar.craft = {
style: { style: {
borderBottom: '1px solid #e4e4e7', borderBottom: '1px solid #e4e4e7',
}, },
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as NavbarProps, } as NavbarProps,
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -308,6 +321,7 @@ Navbar.craft = {
// Add CSS class to each link for hover // Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => { const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link'; const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({ const linkStyle = cssPropsToString({
textDecoration: 'none', textDecoration: 'none',
@@ -319,7 +333,7 @@ Navbar.craft = {
borderRadius: link.isCta ? '6px' : '0', borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s', transition: 'color 0.15s, background-color 0.15s',
}); });
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`; return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n '); }).join('\n ');
return { return {
@@ -30,3 +30,46 @@ describe('SearchBar.toHtml XSS hardening (placeholder/buttonText/showButton)', (
expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/); expect(html).toMatch(/border-radius:(8px 0 0 8px|8px)/);
}); });
}); });
// F2: SearchBar was purely decorative -- no action/method/input name, so
// submitting did nothing. It now emits a real GET form.
describe('SearchBar.toHtml is a functional GET search form (not decorative)', () => {
test('defaults to a GET form action="/" with the query input named "q"', () => {
const { html } = toHtml({}, '');
expect(html).toMatch(/<form role="search" action="\/" method="GET"/);
expect(html).toContain('<input type="search" name="q"');
});
test('a configured action (real search-results page) is used verbatim', () => {
const { html } = toHtml({ action: '/search' }, '');
expect(html).toContain('action="/search"');
});
test('a javascript: action is blocked via safeUrl and falls back to "/"', () => {
const { html } = toHtml({ action: 'javascript:alert(1)' }, '');
expect(html).toContain('action="/"');
expect(html).not.toContain('javascript:');
});
test('an empty/whitespace action falls back to "/"', () => {
const { html } = toHtml({ action: ' ' }, '');
expect(html).toContain('action="/"');
});
});
describe('SearchBar.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ style: { marginBottom: '14px', border: '1px solid #aaa', boxShadow: '0 1px 4px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
expect(html).toContain('margin-bottom:14px');
expect(html).toContain('border:1px solid #aaa');
expect(html).toContain('opacity:0.9');
});
});
describe('SearchBar.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(SearchBar.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+31 -3
View File
@@ -1,19 +1,29 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface SearchBarProps { interface SearchBarProps {
placeholder?: string; placeholder?: string;
buttonText?: string; buttonText?: string;
showButton?: boolean; showButton?: boolean;
/** Where the search GET request is submitted -- a real search-results page
* if the site has one, or '/' (site root) by default. The query is sent
* as `?q=...`, the conventional param name search-results pages look for. */
action?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const SearchBar: UserComponent<SearchBarProps> = ({ export const SearchBar: UserComponent<SearchBarProps> = ({
placeholder = 'Search...', placeholder = 'Search...',
buttonText = 'Search', buttonText = 'Search',
showButton = true, showButton = true,
action = '/',
style = {}, style = {},
}) => { }) => {
const { const {
@@ -27,6 +37,8 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
<form <form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
role="search" role="search"
action={action}
method="GET"
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
style={{ style={{
display: 'flex', display: 'flex',
@@ -51,6 +63,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
/> />
<input <input
type="search" type="search"
name="q"
placeholder={placeholder} placeholder={placeholder}
style={{ style={{
width: '100%', width: '100%',
@@ -101,7 +114,13 @@ SearchBar.craft = {
placeholder: 'Search...', placeholder: 'Search...',
buttonText: 'Search', buttonText: 'Search',
showButton: true, showButton: true,
action: '/',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -117,6 +136,7 @@ SearchBar.craft = {
placeholder = 'Search...', placeholder = 'Search...',
buttonText = 'Search', buttonText = 'Search',
showButton = true, showButton = true,
action = '/',
style = {}, style = {},
} = props; } = props;
@@ -133,11 +153,19 @@ SearchBar.craft = {
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>` ? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: ''; : '';
// F2: previously a purely decorative <form> -- no action/method/input
// name at all, so submitting did nothing. A real GET to `action` with the
// query in the conventional `q` param makes this a functioning search
// form on publish (routes to a real search-results page if the site has
// one, or reloads '/' with ?q=... by default). `safeUrl` blocks
// javascript:/vbscript:/data:text/html breakout via the action attribute.
const actionAttr = escapeAttr(safeUrl(action) || '/');
return { return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}> html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1"> <div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i> <i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" /> <input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div> </div>
${btnHtml} ${btnHtml}
</form>`, </form>`,
@@ -20,3 +20,48 @@ describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', ()
expect(html).toBe('<p>Hello world</p>'); expect(html).toBe('<p>Hello world</p>');
}); });
}); });
describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
const { html } = toHtml({
text: 'x',
style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' },
}, '');
expect(html).toContain('line-height:1.75');
expect(html).toContain('letter-spacing:-0.02em');
expect(html).toContain('text-transform:capitalize');
});
test('italic + underline toggles emit font-style and text-decoration', () => {
const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, '');
expect(html).toContain('font-style:italic');
expect(html).toContain('text-decoration:underline');
});
test('a custom font-size (not one of the presets) still flows through', () => {
const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, '');
expect(html).toContain('font-size:19px');
});
});
describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (TextBlock as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + typography-depth keys', () => {
const style = (TextBlock as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style).toHaveProperty('letterSpacing');
expect(style).toHaveProperty('textTransform');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+14
View File
@@ -83,7 +83,21 @@ TextBlock.craft = {
fontSize: '16px', fontSize: '16px',
lineHeight: '1.6', lineHeight: '1.6',
color: '#3f3f46', color: '#3f3f46',
letterSpacing: '',
textTransform: '' as CSSProperties['textTransform'],
fontStyle: '' as CSSProperties['fontStyle'],
textDecoration: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
}, },
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -0,0 +1,426 @@
<!-- ============================================================
HTML test fixture — everything below goes inside <body>
Unstyled on purpose. No external assets (SVG/data URIs only)
except the media/iframe block, which is intentionally broken
so you can see fallback behavior.
============================================================ -->
<a href="#main">Skip to content</a>
<header>
<h1>HTML Test Fixture</h1>
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
<nav aria-label="Primary">
<ul>
<li><a href="#text">Text</a></li>
<li><a href="#lists">Lists</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#media">Media</a></li>
<li><a href="#edge">Edge cases</a></li>
</ul>
</nav>
</header>
<main id="main">
<!-- ========== HEADINGS ========== -->
<section id="headings">
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<hgroup>
<h2>Grouped heading</h2>
<p>Subtitle paragraph inside hgroup</p>
</hgroup>
</section>
<hr>
<!-- ========== TEXT & INLINE ========== -->
<section id="text">
<h2>Text and inline elements</h2>
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
<p>
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
<samp>output text</samp>, <var>variable</var>,
<abbr title="HyperText Markup Language">HTML</abbr>,
<dfn>definition term</dfn>,
<time datetime="2026-08-09">August 9, 2026</time>,
<data value="42">forty-two</data>,
<q>short inline quote</q>,
<cite>Cited Work</cite>,
<bdi>إسم</bdi>,
<bdo dir="rtl">reversed direction</bdo>,
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
</p>
<p>
Links:
<a href="#top">internal anchor</a> ·
<a href="https://example.com">absolute</a> ·
<a href="/relative/path">relative</a> ·
<a href="mailto:test@example.com">mailto</a> ·
<a href="tel:+15555550123">tel</a> ·
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
<a href="#" download>download attr</a>
</p>
<blockquote cite="https://example.com/source">
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
<blockquote><p>Nested block quotation.</p></blockquote>
<footer>— <cite>Someone, Somewhere</cite></footer>
</blockquote>
<pre><code>#!/usr/bin/env bash
set -euo pipefail
for i in {1..3}; do
printf 'iteration %d\n' "$i"
done
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
</code></pre>
<p>Line break here,<br>after the break.</p>
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
<address>
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
123 Nowhere St, Somewhere
</address>
<p>Entities: &amp; &lt; &gt; &quot; &apos; &copy; &reg; &trade; &nbsp; &mdash; &hellip; &#8364; &#x1F600;</p>
</section>
<hr>
<!-- ========== LISTS ========== -->
<section id="lists">
<h2>Lists</h2>
<h3>Unordered, nested</h3>
<ul>
<li>First item</li>
<li>Second item
<ul>
<li>Nested item
<ul><li>Deeply nested item</li></ul>
</li>
<li>Another nested item</li>
</ul>
</li>
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
</ul>
<h3>Ordered variants</h3>
<ol>
<li>Default numbering</li>
<li>Second
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
</li>
</ol>
<ol start="5" reversed>
<li>Reversed, starting at 5</li>
<li>Next</li>
<li>Next</li>
</ol>
<h3>Description list</h3>
<dl>
<dt>Term one</dt>
<dd>Definition of the first term.</dd>
<dt>Term two</dt>
<dt>Term two, alias</dt>
<dd>Definition covering both terms above.</dd>
</dl>
<h3>Menu</h3>
<menu>
<li><button type="button">Copy</button></li>
<li><button type="button">Paste</button></li>
</menu>
</section>
<hr>
<!-- ========== TABLES ========== -->
<section id="tables">
<h2>Tables</h2>
<table>
<caption>Quarterly figures with spans and a footer</caption>
<colgroup>
<col span="1">
<col span="2">
<col>
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>1,391</td>
<td rowspan="2">Shared note spanning two rows</td>
</tr>
<tr>
<th scope="row">South</th>
<td>988</td>
<td>1,022</td>
</tr>
<tr>
<th scope="row">East</th>
<td colspan="2">Merged across two quarters</td>
<td>—</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>2,192</td>
<td>2,413</td>
<td></td>
</tr>
</tfoot>
</table>
<h3>Wide table (horizontal overflow)</h3>
<table>
<tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
</table>
</section>
<hr>
<!-- ========== FORMS ========== -->
<section id="forms">
<h2>Forms</h2>
<form action="#" method="get">
<fieldset>
<legend>Text inputs</legend>
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
<datalist id="suggestions">
<option value="alpha"></option>
<option value="beta"></option>
<option value="gamma"></option>
</datalist>
<p><label for="f-email">Email</label> <input id="f-email" type="email" required></p>
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
content
here</textarea></p>
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly></p>
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled></p>
</fieldset>
<fieldset>
<legend>Date, time, color, range, file</legend>
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple accept=".txt,.md"></p>
</fieldset>
<fieldset>
<legend>Choices</legend>
<p>
<label><input type="checkbox" name="c" value="1" checked> Checked</label>
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
<label><input type="checkbox" name="c" value="3" disabled> Disabled</label>
</p>
<p>
<label><input type="radio" name="r" value="a" checked> Option A</label>
<label><input type="radio" name="r" value="b"> Option B</label>
</p>
<p>
<label for="f-select">Select</label>
<select id="f-select" name="select">
<option value="">— choose —</option>
<optgroup label="Group one">
<option value="1" selected>One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="Group two" disabled>
<option value="3">Three</option>
</optgroup>
</select>
</p>
<p>
<label for="f-multi">Multi-select</label><br>
<select id="f-multi" multiple size="4">
<option>Red</option><option selected>Green</option><option>Blue</option><option>Violet</option>
</select>
</p>
</fieldset>
<fieldset>
<legend>Output and buttons</legend>
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
<p><output name="result" for="f-num f-range">Computed output</output></p>
<p>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
<button type="button">Plain button</button>
<button type="button" disabled>Disabled button</button>
<input type="submit" value="Input submit">
<input type="button" value="Input button">
</p>
<input type="hidden" name="csrf" value="hidden-value">
</fieldset>
</form>
</section>
<hr>
<!-- ========== MEDIA & EMBEDS ========== -->
<section id="media">
<h2>Media and embeds</h2>
<h3>Inline SVG</h3>
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
</svg>
<h3>Figure with data-URI image</h3>
<figure>
<img alt="Small red square"
width="64" height="64"
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
<figcaption>Figure caption describing the image above.</figcaption>
</figure>
<h3>Broken image (alt-text fallback test)</h3>
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
<h3>Picture element</h3>
<picture>
<source media="(min-width: 800px)" srcset="wide.png">
<source media="(min-width: 400px)" srcset="medium.png">
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
</picture>
<h3>Video and audio (sources intentionally missing)</h3>
<video controls width="320" poster="poster.jpg">
<source src="clip.webm" type="video/webm">
<source src="clip.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
Your browser does not support the video element.
</video>
<audio controls>
<source src="tone.ogg" type="audio/ogg">
<source src="tone.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<h3>Canvas and iframe</h3>
<canvas width="200" height="60">Canvas fallback text</canvas>
<iframe title="Sandboxed iframe" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
</section>
<hr>
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
<section id="interactive">
<h2>Interactive and semantic containers</h2>
<details>
<summary>Collapsed disclosure</summary>
<p>Hidden content revealed on toggle.</p>
</details>
<details open>
<summary>Open disclosure</summary>
<ul><li>With a list inside</li><li>Second item</li></ul>
</details>
<dialog id="test-dialog">
<p>Non-modal dialog content.</p>
<button type="button" onclick="this.closest('dialog').close()">Close</button>
</dialog>
<button type="button" onclick="document.getElementById('test-dialog').show()">Open dialog</button>
<article>
<header><h3>Article header</h3></header>
<p>Article body content.</p>
<aside><p>An aside nested inside the article.</p></aside>
<footer><p>Article footer.</p></footer>
</article>
<p><span contenteditable="true">Editable inline region</span></p>
<p hidden>This paragraph has the hidden attribute and should not render.</p>
<template id="tpl">
<p>Template content — must not render until cloned.</p>
</template>
</section>
<hr>
<!-- ========== EDGE CASES ========== -->
<section id="edge">
<h2>Edge cases</h2>
<p>Very long unbroken token (overflow test):</p>
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&amp;another=2&amp;third=3#fragment-identifier</p>
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
<p>Emoji &amp; combining: 👋🏽 👨‍👩‍👧‍👦 🇺🇸 é vs é (precomposed vs combining)</p>
<p>Zero-width chars between letters: a&#8203;b&#8203;c</p>
<p>Escaped tag text: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>Attribute with quotes: <span title='He said "hello"'>hover me</span></p>
<p>Empty elements follow:</p>
<div></div>
<p></p>
<ul></ul>
<table></table>
<p>Deep nesting:</p>
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
<p>Inline element stress:
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
</p>
<p style="color: teal;">Inline style attribute (teal).</p>
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
</section>
</main>
<footer>
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
</footer>
@@ -0,0 +1,415 @@
<a href="#main">Skip to content</a>
<header>
<h1>HTML Test Fixture</h1>
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
<nav aria-label="Primary">
<ul>
<li><a href="#text">Text</a></li>
<li><a href="#lists">Lists</a></li>
<li><a href="#tables">Tables</a></li>
<li><a href="#forms">Forms</a></li>
<li><a href="#media">Media</a></li>
<li><a href="#edge">Edge cases</a></li>
</ul>
</nav>
</header>
<main id="main">
<section id="headings">
<h2>Headings</h2>
<h1>Heading level 1</h1>
<h2>Heading level 2</h2>
<h3>Heading level 3</h3>
<h4>Heading level 4</h4>
<h5>Heading level 5</h5>
<h6>Heading level 6</h6>
<hgroup>
<h2>Grouped heading</h2>
<p>Subtitle paragraph inside hgroup</p>
</hgroup>
</section>
<hr>
<section id="text">
<h2>Text and inline elements</h2>
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
<p>
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
<samp>output text</samp>, <var>variable</var>,
<abbr title="HyperText Markup Language">HTML</abbr>,
<dfn>definition term</dfn>,
<time datetime="2026-08-09">August 9, 2026</time>,
<data value="42">forty-two</data>,
<q>short inline quote</q>,
<cite>Cited Work</cite>,
<bdi>إسم</bdi>,
<bdo dir="rtl">reversed direction</bdo>,
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
</p>
<p>
Links:
<a href="#top">internal anchor</a> ·
<a href="https://example.com">absolute</a> ·
<a href="/relative/path">relative</a> ·
<a href="mailto:test@example.com">mailto</a> ·
<a href="tel:+15555550123">tel</a> ·
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
<a href="#" download="">download attr</a>
</p>
<blockquote cite="https://example.com/source">
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
<blockquote><p>Nested block quotation.</p></blockquote>
<footer>— <cite>Someone, Somewhere</cite></footer>
</blockquote>
<pre><code>#!/usr/bin/env bash
set -euo pipefail
for i in {1..3}; do
printf 'iteration %d\n' "$i"
done
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
</code></pre>
<p>Line break here,<br>after the break.</p>
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
<address>
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
123 Nowhere St, Somewhere
</address>
<p>Entities: &amp; &lt; &gt; " ' © ® ™ &nbsp; — … € 😀</p>
</section>
<hr>
<section id="lists">
<h2>Lists</h2>
<h3>Unordered, nested</h3>
<ul>
<li>First item</li>
<li>Second item
<ul>
<li>Nested item
<ul><li>Deeply nested item</li></ul>
</li>
<li>Another nested item</li>
</ul>
</li>
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
</ul>
<h3>Ordered variants</h3>
<ol>
<li>Default numbering</li>
<li>Second
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
</li>
</ol>
<ol start="5" reversed="">
<li>Reversed, starting at 5</li>
<li>Next</li>
<li>Next</li>
</ol>
<h3>Description list</h3>
<dl>
<dt>Term one</dt>
<dd>Definition of the first term.</dd>
<dt>Term two</dt>
<dt>Term two, alias</dt>
<dd>Definition covering both terms above.</dd>
</dl>
<h3>Menu</h3>
<menu>
<li><button type="button">Copy</button></li>
<li><button type="button">Paste</button></li>
</menu>
</section>
<hr>
<section id="tables">
<h2>Tables</h2>
<table>
<caption>Quarterly figures with spans and a footer</caption>
<colgroup>
<col span="1">
<col span="2">
<col>
</colgroup>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>1,204</td>
<td>1,391</td>
<td rowspan="2">Shared note spanning two rows</td>
</tr>
<tr>
<th scope="row">South</th>
<td>988</td>
<td>1,022</td>
</tr>
<tr>
<th scope="row">East</th>
<td colspan="2">Merged across two quarters</td>
<td>—</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>2,192</td>
<td>2,413</td>
<td></td>
</tr>
</tfoot>
</table>
<h3>Wide table (horizontal overflow)</h3>
<table>
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
</tbody></table>
</section>
<hr>
<section>
<h2>Forms</h2>
<form action="#" method="get">
<fieldset>
<legend>Text inputs</legend>
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
<datalist id="suggestions">
<option value="alpha"></option>
<option value="beta"></option>
<option value="gamma"></option>
</datalist>
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
content
here</textarea></p>
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
</fieldset>
<fieldset>
<legend>Date, time, color, range, file</legend>
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
</fieldset>
<fieldset>
<legend>Choices</legend>
<p>
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
</p>
<p>
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
<label><input type="radio" name="r" value="b"> Option B</label>
</p>
<p>
<label for="f-select">Select</label>
<select id="f-select" name="select">
<option value="">— choose —</option>
<optgroup label="Group one">
<option value="1" selected="">One</option>
<option value="2">Two</option>
</optgroup>
<optgroup label="Group two" disabled="">
<option value="3">Three</option>
</optgroup>
</select>
</p>
<p>
<label for="f-multi">Multi-select</label><br>
<select id="f-multi" multiple="" size="4">
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
</select>
</p>
</fieldset>
<fieldset>
<legend>Output and buttons</legend>
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
<p><output name="result" for="f-num f-range">Computed output</output></p>
<p>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
<button type="button">Plain button</button>
<button type="button" disabled="">Disabled button</button>
<input type="submit" value="Input submit">
<input type="button" value="Input button">
</p>
<input type="hidden" name="csrf" value="hidden-value">
</fieldset>
</form>
</section>
<hr>
<section id="media">
<h2>Media and embeds</h2>
<h3>Inline SVG</h3>
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
</svg>
<h3>Figure with data-URI image</h3>
<figure>
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
<figcaption>Figure caption describing the image above.</figcaption>
</figure>
<h3>Broken image (alt-text fallback test)</h3>
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
<h3>Picture element</h3>
<picture>
<source media="(min-width: 800px)" srcset="wide.png">
<source media="(min-width: 400px)" srcset="medium.png">
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
</picture>
<h3>Video and audio (sources intentionally missing)</h3>
<video controls="" width="320" poster="poster.jpg">
<source src="clip.webm" type="video/webm">
<source src="clip.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
Your browser does not support the video element.
</video>
<audio controls="">
<source src="tone.ogg" type="audio/ogg">
<source src="tone.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
<h3>Canvas and iframe</h3>
<canvas width="200" height="60">Canvas fallback text</canvas>
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
</section>
<hr>
<section id="interactive">
<h2>Interactive and semantic containers</h2>
<details>
<summary>Collapsed disclosure</summary>
<p>Hidden content revealed on toggle.</p>
</details>
<details open="">
<summary>Open disclosure</summary>
<ul><li>With a list inside</li><li>Second item</li></ul>
</details>
<p>Non-modal dialog content.</p>
<button type="button">Close</button>
<button type="button">Open dialog</button>
<article>
<header><h3>Article header</h3></header>
<p>Article body content.</p>
<aside><p>An aside nested inside the article.</p></aside>
<footer><p>Article footer.</p></footer>
</article>
<p><span contenteditable="true">Editable inline region</span></p>
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
</section>
<hr>
<section id="edge">
<h2>Edge cases</h2>
<p>Very long unbroken token (overflow test):</p>
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&amp;another=2&amp;third=3#fragment-identifier</p>
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
<p>Emoji &amp; combining: 👋🏽 👨‍👩‍👧‍👦 🇺🇸 é vs é (precomposed vs combining)</p>
<p>Zero-width chars between letters: a​b​c</p>
<p>Escaped tag text: &lt;script&gt;alert(1)&lt;/script&gt;</p>
<p>Attribute with quotes: <span title="He said &quot;hello&quot;">hover me</span></p>
<p>Empty elements follow:</p>
<div></div>
<p></p>
<ul></ul>
<table></table>
<p>Deep nesting:</p>
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
<p>Inline element stress:
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
</p>
<p style="color: teal;">Inline style attribute (teal).</p>
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
</section>
</main>
<footer>
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
</footer>
@@ -126,3 +126,163 @@ describe('ContactForm.toHtml field type attribute sanitization', () => {
expect(html).toContain('type="email"'); expect(html).toContain('type="email"');
}); });
}); });
// F1: the field editor (FormStylePanel) can now create fields of every type
// in sanitizeInputType's allowlist, plus textarea/select. Verify each
// renders with the right control, label/for association, and required flag.
describe('ContactForm.toHtml renders every configured field type/label/required', () => {
const cases: { type: string; tag: string }[] = [
{ type: 'text', tag: 'input' },
{ type: 'email', tag: 'input' },
{ type: 'tel', tag: 'input' },
{ type: 'number', tag: 'input' },
{ type: 'password', tag: 'input' },
{ type: 'url', tag: 'input' },
{ type: 'search', tag: 'input' },
{ type: 'date', tag: 'input' },
{ type: 'checkbox', tag: 'input' },
{ type: 'radio', tag: 'input' },
];
test.each(cases)('type=$type renders a sanitized <$tag type="$type"> with label + for/id wiring', ({ type, tag }) => {
const fields = [{ type: type as any, label: `Field ${type}`, name: `f_${type}`, placeholder: '', required: true }];
const { html } = toHtml({ fields }, '');
expect(html).toContain(`<${tag}`);
expect(html).toContain(`type="${type}"`);
expect(html).toContain(`Field ${type}`);
// required renders the input attribute AND the visual asterisk
expect(html).toMatch(/ required/);
expect(html).toContain('*</span>');
const labelFor = html.match(/<label for="([^"]+)"/)![1];
expect(html).toContain(`id="${labelFor}"`);
});
test('type=textarea renders a <textarea>, not an <input>', () => {
const fields = [{ type: 'textarea' as const, label: 'Message', name: 'message', placeholder: '', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).toMatch(/<textarea[^>]*name="message"/);
expect(html).not.toMatch(/<input[^>]*name="message"/);
});
test('type=select renders a <select> with escaped <option> values from field.options', () => {
const fields = [{ type: 'select' as const, label: 'Plan', name: 'plan', placeholder: 'Choose one', required: false, options: ['Basic', 'Pro', '"><script>alert(1)</script>'] }];
const { html } = toHtml({ fields }, '');
expect(html).toMatch(/<select[^>]*name="plan"/);
expect(html).toContain('<option value="Basic">Basic</option>');
expect(html).toContain('<option value="Pro">Pro</option>');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a non-required field omits both the required attribute and the asterisk', () => {
const fields = [{ type: 'text' as const, label: 'Nickname', name: 'nickname', placeholder: '', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).not.toMatch(/ required/);
expect(html).not.toContain('*</span>');
});
});
// Box-model / animation / visibility rollout (common enh-batch pattern):
// these are top-level props consumed generically by the export's
// buildDataAttrs() -- this just confirms the defaults are present on
// craft.props so the panel controls render and the props survive save/load.
describe('ContactForm.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults for animation, animationDelay, hideOnDesktop/Tablet/Mobile', () => {
expect(ContactForm.craft!.props).toMatchObject({
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
});
});
});
/* ---------- Webhook destination (Task 10) ----------
This file builds props inline rather than spreading a shared object, so
`defaultProps` is introduced here for the destination cases only; every
pre-existing test above is untouched. */
const defaultProps = { fields: [] as any[], formAction: '#' };
describe('ContactForm.craft.props includes the destination defaults', () => {
// The trap this pins: FormStylePanel renders each destination control behind
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
// invisible control and the whole feature looks like it does nothing.
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
expect(ContactForm.craft!.props).toMatchObject({
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
});
});
test('no craft prop holds a raw secret -- only an id', () => {
const keys = Object.keys(ContactForm.craft!.props as object);
expect(keys).toContain('webhookSecretId');
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
});
});
describe('ContactForm.toHtml destination marker', () => {
test('email destination emits the legacy marker unchanged', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
expect(out.html).toContain('<!--WHP-FORM');
expect(out.html).toContain('recipient="a@example.com"');
expect(out.html).not.toContain('type="webhook"');
});
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
// The guarantee every already-published site depends on: a marker with no
// `type` still provisions an email endpoint, so its bytes must not drift by
// so much as a space.
//
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
// to each other only catches a drift that affects ONE of them -- a uniform
// change passes it. This string was captured from `071f3447` (the revision
// deployed to production before this feature) and is the actual reference:
// if it has to be edited, every already-published site's forms have changed
// shape and that is the thing to stop, not the test.
const FROZEN_LEGACY_MARKER =
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
// ...and the new props, set to their defaults, change nothing about it.
const explicit = toHtml(
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
expect(explicit.html).toBe(legacy.html);
});
test('webhook destination emits type, url, secret id and auth mode', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('url="https://hooks.example.com/x"');
expect(out.html).toContain('secret="sec-1"');
expect(out.html).toContain('authmode="bearer"');
expect(out.html).toContain('recipient="fb@example.com"');
});
test('a raw secret value is never emitted, only its id', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
// Non-vacuous: the marker IS emitted (so there is something that could have
// carried the secret) and carries the id, but not the value.
expect(out.html).toContain('secret="sec-1"');
expect(out.html).not.toContain('SUPERSECRET');
});
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
const out = toHtml(
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
expect(out.html).toContain('type="webhook"');
expect(out.html).toContain('recipient=""');
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(out.html).toContain('name="_gotcha"');
});
});
+47 -2
View File
@@ -4,8 +4,17 @@ import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring'; import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape'; import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
// The allowlist enforced at export time lives in `sanitizeInputType`
// (utils/escape.ts) -- this union is a superset (it also covers 'textarea'
// and 'select', which take their own render branches instead of an
// `<input type>`), kept in sync by hand since TS unions can't import a
// runtime array.
export type ContactFormFieldType =
| 'text' | 'email' | 'tel' | 'number' | 'password' | 'url' | 'search' | 'date'
| 'checkbox' | 'radio' | 'textarea' | 'select';
interface ContactFormField { interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select'; type: ContactFormFieldType;
label: string; label: string;
name: string; name: string;
placeholder: string; placeholder: string;
@@ -25,6 +34,21 @@ interface ContactFormProps {
inputBorder?: string; inputBorder?: string;
recipientEmail?: string; recipientEmail?: string;
thankYouUrl?: string; thankYouUrl?: string;
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
with the url / secret id / auth mode below.
There is deliberately NO raw-secret prop: craft props are serialised into
the saved project and into published output, so the secret is POSTed to
/api/form-webhook-secret.php and only the returned opaque id is kept. */
destinationType?: 'email' | 'webhook';
webhookUrl?: string;
webhookSecretId?: string;
webhookAuthMode?: 'signature' | 'bearer';
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultFields: ContactFormField[] = [ const defaultFields: ContactFormField[] = [
@@ -157,6 +181,17 @@ ContactForm.craft = {
inputBorder: '#d1d5db', inputBorder: '#d1d5db',
recipientEmail: '', recipientEmail: '',
thankYouUrl: '', thankYouUrl: '',
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
// gates actually render the destination controls.
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -214,7 +249,17 @@ ContactForm.craft = {
alignSelf: 'flex-start', alignSelf: 'flex-start',
}); });
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId); // Only the webhook SECRET ID travels here -- there is no prop holding the raw
// secret, by construction (see ContactFormProps).
const { marker, actionAttr, honeypot } = relayFormWiring(
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
{
type: props.destinationType,
url: props.webhookUrl,
secretId: props.webhookSecretId,
authMode: props.webhookAuthMode,
},
);
// The form-sender relay delivers success via a full-page 303 redirect // The form-sender relay delivers success via a full-page 303 redirect
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS // (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
@@ -22,4 +22,19 @@ describe('FormButton.toHtml', () => {
expect(html).toContain('&amp;'); expect(html).toContain('&amp;');
expect(html).toContain('&quot;quoted&quot;'); expect(html).toContain('&quot;quoted&quot;');
}); });
test('box-model style (margin/border/box-shadow/opacity) flows through via the style prop', () => {
const { html } = toHtml({ text: 'Submit', style: { marginTop: '12px', border: '2px solid #000', boxShadow: '0 2px 4px rgba(0,0,0,.2)', opacity: '0.8' } }, '');
expect(html).toContain('margin-top:12px');
expect(html).toContain('border:2px solid #000');
expect(html).toContain('opacity:0.8');
});
});
describe('FormButton.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(FormButton.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
}); });
+10
View File
@@ -6,6 +6,11 @@ import { escapeHtml } from '../../utils/escape';
interface FormButtonProps { interface FormButtonProps {
text?: string; text?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const FormButton: UserComponent<FormButtonProps> = ({ export const FormButton: UserComponent<FormButtonProps> = ({
@@ -58,6 +63,11 @@ FormButton.craft = {
fontSize: '16px', fontSize: '16px',
border: 'none', border: 'none',
}, },
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -52,3 +52,20 @@ describe('FormContainer.toHtml method attribute sanitization', () => {
expect(html).toContain('method="GET"'); expect(html).toContain('method="GET"');
}); });
}); });
describe('FormContainer.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ action: '/legacy', style: { marginTop: '20px', border: '3px dashed #ccc', boxShadow: '0 4px 8px rgba(0,0,0,.2)', opacity: '0.95' } }, '');
expect(html).toContain('margin-top:20px');
expect(html).toContain('border:3px dashed #ccc');
expect(html).toContain('opacity:0.95');
});
});
describe('FormContainer.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(FormContainer.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
@@ -12,6 +12,11 @@ interface FormContainerProps {
thankYouUrl?: string; thankYouUrl?: string;
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const FormContainer: UserComponent<FormContainerProps> = ({ export const FormContainer: UserComponent<FormContainerProps> = ({
@@ -59,6 +64,11 @@ FormContainer.craft = {
borderRadius: '8px', borderRadius: '8px',
border: '1px solid #e4e4e7', border: '1px solid #e4e4e7',
}, },
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -72,3 +72,20 @@ describe('InputField.toHtml type attribute sanitization', () => {
expect(html).toContain('type="number"'); expect(html).toContain('type="number"');
}); });
}); });
describe('InputField.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ label: 'Name', name: 'name', style: { marginBottom: '8px', border: '1px solid #333', boxShadow: '0 1px 2px rgba(0,0,0,.1)', opacity: '0.9' } }, '');
expect(html).toContain('margin-bottom:8px');
expect(html).toContain('border:1px solid #333');
expect(html).toContain('opacity:0.9');
});
});
describe('InputField.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(InputField.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+10
View File
@@ -10,6 +10,11 @@ interface InputFieldProps {
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const InputField: UserComponent<InputFieldProps> = ({ export const InputField: UserComponent<InputFieldProps> = ({
@@ -77,6 +82,11 @@ InputField.craft = {
placeholder: 'Enter your name', placeholder: 'Enter your name',
required: false, required: false,
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -6,7 +6,7 @@ const toHtml = (SubscribeForm as any).toHtml;
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => { describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
test('form method is always POST regardless of any injected props', () => { test('form method is always POST regardless of any injected props', () => {
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, ''); const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
expect(html).toContain('<form method="POST"'); expect(html).toMatch(/<form action="[^"]*" method="POST"/);
expect(html).not.toContain('<script'); expect(html).not.toContain('<script');
}); });
@@ -37,3 +37,45 @@ describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop
expect(html).toContain('>Go<'); expect(html).toContain('>Go<');
}); });
}); });
// F1: SubscribeForm previously emitted `<form method="POST">` with no action
// at all -- a published subscribe form silently did nothing on submit.
// Wired through the same relay contract as ContactForm/FormContainer
// (utils/form-relay-wiring.ts) so setting a recipient makes it functional.
describe('SubscribeForm.toHtml is functional (not a dead POST)', () => {
test('without a recipient: still has a real (non-empty) action -- "#" fallback, not a bare method="POST"', () => {
const { html } = toHtml({}, '');
expect(html).toMatch(/<form action="#" method="POST"/);
});
test('with recipientEmail: emits the relay marker, placeholder action, and honeypot -- a working submission path', () => {
const { html } = toHtml({ recipientEmail: 'news@example.com', thankYouUrl: '/thanks' }, '', 'node-sub1');
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="news@example.com" thankyou="\/thanks"-->/);
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
expect(html).toContain('name="_gotcha"');
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('the email input keeps its name="email" so the relay receives it', () => {
const { html } = toHtml({ recipientEmail: 'news@example.com' }, '', 'node-sub2');
expect(html).toContain('name="email"');
});
});
describe('SubscribeForm.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ style: { marginTop: '16px', border: '1px solid #ddd', boxShadow: '0 2px 6px rgba(0,0,0,.15)', opacity: '0.85' } }, '');
expect(html).toContain('margin-top:16px');
expect(html).toContain('border:1px solid #ddd');
expect(html).toContain('opacity:0.85');
});
});
describe('SubscribeForm.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(SubscribeForm.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
+33 -4
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react'; import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core'; import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr } from '../../utils/escape';
interface SubscribeFormProps { interface SubscribeFormProps {
@@ -10,6 +11,17 @@ interface SubscribeFormProps {
buttonColor?: string; buttonColor?: string;
layout?: 'inline' | 'stacked'; layout?: 'inline' | 'stacked';
style?: CSSProperties; style?: CSSProperties;
/** "Send submissions to" address -- same relay contract as ContactForm/
* FormContainer (see utils/form-relay-wiring.ts). Blank = no relay; the
* published form then has no working action at all, which is the bug
* this prop exists to fix. */
recipientEmail?: string;
thankYouUrl?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
@@ -110,6 +122,13 @@ SubscribeForm.craft = {
buttonColor: '#3b82f6', buttonColor: '#3b82f6',
layout: 'inline', layout: 'inline',
style: { backgroundColor: '#f8fafc' }, style: { backgroundColor: '#f8fafc' },
recipientEmail: '',
thankYouUrl: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -120,7 +139,7 @@ SubscribeForm.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => { (SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string, nodeId?: string) => {
const { const {
heading = 'Subscribe to our newsletter', heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email', placeholder = 'Enter your email',
@@ -166,11 +185,21 @@ SubscribeForm.craft = {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
// Same relay contract as ContactForm/FormContainer: a recipientEmail wires
// the form through the WHP form-sender relay (marker + placeholder action
// + honeypot, provisioned/rewritten at publish time). Previously this form
// always emitted `<form method="POST">` with NO action at all -- a
// published subscribe form silently did nothing on submit. Falling back to
// `formAction`-less relay wiring (fallbackAction undefined -> '#') keeps
// the old no-recipient case visually identical (action="#") while making
// the relay path actually functional once an admin sets an email.
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, undefined, nodeId);
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}> html: `${marker}<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${headingHtml} ${headingHtml}
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}> <form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" /> ${honeypot ? ` ${honeypot}\n` : ''} <input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button> <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
</form> </form>
</div>`, </div>`,
@@ -63,3 +63,20 @@ describe('TextareaField.toHtml rows attribute sanitization', () => {
expect(html).toContain('rows="8"'); expect(html).toContain('rows="8"');
}); });
}); });
describe('TextareaField.toHtml box-model style passthrough', () => {
test('margin/border/box-shadow/opacity flow through via the style prop', () => {
const { html } = toHtml({ label: 'Message', name: 'message', style: { marginTop: '10px', border: '1px solid #555', boxShadow: '0 1px 3px rgba(0,0,0,.15)', opacity: '0.7' } }, '');
expect(html).toContain('margin-top:10px');
expect(html).toContain('border:1px solid #555');
expect(html).toContain('opacity:0.7');
});
});
describe('TextareaField.craft.props includes animation/visibility defaults', () => {
test('has blank/false defaults', () => {
expect(TextareaField.craft!.props).toMatchObject({
animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
});
});
});
@@ -10,6 +10,11 @@ interface TextareaFieldProps {
rows?: number; rows?: number;
required?: boolean; required?: boolean;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const TextareaField: UserComponent<TextareaFieldProps> = ({ export const TextareaField: UserComponent<TextareaFieldProps> = ({
@@ -79,6 +84,11 @@ TextareaField.craft = {
rows: 4, rows: 4,
required: false, required: false,
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -80,3 +80,56 @@ describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
expect(html).toMatch(/calc\(50% - 24px\)/); expect(html).toMatch(/calc\(50% - 24px\)/);
}); });
}); });
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
expect(html).toContain('align-items:center');
});
});
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
columns: 2,
split: '50-50',
gap: '16px',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'<div>A</div><div>B</div>',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ColumnLayout as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default alignItems and box-model keys', () => {
const style = (ColumnLayout as any).craft.props.style;
expect(style).toHaveProperty('alignItems');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+18 -1
View File
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
const splitToWidths: Record<string, string[]> = { const splitToWidths: Record<string, string[]> = {
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
columns: 2, columns: 2,
split: '50-50', split: '50-50',
gap: '16px', gap: '16px',
style: {}, style: {
alignItems: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -63,3 +63,88 @@ describe('Container.toHtml tag allowlist (adversarial re-review, same class as C
} }
}); });
}); });
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: Container/Section must NOT unconditionally become a
// flex container. Flex-blockifies in-flow children, forcing components
// that deliberately render display:inline-block (ButtonLink, Icon) to
// stack vertically instead of sitting side-by-side -- a real visual
// regression for existing published pages that never touch vertical
// alignment.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:400px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).toContain('min-height:400px');
});
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:flex-end');
expect(html).toContain('min-height:500px');
});
});
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Container as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Container as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+33 -1
View File
@@ -43,6 +43,20 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {}; return {};
}; };
// Container only becomes display:flex/flex-direction:column at its root
// (both in the editor render below and in toHtml) when the user has
// actually set `style.justifyContent` (the Vertical Alignment control,
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
// vertical-align actually being in use, not unconditional. In-flow children
// of a flex container get CSS-blockified, which would force components that
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
// vertically instead of sitting side-by-side -- a real visual regression for
// any container/section that never touches vertical alignment, not a no-op.
// So plain block flow (no `display`/`flex-direction` at all) is preserved
// unless vertical-align is set. `flexAlignFromTextAlign` above still
// supplies its own conditional flex conversion (cross-axis alignItems from
// `textAlign`) independently -- unrelated to this gate.
export const Container: UserComponent<ContainerProps> = ({ export const Container: UserComponent<ContainerProps> = ({
style = {}, style = {},
tag = 'div', tag = 'div',
@@ -58,10 +72,12 @@ export const Container: UserComponent<ContainerProps> = ({
const safeTag = sanitizeContainerTag(tag); const safeTag = sanitizeContainerTag(tag);
const needsBoxedWrapper = contentWidth === 'boxed'; const needsBoxedWrapper = contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(style.textAlign); const flexStyles = flexAlignFromTextAlign(style.textAlign);
const hasVerticalAlign = !!style.justifyContent;
const outerStyle: CSSProperties = { const outerStyle: CSSProperties = {
minHeight: '40px', minHeight: '40px',
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...(needsBoxedWrapper ? {} : flexStyles), ...(needsBoxedWrapper ? {} : flexStyles),
}; };
@@ -93,13 +109,27 @@ export const Container: UserComponent<ContainerProps> = ({
Container.craft = { Container.craft = {
displayName: 'Container', displayName: 'Container',
props: { props: {
style: { padding: '20px', minHeight: '100px' }, style: {
padding: '20px',
minHeight: '100px',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
tag: 'div', tag: 'div',
fullWidth: false, fullWidth: false,
contentWidth: 'full', contentWidth: 'full',
anchorId: '', anchorId: '',
cssId: '', cssId: '',
cssClass: '', cssClass: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -114,9 +144,11 @@ Container.craft = {
const tag = sanitizeContainerTag(props.tag); const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed'; const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const hasVerticalAlign = !!props.style?.justifyContent;
const outerCss: CSSProperties = { const outerCss: CSSProperties = {
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(isBoxed ? {} : flexStyles), ...(isBoxed ? {} : flexStyles),
}; };
@@ -73,3 +73,78 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
expect(html).not.toContain('<svg'); expect(html).not.toContain('<svg');
}); });
}); });
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
// Section must not unconditionally become a flex container, or it
// blockifies inline-block children (ButtonLink, Icon) that are meant to
// sit side-by-side in existing published sections.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:600px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).toContain('min-height:600px');
});
});
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Section as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Section as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+31 -1
View File
@@ -27,6 +27,11 @@ interface SectionProps {
bottomDividerColor?: string; bottomDividerColor?: string;
bottomDividerHeight?: string; bottomDividerHeight?: string;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Divider renderer ---------- */ /* ---------- Divider renderer ---------- */
@@ -98,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
const hasTopDivider = topDivider && topDivider !== 'none'; const hasTopDivider = topDivider && topDivider !== 'none';
const hasBottomDivider = bottomDivider && bottomDivider !== 'none'; const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
// Section's root only becomes a column flex container when the user has
// actually set `style.justifyContent` (Vertical Alignment control, paired
// with `style.minHeight`) -- see the matching note in Container.tsx for
// why an unconditional conversion is a real regression (blockifies
// deliberately inline-block children like ButtonLink/Icon) rather than a
// no-op, so plain block flow is preserved unless vertical-align is set.
const hasVerticalAlign = !!style.justifyContent;
return ( return (
<section <section
@@ -107,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
width: '100%', width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}} }}
> >
{hasTopDivider && ( {hasTopDivider && (
@@ -143,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
Section.craft = { Section.craft = {
displayName: 'Section', displayName: 'Section',
props: { props: {
style: { padding: '40px 0', backgroundColor: '#ffffff' }, style: {
padding: '40px 0',
backgroundColor: '#ffffff',
minHeight: '',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
innerMaxWidth: '1200px', innerMaxWidth: '1200px',
topDivider: 'none', topDivider: 'none',
topDividerColor: '#ffffff', topDividerColor: '#ffffff',
@@ -152,6 +175,11 @@ Section.craft = {
bottomDividerColor: '#ffffff', bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px', bottomDividerHeight: '50px',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -199,11 +227,13 @@ function buildDividerHtml(
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => { (Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const hasTopDivider = props.topDivider && props.topDivider !== 'none'; const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none'; const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
const hasVerticalAlign = !!props.style?.justifyContent;
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
width: '100%', width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}); });
const innerStyle = cssPropsToString({ const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px', maxWidth: props.innerMaxWidth || '1200px',
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { ImageBlock } from './ImageBlock'; import { ImageBlock, pxAttr } from './ImageBlock';
const toHtml = (ImageBlock as any).toHtml; const toHtml = (ImageBlock as any).toHtml;
@@ -32,3 +32,95 @@ describe('ImageBlock.toHtml src/alt XSS hardening', () => {
expect(html).toContain('alt="A photo"'); expect(html).toContain('alt="A photo"');
}); });
}); });
describe('ImageBlock.toHtml perf attributes (always emitted)', () => {
test('loading="lazy" and decoding="async" are always present', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg' }, '');
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
test('width/height attributes are emitted when the style has plain px values', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '400px', height: '300px' } }, '');
expect(html).toContain('width="400"');
expect(html).toContain('height="300"');
});
test('width/height attributes are omitted when the style value is not a plain px length', () => {
const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '50%', height: 'auto' } }, '');
expect(html).not.toMatch(/\swidth="/);
expect(html).not.toMatch(/\sheight="/);
});
});
describe('pxAttr', () => {
test('extracts the numeric portion of a plain px length', () => {
expect(pxAttr('400px')).toBe('400');
expect(pxAttr('12.5px')).toBe('12.5');
});
test('returns undefined for non-px units, non-string, or unset values', () => {
expect(pxAttr('50%')).toBeUndefined();
expect(pxAttr('auto')).toBeUndefined();
expect(pxAttr(undefined)).toBeUndefined();
expect(pxAttr(400)).toBeUndefined();
});
});
describe('ImageBlock.toHtml CSS framing crop (aspect-ratio + object-fit + object-position)', () => {
test('style.aspectRatio, objectFit, objectPosition all flow into the emitted style attribute', () => {
const { html } = toHtml(
{ src: 'https://example.com/photo.jpg', style: { aspectRatio: '16 / 9', objectFit: 'cover', objectPosition: 'center top' } },
''
);
expect(html).toContain('aspect-ratio:16 / 9');
expect(html).toContain('object-fit:cover');
expect(html).toContain('object-position:center top');
});
});
describe('ImageBlock.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
src: 'https://example.com/photo.jpg',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
''
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ImageBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ImageBlock as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model and crop keys', () => {
const style = (ImageBlock as any).craft.props.style;
expect(style).toHaveProperty('aspectRatio');
expect(style).toHaveProperty('objectFit');
expect(style).toHaveProperty('objectPosition');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+42 -2
View File
@@ -9,6 +9,23 @@ interface ImageBlockProps {
src?: string; src?: string;
alt?: string; alt?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/** Extracts the numeric portion of a plain "<n>px" CSS length string, for
* emitting real `width`/`height` HTML attributes on the exported `<img>`
* (helps the browser reserve layout space before the image loads --
* avoiding CLS -- something a CSS-only width/height can't do on its own).
* Returns undefined for any other unit ('%', 'auto', '', etc.) so the
* attribute is simply omitted when the pixel size isn't known. */
export function pxAttr(v: unknown): string | undefined {
if (typeof v !== 'string') return undefined;
const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/);
return m ? m[1] : undefined;
} }
// Helper: upload a file to the WHP API and return the proxy URL // Helper: upload a file to the WHP API and return the proxy URL
@@ -83,7 +100,27 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
ImageBlock.craft = { ImageBlock.craft = {
displayName: 'Image', displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } }, props: {
src: PLACEHOLDER_SRC,
alt: '',
style: {
width: '100%',
height: 'auto',
aspectRatio: '',
objectFit: '' as CSSProperties['objectFit'],
objectPosition: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
}; };
@@ -95,5 +132,8 @@ ImageBlock.craft = {
} }
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""'; const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` }; const widthAttr = pxAttr((props.style as any)?.width);
const heightAttr = pxAttr((props.style as any)?.height);
const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`;
return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${dims} loading="lazy" decoding="async"${s ? ` style="${s}"` : ''} />` };
}; };
@@ -28,6 +28,49 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)'
}); });
}); });
describe('MapEmbed.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the wrapper style attribute', () => {
const { html } = toHtml(
{
address: 'New York, NY',
style: {
marginTop: '16px',
paddingRight: '4px',
border: '1px solid #cccccc',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.95',
},
},
''
);
expect(html).toContain('margin-top:16px');
expect(html).toContain('padding-right:4px');
expect(html).toContain('border:1px solid #cccccc');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.95');
});
});
describe('MapEmbed.craft.props exposes the animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (MapEmbed as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (MapEmbed as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => { describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => {
test('a malicious address cannot break out of the src or title attribute', () => { test('a malicious address cannot break out of the src or title attribute', () => {
const malicious = 'X" onerror="alert(1)'; const malicious = 'X" onerror="alert(1)';
+17 -1
View File
@@ -8,6 +8,11 @@ interface MapEmbedProps {
zoom?: number; zoom?: number;
height?: string; height?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
function buildMapUrl(address: string, zoom: number): string { function buildMapUrl(address: string, zoom: number): string {
@@ -70,7 +75,18 @@ MapEmbed.craft = {
address: 'New York, NY', address: 'New York, NY',
zoom: 14, zoom: 14,
height: '400px', height: '400px',
style: {}, style: {
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -118,3 +118,75 @@ describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/); expect(srcMatch![1]).not.toMatch(/&(?!amp;)/);
}); });
}); });
describe('VideoBlock.toHtml size + aspect ratio (frame honors style props, not a hardcoded 16:9)', () => {
test('direct file: style.width flows to the wrapper, style.aspectRatio flows to the <video>', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: { width: '50%', aspectRatio: '4 / 3' } }, '');
expect(html).toMatch(/<div style="[^"]*width:50%[^"]*"/);
expect(html).toMatch(/<video[^>]*style="[^"]*aspect-ratio:4 \/ 3[^"]*"/);
});
test('direct file: no aspectRatio set -- no aspect-ratio declaration is forced onto the <video>', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
const videoTag = html.match(/<video[^>]*>/)![0];
expect(videoTag).not.toContain('aspect-ratio');
});
test('YouTube/Vimeo: style.aspectRatio overrides the 16:9 default on the iframe container', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: { aspectRatio: '1 / 1' } }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:1 \/ 1[^"]*"[^>]*><iframe/);
});
test('YouTube/Vimeo: defaults to 16 / 9 when no aspectRatio style is set', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789' }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:16 \/ 9[^"]*"[^>]*><iframe/);
});
});
describe('VideoBlock.toHtml poster + preload (file type)', () => {
test('poster attribute is emitted (escaped) and preload="metadata" is always present on a direct file', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'https://example.com/poster.jpg' }, '');
expect(html).toContain('poster="https://example.com/poster.jpg"');
expect(html).toContain('preload="metadata"');
});
test('no poster prop -- no poster attribute is emitted, but preload="metadata" still is', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
expect(html).not.toContain('poster=');
expect(html).toContain('preload="metadata"');
});
test('a malicious poster (javascript: scheme) is blocked by safeImageUrl', () => {
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'javascript:alert(1)' }, '');
expect(html).not.toContain('javascript:');
});
test('a poster value cannot break out of the poster attribute', () => {
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: malicious }, '');
expect(html).not.toContain('onerror="alert(1)"');
});
});
describe('VideoBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
test('poster, animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (VideoBlock as any).craft.props;
expect(props.poster).toBe('');
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model + size/aspect keys', () => {
const style = (VideoBlock as any).craft.props.style;
expect(style).toHaveProperty('width');
expect(style).toHaveProperty('aspectRatio');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+36 -11
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core'; import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container'; import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -12,6 +12,7 @@ interface VideoBlockProps {
videoUrl?: string; videoUrl?: string;
videoType?: VideoType; videoType?: VideoType;
embedUrl?: string; embedUrl?: string;
poster?: string;
autoplay?: boolean; autoplay?: boolean;
muted?: boolean; muted?: boolean;
loop?: boolean; loop?: boolean;
@@ -22,6 +23,11 @@ interface VideoBlockProps {
innerMaxWidth?: string; innerMaxWidth?: string;
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
/* ---------- URL detection ---------- */ /* ---------- URL detection ---------- */
@@ -120,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
videoUrl = '', videoUrl = '',
videoType: _videoTypeProp, videoType: _videoTypeProp,
embedUrl: _embedUrlProp, embedUrl: _embedUrlProp,
poster = '',
autoplay = false, autoplay = false,
muted = true, muted = true,
loop = false, loop = false,
@@ -259,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
<div <div
style={{ style={{
position: 'relative', position: 'relative',
paddingBottom: '56.25%', aspectRatio: (style as any)?.aspectRatio || '16 / 9',
height: 0,
overflow: 'hidden', overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined, borderRadius: (style as any)?.borderRadius || undefined,
}} }}
@@ -269,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })} src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 0, inset: 0,
left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
border: 'none', border: 'none',
@@ -284,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
{type === 'file' && ( {type === 'file' && (
<video <video
src={embedUrl} src={embedUrl}
poster={poster || undefined}
autoPlay={autoplay} autoPlay={autoplay}
muted={muted} muted={muted}
loop={loop} loop={loop}
controls={controls} controls={controls}
preload="metadata"
playsInline playsInline
style={{ style={{
display: 'block', display: 'block',
width: '100%', width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined, borderRadius: (style as any)?.borderRadius || undefined,
}} }}
/> />
@@ -310,6 +319,7 @@ VideoBlock.craft = {
videoUrl: '', videoUrl: '',
videoType: 'none', videoType: 'none',
embedUrl: '', embedUrl: '',
poster: '',
autoplay: false, autoplay: false,
muted: true, muted: true,
loop: false, loop: false,
@@ -318,7 +328,20 @@ VideoBlock.craft = {
overlayColor: '#000000', overlayColor: '#000000',
overlayOpacity: 50, overlayOpacity: 50,
innerMaxWidth: '1200px', innerMaxWidth: '1200px',
style: {}, style: {
width: '',
aspectRatio: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -334,6 +357,7 @@ VideoBlock.craft = {
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => { (VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const { const {
videoUrl = '', videoUrl = '',
poster = '',
autoplay = false, autoplay = false,
muted = true, muted = true,
loop: doLoop = false, loop: doLoop = false,
@@ -422,15 +446,13 @@ VideoBlock.craft = {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls }); const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
const containerStyle = cssPropsToString({ const containerStyle = cssPropsToString({
position: 'relative', position: 'relative',
paddingBottom: '56.25%', aspectRatio: (style as any)?.aspectRatio || '16 / 9',
height: '0',
overflow: 'hidden', overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined, borderRadius: (style as any)?.borderRadius || undefined,
}); });
const iframeStyle = cssPropsToString({ const iframeStyle = cssPropsToString({
position: 'absolute', position: 'absolute',
top: '0', inset: '0',
left: '0',
width: '100%', width: '100%',
height: '100%', height: '100%',
border: 'none', border: 'none',
@@ -447,13 +469,16 @@ VideoBlock.craft = {
if (doLoop) vidAttrs.push('loop'); if (doLoop) vidAttrs.push('loop');
if (controls) vidAttrs.push('controls'); if (controls) vidAttrs.push('controls');
vidAttrs.push('playsinline'); vidAttrs.push('playsinline');
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
const vidStyle = cssPropsToString({ const vidStyle = cssPropsToString({
display: 'block', display: 'block',
width: '100%', width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined, borderRadius: (style as any)?.borderRadius || undefined,
}); });
return { return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`, html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}"${posterAttr} preload="metadata" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
}; };
}; };
@@ -139,3 +139,46 @@ describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be c
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/); expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
}); });
}); });
describe('ContentSlider.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
const { html } = toHtml(
{
slides,
style: {
marginBottom: '24px',
paddingTop: '8px',
border: '3px dashed #00ff00',
boxShadow: '0 10px 24px rgba(0,0,0,0.18)',
opacity: '0.75',
},
},
''
);
expect(html).toContain('margin-bottom:24px');
expect(html).toContain('padding-top:8px');
expect(html).toContain('border:3px dashed #00ff00');
expect(html).toContain('box-shadow:0 10px 24px rgba(0,0,0,0.18)');
expect(html).toContain('opacity:0.75');
});
});
describe('ContentSlider.craft.props exposes the animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ContentSlider as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (ContentSlider as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
@@ -21,6 +21,11 @@ interface ContentSliderProps {
showArrows?: boolean; showArrows?: boolean;
height?: string; height?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultSlides: Slide[] = [ const defaultSlides: Slide[] = [
@@ -227,7 +232,18 @@ ContentSlider.craft = {
showDots: true, showDots: true,
showArrows: true, showArrows: true,
height: '400px', height: '400px',
style: {}, style: {
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -3,6 +3,73 @@ import { Gallery } from './Gallery';
const toHtml = (Gallery as any).toHtml; const toHtml = (Gallery as any).toHtml;
describe('Gallery.toHtml columns + lightbox controls (previously unexposed props)', () => {
test('columns drives the grid-template-columns repeat count', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 5 }, '');
expect(html).toContain('grid-template-columns:repeat(5,1fr)');
});
test('lightbox=true adds the delegated-listener overlay markup (columns unaffected)', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], columns: 4, lightbox: true }, '');
expect(html).toContain('grid-template-columns:repeat(4,1fr)');
expect(html).toContain('role="dialog"');
});
test('a non-numeric columns value falls back safely (Number() coercion, not NaN in the template)', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg' }], columns: 'not-a-number' as any }, '');
expect(html).toContain('grid-template-columns:repeat(3,1fr)');
});
});
describe('Gallery.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the section style attribute', () => {
const { html } = toHtml(
{
images: [{ src: '/a.jpg', alt: 'a' }],
style: {
marginTop: '20px',
paddingLeft: '12px',
border: '1px solid #333333',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
opacity: '0.9',
},
},
''
);
expect(html).toContain('margin-top:20px');
expect(html).toContain('padding-left:12px');
expect(html).toContain('border:1px solid #333333');
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.08)');
expect(html).toContain('opacity:0.9');
});
});
describe('Gallery.craft.props exposes columns/lightbox + the animation/visibility rollout', () => {
test('columns and lightbox have their existing defaults', () => {
const props = (Gallery as any).craft.props;
expect(props.columns).toBe(3);
expect(props.lightbox).toBe(false);
});
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Gallery as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default box-model keys', () => {
const style = (Gallery as any).craft.props.style;
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => { describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclick (A4.3)', () => {
test('no per-item inline onclick with interpolated src', () => { test('no per-item inline onclick with interpolated src', () => {
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, ''); const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
+18 -1
View File
@@ -15,6 +15,11 @@ interface GalleryProps {
gap?: string; gap?: string;
style?: CSSProperties; style?: CSSProperties;
lightbox?: boolean; lightbox?: boolean;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const placeholderSvg = (index: number) => { const placeholderSvg = (index: number) => {
@@ -112,8 +117,20 @@ Gallery.craft = {
images: defaultImages, images: defaultImages,
columns: 3, columns: 3,
gap: '16px', gap: '16px',
style: { backgroundColor: '#ffffff' }, style: {
backgroundColor: '#ffffff',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
lightbox: false, lightbox: false,
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
+3
View File
@@ -11,6 +11,7 @@ import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext'; import { MobileChromeProvider } from '../state/MobileChromeContext';
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -83,6 +84,7 @@ export const EditorShell: React.FC = () => {
// doesn't render MobilePanelBar and TopBar's desktop branch behaves // doesn't render MobilePanelBar and TopBar's desktop branch behaves
// identically to before (same booleans, just sourced from context). // identically to before (same booleans, just sourced from context).
<MobileChromeProvider> <MobileChromeProvider>
<LayerFocusProvider>
<div className="editor-app"> <div className="editor-app">
<TopBar <TopBar
device={device} device={device}
@@ -107,6 +109,7 @@ export const EditorShell: React.FC = () => {
onClose={hideMenu} onClose={hideMenu}
/> />
</div> </div>
</LayerFocusProvider>
</MobileChromeProvider> </MobileChromeProvider>
); );
}; };
+78 -12
View File
@@ -1,29 +1,95 @@
import { describe, test, expect, afterEach } from 'vitest'; import { describe, test, expect, afterEach } from 'vitest';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import type { NodeTree } from '@craftjs/core';
import { getClipboardTree, setClipboardTree } from './clipboard';
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
return {
rootNodeId: rootId,
nodes: {
[rootId]: {
id: rootId,
data: {
type: { resolvedName: 'Container' },
name: 'Container',
displayName: 'Container',
props,
custom: {},
isCanvas: false,
parent: 'wherever-it-originally-lived',
nodes: [],
linkedNodes: {},
hidden: false,
},
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as NodeTree['nodes'][string],
},
};
}
describe('clipboard', () => { describe('clipboard', () => {
afterEach(() => { afterEach(() => {
setClipboardNodeId(null); setClipboardTree(null);
}); });
test('starts empty', () => { test('starts empty', () => {
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
}); });
test('set then get returns the stored node id', () => { test('set then get returns a tree with the same root id and shape', () => {
setClipboardNodeId('node-123'); const tree = makeTree('node-123', { text: 'hello' });
expect(getClipboardNodeId()).toBe('node-123'); setClipboardTree(tree);
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.rootNodeId).toBe('node-123');
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
}); });
test('is a shared module-level store -- overwriting replaces the previous value', () => { test('is a shared module-level store -- overwriting replaces the previous value', () => {
setClipboardNodeId('first'); setClipboardTree(makeTree('first'));
setClipboardNodeId('second'); setClipboardTree(makeTree('second'));
expect(getClipboardNodeId()).toBe('second'); expect(getClipboardTree()!.rootNodeId).toBe('second');
}); });
test('can be cleared back to null', () => { test('can be cleared back to null', () => {
setClipboardNodeId('node-123'); setClipboardTree(makeTree('node-123'));
setClipboardNodeId(null); setClipboardTree(null);
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
});
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
const original = makeTree('node-123', { text: 'original' });
setClipboardTree(original);
// Mutate the original tree's props object directly (as if the source
// node were edited, or the same live node got copied again).
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
});
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
setClipboardTree(original);
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
'https://example.com',
);
});
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
setClipboardTree(tree);
tree = null; // simulate the original page's node/tree going away entirely
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
}); });
}); });
+65 -7
View File
@@ -1,3 +1,5 @@
import type { Node, NodeId, NodeTree } from '@craftjs/core';
/** /**
* Tiny shared clipboard for canvas node copy/paste. * Tiny shared clipboard for canvas node copy/paste.
* *
@@ -9,15 +11,71 @@
* Deliberately not React state -- nothing in the UI needs to re-render * Deliberately not React state -- nothing in the UI needs to re-render
* reactively when the clipboard changes; consumers just read the current * reactively when the clipboard changes; consumers just read the current
* value at the moment they need it (on paste, or when a menu opens). * value at the moment they need it (on paste, or when a menu opens).
*
* Historical bug (cross-page copy/paste): this used to store only the copied
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
* at paste time. That works fine same-page, but the moment the user switches
* pages the canvas is re-deserialized to the target page's Craft.js state --
* the copied id no longer exists in `query` at all -- so a cross-page paste
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
* TREE SNAPSHOT at copy time instead means paste never needs to look the
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
* `actions.addNodeTree`, which works identically regardless of which page's
* state is currently loaded on the canvas.
*/ */
let clipboardNodeId: string | null = null; let clipboardTree: NodeTree | null = null;
/** Returns the id of the node currently on the clipboard, or null if empty. */ /**
export function getClipboardNodeId(): string | null { * Deep, detached clone of a live Craft.js `NodeTree` (as returned by
return clipboardNodeId; * `query.node(id).toNodeTree()`).
*
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
* `data.type` is the actual component function/class reference (not a
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
* function and throws `DataCloneError` (see the identical note on
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
* historically). `type` is a stable reference shared by every node of that
* component across the whole app (it doesn't change per page), so it's safe
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
* subsequent paste's `setProp`, or a fresh copy of the same live node)
* can never reach back into this stored snapshot.
*/
function cloneNodeTree(tree: NodeTree): NodeTree {
const nodes: Record<NodeId, Node> = {};
for (const [id, node] of Object.entries(tree.nodes)) {
nodes[id] = {
...node,
data: {
...node.data,
props: structuredClone(node.data.props),
custom: structuredClone(node.data.custom),
nodes: [...(node.data.nodes || [])],
linkedNodes: { ...(node.data.linkedNodes || {}) },
},
};
}
return { rootNodeId: tree.rootNodeId, nodes };
} }
/** Sets (or clears, with `null`) the node id on the clipboard. */ /**
export function setClipboardNodeId(nodeId: string | null): void { * Returns the tree snapshot currently on the clipboard, or null if empty.
clipboardNodeId = nodeId; * The returned tree is safe to hand straight to `regenerateTreeIds` --
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
* same clipboard contents (including across a page switch) all work off the
* same untouched snapshot.
*/
export function getClipboardTree(): NodeTree | null {
return clipboardTree;
}
/**
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
* detached from the live Craft.js node it was captured from -- it survives
* that node being deleted, mutated, or (the whole point) the canvas being
* re-deserialized to a different page entirely.
*/
export function setClipboardTree(tree: NodeTree | null): void {
clipboardTree = tree ? cloneNodeTree(tree) : null;
} }
+53 -17
View File
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core'; import type { NodeTree, Node } from '@craftjs/core';
import { useKeyboardShortcuts } from './useKeyboardShortcuts'; import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import { getClipboardTree, setClipboardTree } from './clipboard';
/** /**
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through * Regression coverage: Ctrl/Cmd+V must run the copied subtree through
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing * ROOT-fallback targeting, the empty-clipboard no-op, and the existing
* input-focus guard. * input-focus guard.
* *
* Also covers the cross-page clipboard fix: copy stores a detached TREE
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
* to re-resolve the original node via `query.node(id)`, which is exactly
* what breaks once the canvas has been re-deserialized to a different page
* (see `hooks/clipboard.ts` and the cross-page integration test in
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
*
* Mock pattern mirrors PageContext.pure-updaters.test.tsx / * Mock pattern mirrors PageContext.pure-updaters.test.tsx /
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`, * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
* mounted via a bare consumer component, with REAL `keydown` events * mounted via a bare consumer component, with REAL `keydown` events
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
*/ */
const addNodeTreeMock = vi.fn(); const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
let selectedIds: string[] = []; let selectedIds: string[] = [];
function makeNode(id: string, parent: string | null, children: string[] = []): Node { function makeNode(id: string, parent: string | null, children: string[] = []): Node {
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
}, },
}; };
const nodeStore: Record<string, { data: { parent: string | null } }> = { const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
'selected-1': { data: { parent: 'parent-container-1' } }, 'selected-1': { data: { parent: 'parent-container-1' } },
ROOT: { data: { parent: null } }, 'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
ROOT: { data: { parent: null, nodes: [] } },
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } }, 'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
}; };
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
}, },
actions: { actions: {
addNodeTree: addNodeTreeMock, addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
history: { undo: vi.fn(), redo: vi.fn() }, history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(), delete: vi.fn(),
clearEvents: vi.fn(), clearEvents: vi.fn(),
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
beforeEach(() => { beforeEach(() => {
selectedIds = []; selectedIds = [];
addNodeTreeMock.mockClear(); addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
regenerateTreeIdsMock.mockClear(); regenerateTreeIdsMock.mockClear();
setClipboardNodeId(null); setClipboardTree(null);
}); });
afterEach(() => { afterEach(() => {
setClipboardNodeId(null); setClipboardTree(null);
if (root) unmount(); if (root) unmount();
}); });
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
test('Ctrl+C copies the selected node id to the clipboard', () => { test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['selected-1']; selectedIds = ['copied-root-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBe('selected-1'); const clip = getClipboardTree();
expect(clip).not.toBeNull();
expect(clip!.rootNodeId).toBe('copied-root-1');
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
}); });
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => { test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['copied-root-1']; selectedIds = ['copied-root-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBe('copied-root-1'); expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('v'); pressKey('v');
// regenerateTreeIds actually ran before the tree was handed to Craft.js. // regenerateTreeIds actually ran before the tree was handed to Craft.js.
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE); expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
expect(addNodeTreeMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0]; const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
// Sibling of the current selection: selected-1's data.parent. // Sibling of the current selection: selected-1's data.parent.
expect(targetParent).toBe('parent-container-1'); expect(targetParent).toBe('parent-container-1');
// Immediately after selected-1 (index 1 among parent-container-1's
// children), matching duplicate()'s "insert right after" UX.
expect(insertIndex).toBe(2);
// The regression this guards: pasted ids must be fresh, never reuse the // The regression this guards: pasted ids must be fresh, never reuse the
// ids the copied node already occupies in the live Craft.js tree. // ids the copied node already occupies in the live Craft.js tree.
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
for (const id of pastedIds) { for (const id of pastedIds) {
expect(originalIds.has(id)).toBe(false); expect(originalIds.has(id)).toBe(false);
} }
// The new copy is selected, same as duplicate()'s existing UX.
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
}); });
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => { test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['copied-root-1']; selectedIds = ['copied-root-1'];
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
pressKey('v'); pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent] = addNodeTreeMock.mock.calls[0]; const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT'); expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = [];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
}); });
test('Ctrl+V with an empty clipboard is a no-op', () => { test('Ctrl+V with an empty clipboard is a no-op', () => {
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
setClipboardNodeId('copied-root-1'); setClipboardTree(COPIED_TREE);
pressKey('v'); pressKey('v');
expect(addNodeTreeMock).not.toHaveBeenCalled(); expect(addNodeTreeMock).not.toHaveBeenCalled();
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
activeElementSpy.mockRestore(); activeElementSpy.mockRestore();
}); });
+35 -13
View File
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers'; import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree'; import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import { getClipboardTree, setClipboardTree } from './clipboard';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+C: copy selected node id to the shared clipboard // Ctrl+C: copy the selected node's subtree (a detached snapshot, not
// just its id -- see clipboard.ts for why: an id-based clipboard can't
// survive a page switch, since the copied id no longer exists in
// `query` once the canvas is re-deserialized to a different page).
if (ctrl && (e.key === 'c' || e.key === 'C')) { if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault(); e.preventDefault();
try { try {
const selected = query.getEvent('selected').all(); const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') { if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardNodeId(selected[0]); setClipboardTree(query.node(selected[0]).toNodeTree());
} }
} catch (err) { } catch (err) {
console.error('Copy failed:', err); console.error('Copy failed:', err);
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+V: paste the clipboard node as a sibling of the current selection // Ctrl+V: paste the clipboard tree as a sibling of the current
// selection (immediately after it, matching duplicate()'s UX), or
// append to ROOT when nothing is selected. Works regardless of which
// page is currently on the canvas -- the clipboard tree is a detached
// snapshot, not a reference to a node that may no longer exist here.
if (ctrl && (e.key === 'v' || e.key === 'V')) { if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault(); e.preventDefault();
try { try {
const sourceId = getClipboardNodeId(); const clip = getClipboardTree();
if (!sourceId || !query.node(sourceId).get()) return; if (!clip) return;
const selected = query.getEvent('selected').all(); const selected = query.getEvent('selected').all();
if (selected.length === 0) return; const selectedId = selected.length > 0 ? selected[0] : null;
const selectedId = selected[0];
let targetParent = 'ROOT'; let targetParentId = 'ROOT';
if (selectedId !== 'ROOT') { let insertIndex: number | undefined;
if (selectedId && selectedId !== 'ROOT') {
const node = query.node(selectedId).get(); const node = query.node(selectedId).get();
targetParent = node?.data?.parent || 'ROOT'; const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
targetParentId = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
} }
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); const tree = regenerateTreeIds(clip);
actions.addNodeTree(tree, targetParent); if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParentId, insertIndex);
} else {
actions.addNodeTree(tree, targetParentId);
}
actions.selectNode(tree.rootNodeId);
} catch (err) { } catch (err) {
console.error('Paste failed:', err); console.error('Paste failed:', err);
} }
+159
View File
@@ -0,0 +1,159 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
/**
* PKG-H §5 round-trip coverage: `load()` must restore per-page `seo`
* (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed
* `PageData`, exactly like it already restores `craftState`. Mocks
* `@craftjs/core`'s `useEditor` (same pattern as
* `PageContext.pure-updaters.test.tsx`) since this test only needs
* `query.serialize`/`actions.deserialize` as inert stubs -- it drives
* `load()`, not the live canvas.
*/
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: deserializeMock },
}),
}));
const whpConfig: WhpConfig = {
user: 'testuser',
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
siteName: 'Test Site',
backUrl: '/panel/sites',
isRoot: false,
};
let container: HTMLDivElement;
let root: Root;
interface Captured {
load: ReturnType<typeof useWhpApi>['load'];
pages: ReturnType<typeof usePages>['pages'];
}
function render(): { get: () => Captured } {
container = document.createElement('div');
document.body.appendChild(container);
let captured: Captured | null = null;
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages } = usePages();
captured = { load, pages };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
</EditorConfigProvider>,
);
});
return { get: () => captured! };
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => {
const seoPayload = {
metaTitle: 'Custom Title',
metaDescription: 'A custom description.',
ogTitle: 'Custom OG Title',
ogImage: '/uploads/og.jpg',
twitterCard: 'summary_large_image' as const,
noindex: true,
};
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload },
{ id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload);
// A page with no seo in the payload stays undefined -- back-compat, not
// coerced into an empty object.
expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
unmount();
});
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined();
unmount();
});
});
@@ -0,0 +1,218 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
/**
* Task 13b: `PageContext.loadState` runs every stored craft state through
* `repairOrphanNodes` before handing it to `actions.deserialize` -- that
* covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
* the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
* fired once on mount by `TopBar.tsx` -- used to call
* `actions.deserialize(state)` directly on the first page's stored
* `craftState`, bypassing repair entirely. A site whose saved state
* contains a node unreachable from ROOT would get it silently repaired on
* the NEXT page switch but not on the load that actually renders it first.
*
* This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
* `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
* string handed to the mocked `actions.deserialize` -- the orphan must be
* reattached to ROOT and a `console.error` must fire, exactly like the
* page-switch path (I5 review: this was `console.warn`, which
* console-buffer.ts -- feeding the in-builder issue reporter -- does not
* capture).
*/
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: deserializeMock },
}),
}));
const whpConfig: WhpConfig = {
user: 'testuser',
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
siteName: 'Test Site',
backUrl: '/panel/sites',
isRoot: false,
};
let container: HTMLDivElement;
let root: Root;
interface Captured {
load: ReturnType<typeof useWhpApi>['load'];
pages: ReturnType<typeof usePages>['pages'];
}
function render(): { get: () => Captured } {
container = document.createElement('div');
document.body.appendChild(container);
let captured: Captured | null = null;
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages } = usePages();
captured = { load, pages };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
</EditorConfigProvider>,
);
});
return { get: () => captured! };
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
* a ROOT with no children plus an orphan ('stray') whose `parent` points at
* an id that doesn't exist in the tree, and which no node's `nodes`/
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
const ORPHAN_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
},
stray: {
type: { resolvedName: 'HtmlBlock' },
isCanvas: false,
props: { code: '<p>stranded</p>', style: {} },
displayName: 'HTML',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
},
});
describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const harness = render();
await act(async () => {
await harness.get().load();
});
expect(deserializeMock).toHaveBeenCalled();
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
const parsed = JSON.parse(passedState);
// The orphan is now an ordinary, reachable child of ROOT.
expect(parsed.ROOT.nodes).toContain('stray');
expect(parsed.stray.parent).toBe('ROOT');
// The observable signal that repair actually ran, not just that the
// orphan happened to be absent for some unrelated reason. (React's own
// act()-environment warnings also go through console.error in this
// harness, so search all calls rather than assuming index 0.)
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
errorSpy.mockRestore();
unmount();
});
});
/**
* I5 (review): `PageContext.loadState`'s deserialize failure path logs via
* `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
* up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
* used to be `console.warn` with NO fallback deserialize -- the initial
* load, which decides whether the user sees a working editor at all, both
* failed harder (silently leaving the Frame undeserialized) and reported
* quieter than every subsequent page switch. This pins the aligned
* behaviour.
*/
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{"broken":true}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
deserializeMock.mockImplementationOnce(() => {
throw new Error('malformed state');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const harness = render();
await act(async () => {
await harness.get().load();
});
// First call (the broken state) threw; the second call is the fallback.
expect(deserializeMock).toHaveBeenCalledTimes(2);
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
errorSpy.mockRestore();
unmount();
});
});
+59
View File
@@ -147,4 +147,63 @@ describe('buildSavePayload', () => {
expect(payload.design).toEqual(design); expect(payload.design).toEqual(design);
expect(payload.design.headCode).toBe('<meta name="x">'); expect(payload.design.headCode).toBe('<meta name="x">');
}); });
test('PKG-H §5: per-page seo is included in both pages_craft_state and pages entries', () => {
const pageWithSeo: PageData = {
id: 'home',
name: 'Home',
slug: 'index',
craftState: 'STORED_HOME',
seo: {
metaTitle: 'Custom Title',
metaDescription: 'Custom description',
ogTitle: 'Custom OG',
ogImage: '/uploads/og.jpg',
twitterCard: 'summary_large_image',
noindex: true,
},
};
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageWithSeo, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
});
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.seo).toEqual(pageWithSeo.seo);
expect(payload.pages.find((p) => p.filename === 'index.html')?.seo).toEqual(pageWithSeo.seo);
// A page with no seo overrides omits the field entirely (undefined),
// not an empty object -- back-compat with pre-PKG-H saved shapes.
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
expect(payload.pages.find((p) => p.filename === 'about.html')?.seo).toBeUndefined();
});
test('PKG-H §5: favicon flows through the design object already carried by the payload', () => {
const design = { ...DEFAULT_SITE_DESIGN, favicon: '/uploads/favicon.png' };
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design,
});
expect(payload.design.favicon).toBe('/uploads/favicon.png');
});
}); });
+43 -6
View File
@@ -1,9 +1,10 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext'; import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext'; import { usePages, EMPTY_CANVAS } from '../state/PageContext';
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext'; import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types'; import { PageData } from '../types';
export interface BuildSavePayloadInput { export interface BuildSavePayloadInput {
@@ -149,6 +150,12 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
filename, filename,
title: page.name, title: page.name,
html: pageHtml, html: pageHtml,
// PKG-H §5: per-page SEO overrides, read by the backend's
// `handlePublish` (mirrors the existing `title` field above) to build
// the published <head> for this file. Omitted (undefined) when the
// page has no seo overrides -- JSON.stringify drops undefined keys,
// so legacy-shaped payloads round-trip unchanged.
seo: page.seo,
}; };
}); });
@@ -166,6 +173,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
// name.html) lines up with the file we just wrote (index.html). // name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug, slug: i === 0 ? 'index' : page.slug,
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null), craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
// PKG-H §5: round-trips PageData.seo through save -> load() so the
// editor's SEO fields survive a reload (mirrors craftState above).
seo: page.seo,
})); }));
return { return {
@@ -292,19 +302,46 @@ export function useWhpApi() {
// Restore pages and load the first page into the canvas // Restore pages and load the first page into the canvas
if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) { if (proj.pages_craft_state && Array.isArray(proj.pages_craft_state) && proj.pages_craft_state.length > 0) {
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null }) => ({ setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
}))); })));
// Load the first page (home) into the canvas // Load the first page (home) into the canvas. Routed through
// `repairOrphanNodes` first -- same as `PageContext.loadState` does
// for every subsequent page switch -- so a node that's unreachable
// from ROOT (invisible to Layers/selection) gets reattached here
// too, on the load that actually puts it on screen, rather than only
// on the next page switch. `repairOrphanNodes` never throws and
// returns the original string reference when nothing needed fixing,
// so this is cheap to run unconditionally.
const firstPage = proj.pages_craft_state[0]; const firstPage = proj.pages_craft_state[0];
if (firstPage.craftState) { if (firstPage.craftState) {
try { try {
const state = typeof firstPage.craftState === 'string' const rawState = typeof firstPage.craftState === 'string'
? firstPage.craftState : JSON.stringify(firstPage.craftState); ? firstPage.craftState : JSON.stringify(firstPage.craftState);
const { state, repaired } = repairOrphanNodes(rawState);
if (repaired.length > 0) {
// I5: console-buffer.ts only patches console.error, and this
// reattach signal is the single most diagnostic clue for the
// still-unreproduced "elements drop off the canvas" report --
// it must reach the in-builder issue reporter's console buffer.
console.error(
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
repaired.join(', '),
);
}
actions.deserialize(state); actions.deserialize(state);
} catch (e) { } catch (e) {
console.warn('Failed to load page state:', e); // I5: this is the initial load that decides whether the user
// sees a working editor at all -- align with `loadState`'s
// behaviour (console.error + a known-safe fallback) instead of
// warning quietly and leaving the Frame on whatever it last had.
console.error('Failed to load page state:', e);
try {
actions.deserialize(EMPTY_CANVAS);
} catch (_e2) {
// give up
}
} }
} }
+16
View File
@@ -2,8 +2,24 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import { WhpConfig } from './types'; import { WhpConfig } from './types';
import { installConsoleErrorBuffer } from './utils/console-buffer';
import { editorBuild } from './utils/build-stamp';
import './styles/editor.css'; import './styles/editor.css';
// Installed before React mounts so errors thrown during the first render are
// captured too.
installConsoleErrorBuffer();
// Exposed on window (not logged -- no need to print this on every load for
// every customer) so support can ask someone to type __WHP_EDITOR_BUILD__
// in the console on request. This call is also load-bearing for bundling:
// it is currently the only reference to `editorBuild()`, which keeps the
// __EDITOR_BUILD__-reading module from being tree-shaken out of the bundle
// before Task 19 wires it into the report payload. Do not remove this line
// as a "stray global" cleanup -- doing so silently reverts every future
// bug report to showing 'dev' instead of a real build stamp.
(window as any).__WHP_EDITOR_BUILD__ = editorBuild();
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev) // Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null; const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
+34 -15
View File
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree'; import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions'; import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps { interface ContextMenuProps {
@@ -79,35 +79,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
try { try {
setClipboardNodeId(nodeId); // Store a detached subtree snapshot, not just the id -- an id-based
// clipboard can't survive a page switch (the copied id no longer
// exists in `query` once the canvas is re-deserialized to a different
// page's Craft.js state). See clipboard.ts.
setClipboardTree(query.node(nodeId).toNodeTree());
} catch (e) { } catch (e) {
console.error('Copy failed:', e); console.error('Copy failed:', e);
} }
onClose(); onClose();
}, [nodeId, onClose]); }, [nodeId, query, onClose]);
const pasteNode = useCallback(() => { const pasteNode = useCallback(() => {
const sourceId = getClipboardNodeId(); const clip = getClipboardTree();
if (!sourceId) { if (!clip) {
onClose(); onClose();
return; return;
} }
try { try {
if (!query.node(sourceId).get()) { // Paste as a SIBLING of the right-clicked node (immediately after it,
onClose(); // matching duplicate()'s UX), not as its child -- using the clicked
return; // node itself as the parent throws when it's a leaf. Falls back to
} // appending into ROOT when nothing valid was right-clicked. This works
// regardless of which page is on the canvas -- the clipboard tree is a
// Paste as a SIBLING of the right-clicked node, not as its child -- // detached snapshot, not a reference to a node that may not exist here.
// using the clicked node itself as the parent throws when it's a leaf.
let targetParent = 'ROOT'; let targetParent = 'ROOT';
let insertIndex: number | undefined;
if (nodeId && nodeId !== 'ROOT') { if (nodeId && nodeId !== 'ROOT') {
const clickedNode = query.node(nodeId).get(); const clickedNode = query.node(nodeId).get();
targetParent = clickedNode?.data?.parent || 'ROOT'; const parentId: string | null | undefined = clickedNode?.data?.parent;
if (parentId) {
targetParent = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
} }
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); const tree = regenerateTreeIds(clip);
if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParent, insertIndex);
} else {
actions.addNodeTree(tree, targetParent); actions.addNodeTree(tree, targetParent);
}
actions.selectNode(tree.rootNodeId);
} catch (e) { } catch (e) {
console.error('Paste failed:', e); console.error('Paste failed:', e);
} }
@@ -176,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
icon: 'clipboard', icon: 'clipboard',
shortcut: 'Ctrl+V', shortcut: 'Ctrl+V',
action: pasteNode, action: pasteNode,
disabled: !getClipboardNodeId(), disabled: !getClipboardTree(),
dividerAfter: true, dividerAfter: true,
}, },
{ {
@@ -0,0 +1,59 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
let container: HTMLDivElement;
let root: Root;
const Probe: React.FC = () => {
const { focus, requestFocus } = useLayerFocus();
return (
<div>
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
</div>
);
};
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
});
}
describe('LayerFocusContext', () => {
test('starts with no focus request', () => {
render();
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
});
test('requestFocus publishes the target', () => {
render();
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
});
test('repeating the same request bumps the nonce so consumers re-fire', () => {
render();
const btn = container.querySelector('button') as HTMLButtonElement;
act(() => { btn.click(); });
act(() => { btn.click(); });
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
});
test('useLayerFocus outside a provider is inert rather than a crash', () => {
container = document.createElement('div');
document.body.appendChild(container);
expect(() => {
act(() => {
root = createRoot(container);
root.render(<Probe />);
});
}).not.toThrow();
});
});
@@ -0,0 +1,44 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
/**
* Carries "the user clicked a virtual Layers row -- scroll that array item's
* card into view" from the Layers panel (left) to the array editors (right).
*
* Deliberately a request, not a command: consumers that don't implement
* scrolling simply ignore it. Selecting the parent node always happens in
* LayersPanel itself, so a click is useful even with no consumer at all.
*/
export interface LayerFocusRequest {
nodeId: string;
prop: string;
index: number;
/** Bumped on every request so an identical repeat still re-fires effects. */
nonce: number;
}
export interface LayerFocusValue {
focus: LayerFocusRequest | null;
requestFocus(nodeId: string, prop: string, index: number): void;
}
const LayerFocusContext = createContext<LayerFocusValue>({
focus: null,
requestFocus: () => {},
});
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
const nonceRef = useRef(0);
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
nonceRef.current += 1;
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
}, []);
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
};
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { LayersPanel } from './LayersPanel';
import { LayerFocusProvider } from './LayerFocusContext';
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
return JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
},
...extra,
});
}
const featuresNode = {
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
displayName: 'Features Grid',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
describe('LayersPanel', () => {
test('shows virtual rows for a Features Grid array prop', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
// Scoped to `.layer-virtual-row` rather than the whole container:
// `FeaturesGrid` itself renders `feat.title` on the live canvas (which
// is also mounted inside `harness.container`, alongside the panel), so
// asserting on `container.textContent` alone would pass even if
// LayersPanel never rendered a single virtual row -- the "Fast"/"Secure"
// text would already be there from the canvas. Scoping to the virtual
// row elements themselves makes the assertion actually exercise
// LayersPanel's own rendering.
const virtualRows = harness.container.querySelectorAll('.layer-virtual-row');
expect(virtualRows).toHaveLength(2);
const virtualText = Array.from(virtualRows).map((el) => el.textContent).join(' | ');
expect(virtualText).toContain('Fast');
expect(virtualText).toContain('Secure');
// The real node row for the parent is still shown too.
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
expect(nodeText).toContain('Features Grid');
harness.unmount();
});
test('virtual rows are not rendered for a component with no registry entry', () => {
const heading = {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
harness.unmount();
});
test('the Unplaced group does not render for a healthy tree', () => {
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).not.toContain('Unplaced');
harness.unmount();
});
test('the Unplaced group appears and lists a node unreachable from ROOT', () => {
// 'stray' is a real node in the deserialized state, but no node's
// `nodes`/`linkedNodes` list references it -- exactly the "dropped
// outside the page" scenario findUnreachableNodeIds exists to catch.
const heading = {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Title', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const stray = {
type: { resolvedName: 'HtmlBlock' }, isCanvas: false,
props: { code: '<p>stranded</p>', style: {} }, displayName: 'HTML',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
};
const harness = renderEditorHarness({
initialState: stateWith({ h1: heading, stray }, ['h1']),
});
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
expect(harness.container.textContent).toContain('Unplaced');
expect(harness.container.textContent).toContain('Unplaced (1)');
// The orphan itself is rendered as a selectable/deletable LayerNode row
// (displayName 'HTML') underneath the Unplaced heading, not silently
// dropped from the tree.
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
expect(nodeText).toContain('HTML');
harness.unmount();
});
});
+107 -9
View File
@@ -1,6 +1,9 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
import { useLayerFocus } from './LayerFocusContext';
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
/** /**
* Per-type icon lookup keyed by the component's `craft.displayName` (the * Per-type icon lookup keyed by the component's `craft.displayName` (the
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
const DEFAULT_ICON = 'fa-cube'; const DEFAULT_ICON = 'fa-cube';
const ROOT_ICON = 'fa-desktop'; const ROOT_ICON = 'fa-desktop';
/**
* A display-only row for one item of a composite's array prop (a Features
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
* deleted. Clicking it selects the PARENT and asks the array editor to
* scroll that item's card into view.
*/
const VirtualRowNode: React.FC<{
parentId: string;
prop: string;
index: number;
label: string;
depth: number;
onActivate: () => void;
}> = ({ index, label, depth, onActivate }) => (
<div
{...clickableProps(onActivate)}
className="layer-virtual-row"
title={label}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
paddingLeft: `${8 + depth * 16}px`,
fontSize: 11,
color: 'var(--color-text-dim)',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'none',
}}
>
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">&#9642;</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
</div>
);
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
depth: number; depth: number;
} }
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => { const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { requestFocus } = useLayerFocus();
const { node, selectedId, actions, query } = useEditor((state) => { const { node, selectedId, actions, query } = useEditor((state) => {
const n = state.nodes[nodeId]; const n = state.nodes[nodeId];
const selectedIds = state.events.selected; const selectedIds = state.events.selected;
@@ -140,6 +182,10 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const isRoot = nodeId === 'ROOT'; const isRoot = nodeId === 'ROOT';
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON; const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
const hasDisclosure = allChildren.length + virtualRows.length > 0;
return ( return (
<div> <div>
<div <div
@@ -187,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
))} ))}
{/* Indentation/disclosure indicator */} {/* Indentation/disclosure indicator */}
{allChildren.length > 0 ? ( {hasDisclosure ? (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}> <span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
&#9660; &#9660;
</span> </span>
@@ -217,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
</span> </span>
</div> </div>
{/* Virtual rows: array-prop items (composite content Craft doesn't see
as child nodes), rendered before real children. */}
{virtualSpec && virtualRows.map((row) => (
<VirtualRowNode
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
parentId={nodeId}
prop={virtualSpec.prop}
index={row.index}
label={row.label}
depth={depth + 1}
onActivate={() => {
actions.selectNode(nodeId);
requestFocus(nodeId, virtualSpec.prop, row.index);
}}
/>
))}
{/* Render children */} {/* Render children */}
{allChildren.map((childId) => ( {allChildren.map((childId) => (
<LayerNode key={childId} nodeId={childId} depth={depth + 1} /> <LayerNode key={childId} nodeId={childId} depth={depth + 1} />
@@ -226,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
export const LayersPanel: React.FC = () => { export const LayersPanel: React.FC = () => {
const { nodeIds } = useEditor((state) => { const { nodeIds, unplacedIds } = useEditor((state) => {
const serializable: Record<string, any> = {};
for (const [id, n] of Object.entries(state.nodes)) {
serializable[id] = {
nodes: n.data.nodes || [],
linkedNodes: n.data.linkedNodes || {},
};
}
return { return {
nodeIds: Object.keys(state.nodes), nodeIds: Object.keys(state.nodes),
unplacedIds: findUnreachableNodeIds(serializable),
}; };
}); });
@@ -243,13 +314,7 @@ export const LayersPanel: React.FC = () => {
} }
return ( return (
<div <div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
style={{
display: 'flex',
flexDirection: 'column',
margin: '-12px',
}}
>
<div <div
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
@@ -259,11 +324,44 @@ export const LayersPanel: React.FC = () => {
letterSpacing: '0.5px', letterSpacing: '0.5px',
color: 'var(--color-text-muted)', color: 'var(--color-text-muted)',
borderBottom: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}} }}
> >
Component Tree Component Tree
</div> </div>
{/* Own scroll container: a deep or long tree must stay fully reachable
regardless of how the parent tab panel is sized. */}
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
<LayerNode nodeId="ROOT" depth={0} /> <LayerNode nodeId="ROOT" depth={0} />
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
these on load, so this should stay empty -- it exists so an
element stranded mid-session is still selectable and deletable
rather than invisible. */}
{unplacedIds.length > 0 && (
<>
<div
style={{
padding: '8px 12px',
marginTop: 8,
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: 'var(--color-warning, #f59e0b)',
borderTop: '1px solid var(--color-border)',
}}
title="These elements are not attached to the page. Select one to delete it."
>
Unplaced ({unplacedIds.length})
</div>
{unplacedIds.map((id) => (
<LayerNode key={id} nodeId={id} depth={1} />
))}
</>
)}
</div>
</div> </div>
); );
}; };
+234
View File
@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Modal } from '../../ui/Modal';
import { AssetPicker } from '../../ui/AssetPicker';
import { PageData, PageSeo } from '../../types';
interface PageSettingsModalProps {
open: boolean;
onClose: () => void;
page: PageData | null;
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
}
const EMPTY_SEO: PageSeo = {
metaTitle: '',
metaDescription: '',
ogTitle: '',
ogImage: '',
twitterCard: 'summary_large_image',
noindex: false,
};
/**
* Per-page SEO/meta settings (PKG-H contract §1/§4). Opened via the gear
* icon on a page row in PagesPanel. Fields map 1:1 onto `PageData.seo`
* (`types/index.ts`) and are consumed by `html-export.ts`'s `wrapInDocument`
* (Preview) and the backend's `generateCompiledHTML` (published output) --
* field names here MUST match the shared contract exactly.
*
* Follows the HeadCodeModal precedent: portaled to `document.body` (escapes
* the topbar/left-panel's own stacking context, same fix HeadCodeModal and
* TemplateModal already needed), built on the shared `Modal` shell, edits
* commit immediately via `updatePageSeo` (no separate Save step) rather than
* buffering to a "Save" button -- consistent with HeadCodeModal's live-edit
* pattern for `design.headCode`.
*/
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo }) => {
const [seo, setSeo] = useState<PageSeo>(EMPTY_SEO);
// Re-sync local form state from the target page's stored seo whenever the
// modal opens (or the target page changes while open) -- mirrors the
// pattern of resetting form-local state on prop identity change rather
// than deriving directly from props (dropdowns/checkboxes need editable
// local state).
useEffect(() => {
if (!open) return;
setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) });
}, [open, page?.id, page?.seo]);
if (!page) return null;
const update = (patch: Partial<PageSeo>) => {
setSeo((prev) => ({ ...prev, ...patch }));
updatePageSeo(page.id, patch);
};
return createPortal(
<Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={modalHeaderStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<i className="fa fa-cog" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Page Settings — {page.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
SEO and social-sharing metadata for this page only.
</div>
</div>
</div>
<button onClick={onClose} style={closeButtonStyle} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
{/* Body */}
<div style={{ padding: 20, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={fieldLabelStyle}>Meta Title</label>
<input
type="text"
className="control-input"
value={seo.metaTitle || ''}
onChange={(e) => update({ metaTitle: e.target.value })}
placeholder={page.name}
data-testid="page-seo-meta-title"
/>
<p style={hintStyle}>Overrides the browser tab title. Leave blank to use the page name.</p>
</div>
<div>
<label style={fieldLabelStyle}>Meta Description</label>
<textarea
className="control-input"
value={seo.metaDescription || ''}
onChange={(e) => update({ metaDescription: e.target.value })}
placeholder="A short summary shown in search results and link previews..."
rows={3}
style={{ resize: 'vertical' as const }}
data-testid="page-seo-meta-description"
/>
</div>
<div>
<label style={fieldLabelStyle}>Social Share Title (og:title)</label>
<input
type="text"
className="control-input"
value={seo.ogTitle || ''}
onChange={(e) => update({ ogTitle: e.target.value })}
placeholder={seo.metaTitle || page.name}
data-testid="page-seo-og-title"
/>
</div>
<div>
<label style={fieldLabelStyle}>Social Share Image (og:image)</label>
<AssetPicker
value={seo.ogImage || ''}
onChange={(url) => update({ ogImage: url })}
mediaType="image"
variant="full"
placeholder="Or paste image URL..."
/>
</div>
<div>
<label style={fieldLabelStyle}>Twitter Card Type</label>
<select
className="control-select"
value={seo.twitterCard || 'summary_large_image'}
onChange={(e) => update({ twitterCard: e.target.value as PageSeo['twitterCard'] })}
data-testid="page-seo-twitter-card"
>
<option value="summary">Summary</option>
<option value="summary_large_image">Summary with Large Image</option>
</select>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!seo.noindex}
onChange={(e) => update({ noindex: e.target.checked })}
data-testid="page-seo-noindex"
/>
Hide this page from search engines (noindex)
</label>
</div>
{/* Footer */}
<div style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border)',
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
}}>
<button onClick={onClose} style={doneButtonStyle}>
Done
</button>
</div>
</div>
</Modal>,
document.body,
);
};
/* ---------- Styles ---------- */
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 560,
maxHeight: '85vh',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 12,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
};
const modalHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
};
const closeButtonStyle: React.CSSProperties = {
width: 32,
height: 32,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 6,
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 14,
};
const doneButtonStyle: React.CSSProperties = {
padding: '8px 24px',
fontSize: 13,
fontWeight: 600,
background: 'var(--color-accent)',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const fieldLabelStyle: React.CSSProperties = {
display: 'block',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: '0.3px',
};
const hintStyle: React.CSSProperties = {
fontSize: 10,
color: 'var(--color-text-dim)',
margin: '4px 0 0',
};
@@ -0,0 +1,119 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { PageProvider, usePages } from '../../state/PageContext';
import { PagesPanel } from './PagesPanel';
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
return btn;
}
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
const btn = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
if (!btn) throw new Error(`No button found with text "${text}"`);
return btn;
}
/**
* I3 (review): `deleteConfirmId`, `resetConfirmId` and `editingId` are three
* independent bits of local state, but the row rendering treats them as
* mutually exclusive (edit -> delete -> reset -> normal, first match wins).
* Before this fix, none of the three setters cleared the other two, so if
* both `resetConfirmId` and `deleteConfirmId` were ever set to the same page
* id, the delete-confirm view would win (it's checked first) and cancelling
* it would fall through to reveal the reset-confirm view "unbidden" -- a
* destructive prompt the user never asked for.
*
* This is reachable any time two arm-clicks land in the same render pass
* (e.g. a fast double click / synthetic dual dispatch before React commits
* the first click's re-render) -- reproduced below by issuing both clicks
* inside a single `act()` batch, which is exactly what removes the re-render
* that would otherwise make the second button disappear before it can be
* clicked.
*/
describe('PagesPanel confirmation-state isolation (I3 review finding)', () => {
async function setupTwoPages() {
let ctx: ReturnType<typeof usePages> | null = null;
const Probe: React.FC = () => { ctx = usePages(); return null; };
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<Probe />
<PagesPanel />
</PageProvider>,
);
harness.act(() => { ctx!.addPage('About', 'about'); });
await harness.act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
return harness;
}
test('arming Reset then Delete for the same page in one batch shows Delete (render precedence), and cancelling Delete does NOT reveal a leftover Reset prompt', async () => {
const harness = await setupTwoPages();
// Both arm-clicks land before any re-render commits.
harness.act(() => {
clickByLabel(harness.container, 'Reset About to blank').click();
clickByLabel(harness.container, 'Delete About').click();
});
expect(harness.container.textContent).toContain('Delete "About"?');
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
// The fix: arming Delete clears any pending Reset confirmation for the
// same page, so cancelling Delete returns to the normal row, not a
// surprise Reset prompt.
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
expect(harness.container.textContent).not.toContain('Delete "About"?');
harness.unmount();
});
test('arming Delete then Reset for the same page in one batch shows Reset, and cancelling Reset does NOT reveal a leftover Delete prompt', async () => {
const harness = await setupTwoPages();
harness.act(() => {
clickByLabel(harness.container, 'Delete About').click();
clickByLabel(harness.container, 'Reset About to blank').click();
});
expect(harness.container.textContent).toContain('Clear every element from "About"?');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(harness.container.textContent).not.toContain('Delete "About"?');
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.unmount();
});
test('starting a Rename while a Reset confirmation is pending for the same page clears the pending Reset', async () => {
const harness = await setupTwoPages();
// Arm Reset, then (same batch) start editing the same row -- edit wins
// in render precedence, but the fix also clears resetConfirmId so
// cancelling the rename doesn't fall through to a stale Reset prompt.
harness.act(() => {
clickByLabel(harness.container, 'Reset About to blank').click();
clickByLabel(harness.container, 'Rename About').click();
});
const nameInput = harness.container.querySelector('input.control-input') as HTMLInputElement | null;
expect(nameInput).toBeTruthy();
expect(nameInput!.value).toBe('About');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.unmount();
});
});
@@ -0,0 +1,195 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EMPTY_CANVAS, PageProvider, usePages } from '../../state/PageContext';
import { PagesPanel } from './PagesPanel';
const withHeading = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' }, isCanvas: true,
props: { style: {}, tag: 'div' }, displayName: 'Container',
custom: {}, hidden: false, nodes: ['h1'], linkedNodes: {}, parent: null,
},
h1: {
type: { resolvedName: 'Heading' }, isCanvas: false,
props: { text: 'Keep me', level: 2 }, displayName: 'Heading',
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
},
});
/** Same real-timer-flush pattern as `PageContext.orphan-repair-wiring.test.tsx`
* -- `PageContext.loadState`/this feature's own reset handler both schedule
* their `actions.deserialize()` calls via `setTimeout(..., 0)` rather than
* applying them synchronously, so tests that exercise either path need to
* let a real macrotask turn run before asserting on the live Frame. */
async function flushTimers(harness: ReturnType<typeof renderEditorHarness>): Promise<void> {
await harness.act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
return btn;
}
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
const btn = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
if (!btn) throw new Error(`No button found with text "${text}"`);
return btn;
}
describe('resetting a page to EMPTY_CANVAS (Craft.js characterisation)', () => {
test('clears the canvas', () => {
const harness = renderEditorHarness({ initialState: withHeading });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
test('the reset is undoable through Craft history', () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
harness.act(() => { harness.actions.history.undo(); });
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.unmount();
});
});
describe('PagesPanel Reset Page control', () => {
test('a Reset button is rendered for every page row, and the dialog names the page', () => {
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
// Fresh PageProvider starts with exactly one page: "Home".
const erasers = harness.container.querySelectorAll('.fa-eraser');
expect(erasers.length).toBe(1);
expect(clickByLabel(harness.container, 'Reset Home to blank')).toBeTruthy();
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
expect(harness.container.textContent).toContain('Clear every element from "Home"?');
expect(harness.container.textContent).toContain('Ctrl+Z undoes this.');
harness.unmount();
});
test('the Header and Footer zone rows never grow a Reset control', () => {
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
const zoneRows = harness.container.querySelectorAll('.zone-row');
expect(zoneRows.length).toBe(2); // Header, Footer
zoneRows.forEach((row) => {
expect(row.querySelector('.fa-eraser')).toBeNull();
});
// No aria-label anywhere in the panel offers to reset the header/footer.
expect(harness.container.querySelector('[aria-label="Reset Header to blank"]')).toBeNull();
expect(harness.container.querySelector('[aria-label="Reset Footer to blank"]')).toBeNull();
harness.unmount();
});
test('clicking Reset then Cancel leaves the active page untouched', () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
harness.unmount();
});
test('clicking Reset then confirming blanks the ACTIVE page', async () => {
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<PagesPanel />
</PageProvider>,
);
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Reset page').click();
});
await flushTimers(harness);
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
test('resetting a page that is NOT on screen switches to it first, then blanks it -- ' +
'the switch-in load must resolve BEFORE the blank, or the blank would just get ' +
'overwritten by the page\'s real content', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Probe: React.FC = () => {
ctx = usePages();
return null;
};
const harness = renderEditorHarness({ initialState: withHeading });
harness.mountChild(
<PageProvider>
<Probe />
<PagesPanel />
</PageProvider>,
);
// addPage() saves the live Frame (withHeading) into Home's slot, adds
// "About", and switches the live Frame to About's (empty) canvas.
harness.act(() => { ctx!.addPage('About', 'about'); });
await flushTimers(harness);
expect(ctx!.activePageId).not.toBe('home');
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]); // About is blank
// Now reset Home while About is the active/on-screen page.
harness.act(() => {
clickByLabel(harness.container, 'Reset Home to blank').click();
});
harness.act(() => {
clickByText(harness.container, 'Reset page').click();
});
await flushTimers(harness);
// The live Frame now shows Home (switchPage ran) and it's blank (reset ran
// after the switch-in load, not before it).
expect(ctx!.activePageId).toBe('home');
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
// And the blank state actually persisted into Home's stored slot, not
// just transiently on the Frame: switch away and back, still blank.
const aboutId = ctx!.pages.find((p) => p.id !== 'home')!.id;
harness.act(() => { ctx!.switchPage(aboutId); });
await flushTimers(harness);
harness.act(() => { ctx!.switchPage('home'); });
await flushTimers(harness);
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
harness.unmount();
});
});
+190 -29
View File
@@ -1,6 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePages } from '../../state/PageContext'; import { useEditor } from '@craftjs/core';
import { usePages, EMPTY_CANVAS } from '../../state/PageContext';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { PageSettingsModal } from './PageSettingsModal';
export const PagesPanel: React.FC = () => { export const PagesPanel: React.FC = () => {
const { const {
@@ -14,7 +16,12 @@ export const PagesPanel: React.FC = () => {
addPage, addPage,
deletePage, deletePage,
renamePage, renamePage,
duplicatePage,
movePage,
setLandingPage,
updatePageSeo,
} = usePages(); } = usePages();
const { actions: editorActions } = useEditor();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newSlug, setNewSlug] = useState(''); const [newSlug, setNewSlug] = useState('');
@@ -22,6 +29,9 @@ export const PagesPanel: React.FC = () => {
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editSlug, setEditSlug] = useState(''); const [editSlug, setEditSlug] = useState('');
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [resetConfirmId, setResetConfirmId] = useState<string | null>(null);
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
const handleAdd = () => { const handleAdd = () => {
if (!newName.trim()) return; if (!newName.trim()) return;
@@ -42,11 +52,32 @@ export const PagesPanel: React.FC = () => {
setDeleteConfirmId(null); setDeleteConfirmId(null);
}; };
/**
* Blanks `pageId`'s canvas to `EMPTY_CANVAS`. `actions.deserialize()` acts
* on the LIVE `<Frame>`, so resetting a page that isn't currently on
* screen requires switching to it first. `switchPage` itself defers its
* own `actions.deserialize(targetState)` via `setTimeout(..., 0)` (see
* `PageContext.loadState`) rather than applying it synchronously -- so the
* blanking `setTimeout` scheduled here must run AFTER that one, or it
* would blank the frame and then have `switchPage`'s own deferred load
* immediately overwrite the blank with the target page's real content.
* Since `switchPage(pageId)` runs synchronously above (registering its
* internal setTimeout first) before this function schedules its own,
* same-delay `setTimeout` callbacks fire in registration order -- the
* switch's load always resolves before this reset does.
*/
const handleResetPage = (pageId: string): void => {
if (pageId !== activePageId) switchPage(pageId);
setTimeout(() => editorActions.deserialize(EMPTY_CANVAS), 0);
setResetConfirmId(null);
};
const startEditing = (page: { id: string; name: string; slug: string }) => { const startEditing = (page: { id: string; name: string; slug: string }) => {
setEditingId(page.id); setEditingId(page.id);
setEditName(page.name); setEditName(page.name);
setEditSlug(page.slug); setEditSlug(page.slug);
setDeleteConfirmId(null); setDeleteConfirmId(null);
setResetConfirmId(null);
}; };
const autoSlug = (name: string): string => { const autoSlug = (name: string): string => {
@@ -64,6 +95,33 @@ export const PagesPanel: React.FC = () => {
* differently-colored category. The active/editing state reuses the same * differently-colored category. The active/editing state reuses the same
* accent-outline treatment the page list already uses for the active page, * accent-outline treatment the page list already uses for the active page,
* so there's one consistent "this is what's currently open" affordance. */ * so there's one consistent "this is what's currently open" affordance. */
/* ---------- Per-page-row icon button ----------
* Shared 24x24 icon-button styling used by every action in the page row
* (SEO settings, rename, duplicate, reorder, set-as-home, delete) so a new
* action slots in looking identical to the pre-existing gear/pencil/trash
* buttons -- same dark-theme neutral treatment, `disabled` dims the icon
* (matching how `disabled` already reads elsewhere in this panel, e.g. the
* "Add Page" button), and `danger` reuses the existing delete-button
* accent color. */
const pageActionBtnStyle = (opts: { disabled?: boolean; danger?: boolean } = {}): React.CSSProperties => ({
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: opts.disabled
? 'var(--color-text-dim)'
: opts.danger
? 'var(--color-danger)'
: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: opts.disabled ? 'default' : 'pointer',
opacity: opts.disabled ? 0.5 : 1,
});
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({ const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -267,6 +325,60 @@ export const PagesPanel: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
) : resetConfirmId === page.id ? (
/* Reset-to-blank confirmation -- mirrors the delete confirmation
* above, since both are destructive per-page actions. */
<div
style={{
padding: 10,
background: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-danger)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
Clear every element from "{page.name}"? Your header, footer and other
pages are untouched, and the published site doesn't change until you
publish again. Ctrl+Z undoes this.
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleResetPage(page.id)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: '#fff',
background: 'var(--color-danger)',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Reset page
</button>
<button
onClick={() => setResetConfirmId(null)}
style={{
flex: 1,
padding: '5px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</div>
) : ( ) : (
/* Normal page item */ /* Normal page item */
<div <div
@@ -340,47 +452,89 @@ export const PagesPanel: React.FC = () => {
</div> </div>
</div> </div>
<div <div
style={{ display: 'flex', gap: 4, flexShrink: 0 }} style={{ display: 'flex', gap: 2, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 96 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button
onClick={() => movePage(page.id, 'up')}
disabled={pageIndex === 0}
data-tooltip="Move up"
aria-label={`Move ${page.name} up`}
style={pageActionBtnStyle({ disabled: pageIndex === 0 })}
>
<i className="fa fa-arrow-up" aria-hidden="true" />
</button>
<button
onClick={() => movePage(page.id, 'down')}
disabled={pageIndex === pages.length - 1}
data-tooltip="Move down"
aria-label={`Move ${page.name} down`}
style={pageActionBtnStyle({ disabled: pageIndex === pages.length - 1 })}
>
<i className="fa fa-arrow-down" aria-hidden="true" />
</button>
<button
onClick={() => duplicatePage(page.id)}
data-tooltip="Duplicate"
aria-label={`Duplicate ${page.name}`}
style={pageActionBtnStyle()}
>
<i className="fa fa-clone" aria-hidden="true" />
</button>
{isLanding ? (
<span
data-tooltip="This is the home page"
aria-label={`${page.name} is the home page`}
style={pageActionBtnStyle({ disabled: true })}
>
<i className="fa fa-home" aria-hidden="true" />
</span>
) : (
<button
onClick={() => setLandingPage(page.id)}
data-tooltip="Set as home page"
aria-label={`Set ${page.name} as the home page`}
style={pageActionBtnStyle()}
>
<i className="fa fa-home" aria-hidden="true" />
</button>
)}
<button
onClick={() => setSeoSettingsPageId(page.id)}
data-tooltip="Page Settings (SEO)"
aria-label={`Page settings for ${page.name}`}
style={pageActionBtnStyle()}
>
<i className="fa fa-cog" aria-hidden="true" />
</button>
<button <button
onClick={() => startEditing(page)} onClick={() => startEditing(page)}
data-tooltip="Rename" data-tooltip="Rename"
aria-label={`Rename ${page.name}`} aria-label={`Rename ${page.name}`}
style={{ style={pageActionBtnStyle()}
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
> >
<i className="fa fa-pencil" aria-hidden="true" /> <i className="fa fa-pencil" aria-hidden="true" />
</button> </button>
<button
onClick={() => {
setResetConfirmId(page.id);
setDeleteConfirmId(null);
}}
data-tooltip="Reset to blank"
aria-label={`Reset ${page.name} to blank`}
style={pageActionBtnStyle({ danger: true })}
>
<i className="fa fa-eraser" aria-hidden="true" />
</button>
{pages.length > 1 && !isLanding && ( {pages.length > 1 && !isLanding && (
<button <button
onClick={() => setDeleteConfirmId(page.id)} onClick={() => {
setDeleteConfirmId(page.id);
setResetConfirmId(null);
}}
data-tooltip="Delete" data-tooltip="Delete"
aria-label={`Delete ${page.name}`} aria-label={`Delete ${page.name}`}
style={{ style={pageActionBtnStyle({ danger: true })}
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
> >
<i className="fa fa-trash" aria-hidden="true" /> <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
@@ -492,6 +646,13 @@ export const PagesPanel: React.FC = () => {
+ Add Page + Add Page
</button> </button>
)} )}
<PageSettingsModal
open={seoSettingsPageId !== null}
onClose={() => setSeoSettingsPageId(null)}
page={seoSettingsPage}
updatePageSeo={updatePageSeo}
/>
</div> </div>
); );
}; };
@@ -0,0 +1,84 @@
import { describe, test, expect } from 'vitest';
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
describe('deriveVirtualRows', () => {
test('returns one row per item, labelled by the registered field', () => {
const rows = deriveVirtualRows('Features Grid', {
features: [{ title: 'Fast' }, { title: 'Secure' }],
});
expect(rows).toEqual([
{ index: 0, label: 'Fast' },
{ index: 1, label: 'Secure' },
]);
});
test('falls back to "<Fallback> N" when the label field is missing or blank', () => {
const rows = deriveVirtualRows('Features Grid', {
features: [{ title: '' }, { description: 'no title key' }],
});
expect(rows).toEqual([
{ index: 0, label: 'Feature 1' },
{ index: 1, label: 'Feature 2' },
]);
});
test('trims and truncates a long label to 40 characters with an ellipsis', () => {
const long = 'x'.repeat(60);
const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] });
expect(rows[0].label).toHaveLength(41);
expect(rows[0].label.endsWith('…')).toBe(true);
});
test('an unregistered component yields no rows', () => {
expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]);
});
test('a missing or non-array prop yields no rows instead of throwing', () => {
expect(deriveVirtualRows('Tabs', {})).toEqual([]);
expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]);
expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]);
});
test('a non-object item still gets a fallback label', () => {
expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([
{ index: 0, label: 'Link 1' },
]);
});
test('Hero, Call to Action and CTA Section derive rows from their shared ctas prop', () => {
const ctas = [
{ text: 'Get Started', href: '#', variant: 'primary' },
{ text: 'Learn More', href: '#learn', variant: 'outline' },
];
expect(deriveVirtualRows('Hero', { ctas })).toEqual([
{ index: 0, label: 'Get Started' },
{ index: 1, label: 'Learn More' },
]);
expect(deriveVirtualRows('Call to Action', { ctas })).toEqual([
{ index: 0, label: 'Get Started' },
{ index: 1, label: 'Learn More' },
]);
expect(deriveVirtualRows('CTA Section', { ctas })).toEqual([
{ index: 0, label: 'Get Started' },
{ index: 1, label: 'Learn More' },
]);
});
test('a CTA with no text falls back to "Button N"', () => {
const rows = deriveVirtualRows('Hero', {
ctas: [{ href: '#' }, { text: '', href: '#empty' }],
});
expect(rows).toEqual([
{ index: 0, label: 'Button 1' },
{ index: 1, label: 'Button 2' },
]);
});
test('every registry entry has a non-empty prop, label and fallback', () => {
for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) {
expect(spec.prop, `${name}.prop`).toBeTruthy();
expect(spec.label, `${name}.label`).toBeTruthy();
expect(spec.fallback, `${name}.fallback`).toBeTruthy();
}
});
});
@@ -0,0 +1,87 @@
/**
* Virtual Layers rows for components whose content lives in ARRAY PROPS
* rather than Craft child nodes.
*
* FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
* ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all
* render their items from a prop array, so Craft sees them as leaf nodes and
* the Layers tree showed nothing underneath them -- the "Layers doesn't show
* everything" report. These rows are display-only: they are not Craft nodes,
* cannot be dragged, and selecting one selects the PARENT node (plus asks the
* array editor to scroll that item into view -- see LayerFocusContext).
*
* ColumnLayout is deliberately absent: it uses real `<Element canvas>`
* children, which LayersPanel already nests correctly.
*/
export interface VirtualRow {
index: number;
label: string;
}
export interface VirtualChildSpec {
/** Name of the array prop holding the items. */
prop: string;
/** Per-item field used as the row label. */
label: string;
/** Used as "<fallback> <n>" when the label field is missing or blank. */
fallback: string;
}
/** Keyed by the component's craft `displayName` -- the same string
* `LayerNode` already resolves and shows as the row text.
*
* Verified against each component's actual item interface and
* `craft.props` defaults (see task-9-report.md, Step 1):
* - Tabs items are `{ label, content }`, not `{ title }` -- registry
* corrected from `title` to `label`.
* - ContentSlider items are `{ heading, text, ... }`, not `{ title }` --
* registry corrected from `title` to `heading`.
* Every other entry (prop name and label field) matched the component as
* originally drafted.
*
* Hero, Call to Action and CTA Section were added after a follow-up sweep
* of every component under src/components/ for top-level array props (not
* just the 12 files initially listed) turned up three more leaf components
* with the identical pattern: all three render a `ctas?: CtaButton[]` prop
* (see sections/_cta-helpers.tsx) whose items are `{ text, href, variant?,
* target? }`, so `text` is the label field. Verified against each file's
* `craft.displayName` and `craft.props.ctas` defaults. That sweep found no
* further candidates -- the only other array fields in the tree are nested
* one level down inside already-covered items (ContactFormField.options,
* PricingPlan.features), not top-level component props. */
export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec> = {
'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' },
Tabs: { prop: 'tabs', label: 'label', fallback: 'Tab' },
Accordion: { prop: 'items', label: 'title', fallback: 'Item' },
'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' },
Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' },
Gallery: { prop: 'images', label: 'alt', fallback: 'Image' },
'Content Slider': { prop: 'slides', label: 'heading', fallback: 'Slide' },
'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' },
Menu: { prop: 'links', label: 'text', fallback: 'Link' },
'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' },
Navbar: { prop: 'links', label: 'text', fallback: 'Link' },
'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' },
Hero: { prop: 'ctas', label: 'text', fallback: 'Button' },
'Call to Action': { prop: 'ctas', label: 'text', fallback: 'Button' },
'CTA Section': { prop: 'ctas', label: 'text', fallback: 'Button' },
};
const MAX_LABEL = 40;
export function deriveVirtualRows(displayName: string, props: Record<string, any>): VirtualRow[] {
const spec = VIRTUAL_CHILD_PROPS[displayName];
if (!spec) return [];
const items = props?.[spec.prop];
if (!Array.isArray(items)) return [];
return items.map((item, index) => {
const raw = item && typeof item === 'object' ? item[spec.label] : undefined;
const text = typeof raw === 'string' ? raw.trim() : '';
if (!text) return { index, label: `${spec.fallback} ${index + 1}` };
const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text;
return { index, label };
});
}
+11 -4
View File
@@ -17,6 +17,7 @@ import {
PricingStylePanel, PricingStylePanel,
BackgroundSectionStylePanel, BackgroundSectionStylePanel,
GenericPropsEditor, GenericPropsEditor,
HtmlStylePanel,
} from './styles'; } from './styles';
/* ================================================================ /* ================================================================
@@ -75,8 +76,10 @@ export const GuidedStyles: React.FC = () => {
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName); const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
const isPricing = /^pricing/i.test(typeName); const isPricing = /^pricing/i.test(typeName);
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName); const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
// Utility types that need minimal controls const isHtml = /^html$/i.test(typeName);
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName); // Utility types that need minimal controls. HTML is deliberately NOT here
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
const isUtility = /^divider$|^spacer$/i.test(typeName);
// Icon for the type badge // Icon for the type badge
const typeIcon = isText ? 'fa-font' const typeIcon = isText ? 'fa-font'
@@ -90,6 +93,7 @@ export const GuidedStyles: React.FC = () => {
: isForm ? 'fa-wpforms' : isForm ? 'fa-wpforms'
: isSocial ? 'fa-share-alt' : isSocial ? 'fa-share-alt'
: isSection ? 'fa-th-large' : isSection ? 'fa-th-large'
: isHtml ? 'fa-code'
: isUtility ? 'fa-ellipsis-h' : isUtility ? 'fa-ellipsis-h'
: 'fa-cube'; : 'fa-cube';
@@ -158,11 +162,14 @@ export const GuidedStyles: React.FC = () => {
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */} {/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />} {isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */} {/* HTML -- code editor only */}
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />} {isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* FALLBACK: Anything not matched above */} {/* FALLBACK: Anything not matched above */}
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && ( {!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} /> <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)} )}
</div> </div>
@@ -0,0 +1,97 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { PageProvider, usePages } from '../../state/PageContext';
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { SiteDesignPanel } from './SiteDesignPanel';
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
* a plain `input.value = x` assignment doesn't go through it, so React never
* sees the change and skips onChange. */
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Real-provider integration test -- NO mocks. Exercises the actual
* `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations,
* which route the component's blank trees through the real
* `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see
* `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the
* task brief calls out specifically: a tree shape that fails sanitisation
* would silently fall back to an empty canvas (masking a bug) rather than
* throwing, so asserting only on mock call arguments (as
* `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape --
* the mocked assertions there only prove the component PASSED a
* `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't
* prove that object is actually valid input to the real pipeline.
*/
describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => {
test('confirming produces a real, valid single blank Home page + header + footer + default design', () => {
let pageCtx: ReturnType<typeof usePages> | null = null;
let designCtx: ReturnType<typeof useSiteDesign> | null = null;
const Probe: React.FC = () => {
pageCtx = usePages();
designCtx = useSiteDesign();
return null;
};
const harness = renderEditorHarness();
harness.mountChild(
<EditorConfigProvider config={{
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
}}>
<PageProvider>
<SiteDesignProvider>
<Probe />
<SiteDesignPanel />
</SiteDesignProvider>
</PageProvider>
</EditorConfigProvider>,
);
// Dirty the design tokens first so resetToDefaults has something to undo.
harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); });
expect(designCtx!.design.primaryColor).toBe('#000000');
harness.act(() => {
(harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click();
});
const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
harness.act(() => { setInputValue(input, 'example.com'); });
harness.act(() => {
(harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click();
});
// Exactly one page, "Home", landing-page slug -- and its stored
// craftState is real, parseable Craft.js state with a Container ROOT
// and no children (i.e. sanitizeAiTree did NOT reject the tree and fall
// back to a silent empty canvas for the wrong reason -- it's genuinely
// ROOT with zero nodes because that's what we asked for).
expect(pageCtx!.pages).toHaveLength(1);
expect(pageCtx!.pages[0].name).toBe('Home');
expect(pageCtx!.pages[0].slug).toBe('index');
const homeState = JSON.parse(pageCtx!.pages[0].craftState!);
expect(homeState.ROOT.type.resolvedName).toBe('Container');
expect(homeState.ROOT.nodes).toEqual([]);
const headerState = JSON.parse(pageCtx!.headerPage.craftState!);
expect(headerState.ROOT.type.resolvedName).toBe('Container');
expect(headerState.ROOT.nodes).toEqual([]);
expect(headerState.ROOT.props.tag).toBe('header');
const footerState = JSON.parse(pageCtx!.footerPage.craftState!);
expect(footerState.ROOT.type.resolvedName).toBe('Container');
expect(footerState.ROOT.nodes).toEqual([]);
expect(footerState.ROOT.props.tag).toBe('footer');
// Design tokens are back to the defaults.
expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN);
harness.unmount();
});
});
@@ -0,0 +1,213 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* -------------------------------------------------------------------------
* Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that
* confirming calls the right context functions with tree-shaped arguments,
* and that standalone mode (empty siteDomain) hides the entry point
* entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/
* `resetToDefaults` implementations (and whether the blank trees this
* component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are
* covered separately, WITHOUT mocks, in
* `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and
* file-scoped, so it can't be selectively "undone" partway through one file
* for a real-provider test.
* ---------------------------------------------------------------------- */
const replaceAllPages = vi.fn();
const setHeader = vi.fn();
const setFooter = vi.fn();
const resetToDefaults = vi.fn();
let mockSiteDomain = 'example.com';
vi.mock('../../state/PageContext', () => ({
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
}));
vi.mock('../../state/SiteDesignContext', () => ({
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
DEFAULT_SITE_DESIGN: {},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }),
}));
import { SiteDesignPanel, siteResetConfirmMatches } from './SiteDesignPanel';
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<SiteDesignPanel />);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
* a plain `input.value = x` assignment doesn't go through it, so React never
* sees the change and skips onChange. Using the real native setter (same
* pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent
* `input` event register as a genuine value change. */
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* Reach past React's OWN disabled-button protection to grab the `onClick`
* function it attached to a DOM node, and call it directly.
*
* Verified empirically (see task-15 follow-up investigation) that neither
* `el.disabled = false; el.click()` nor a raw `dispatchEvent(new
* MouseEvent('click', ...))` actually invokes a React `onClick` handler once
* React has rendered the element with `disabled` truthy: react-dom's event
* system special-cases form controls and refuses to dispatch synthetic
* click/similar events against its own last-rendered `disabled` prop,
* regardless of what the live DOM property says. That's a second, redundant
* safety net on top of the native browser behavior -- but it also means a
* pure DOM-level "disabled bypass" can't reach `handleResetSite` at all
* through the button, and so can't exercise the handler's OWN guard the way
* this test needs to. Pulling `onClick` off the fiber's stashed props
* (`__reactProps$...`, the same object React itself calls into) and
* invoking it directly is what actually reaches `handleResetSite` -- the
* same code path a differently-wired future trigger (a keyboard shortcut, a
* second button, a copy-paste bug) would also go through without passing
* back through the `disabled` gate at all.
*/
function invokeReactOnClick(el: HTMLElement): void {
const propsKey = Object.keys(el).find((k) => k.startsWith('__reactProps$'));
const onClick = propsKey ? (el as unknown as Record<string, { onClick?: () => void }>)[propsKey].onClick : undefined;
if (!onClick) throw new Error('no React onClick prop found on element');
onClick();
}
describe('siteResetConfirmMatches -- the guard predicate itself', () => {
test('matches only an exact (trimmed) domain match', () => {
expect(siteResetConfirmMatches('example.com', 'example.com')).toBe(true);
expect(siteResetConfirmMatches('example.co', 'example.com')).toBe(false);
expect(siteResetConfirmMatches('', 'example.com')).toBe(false);
expect(siteResetConfirmMatches(' example.com ', 'example.com')).toBe(true);
});
test('an empty domain never matches, even against an empty typed value -- ' +
'this is the guard that must hold even if the (currently hidden) entry ' +
'point were ever reachable in standalone mode', () => {
expect(siteResetConfirmMatches('', '')).toBe(false);
expect(siteResetConfirmMatches(' ', '')).toBe(false);
});
});
describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
test('the confirm button is disabled until the domain is typed exactly', () => {
mockSiteDomain = 'example.com';
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'example.co'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
act(() => { setInputValue(input, 'example.com'); });
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
unmount();
});
test('confirming blanks pages, header, footer and design tokens', () => {
mockSiteDomain = 'example.com';
replaceAllPages.mockClear();
setHeader.mockClear();
setFooter.mockClear();
resetToDefaults.mockClear();
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'example.com'); });
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
expect(replaceAllPages).toHaveBeenCalledTimes(1);
const pagesArg = replaceAllPages.mock.calls[0][0];
expect(pagesArg).toHaveLength(1);
expect(pagesArg[0].name).toBe('Home');
// The replacement is a SerializedTreeNode (recursive tree), not a flat
// craft-state entry: a real Container root with no children.
expect(pagesArg[0].tree.type.resolvedName).toBe('Container');
expect(pagesArg[0].tree.nodes).toEqual([]);
expect(setHeader).toHaveBeenCalledTimes(1);
expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(setFooter).toHaveBeenCalledTimes(1);
expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container');
expect(resetToDefaults).toHaveBeenCalledTimes(1);
// The panel closes its own dialog back up after confirming.
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
unmount();
});
test('the handler itself refuses a non-matching value, even when its onClick is invoked ' +
'directly (bypassing `disabled` and React\'s own disabled-click suppression) -- proves ' +
'the guard is load-bearing in handleResetSite, not just a UI affordance a different ' +
'trigger path could route around', () => {
mockSiteDomain = 'example.com';
replaceAllPages.mockClear();
setHeader.mockClear();
setFooter.mockClear();
resetToDefaults.mockClear();
render();
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
act(() => { setInputValue(input, 'not-the-domain'); });
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
expect(confirm.disabled).toBe(true); // sanity: the UI affordance is still doing its job too
act(() => { invokeReactOnClick(confirm); });
expect(replaceAllPages).not.toHaveBeenCalled();
expect(setHeader).not.toHaveBeenCalled();
expect(setFooter).not.toHaveBeenCalled();
expect(resetToDefaults).not.toHaveBeenCalled();
// The dialog is still open -- handleResetSite returned early before
// reaching its own close-the-dialog cleanup.
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeTruthy();
unmount();
});
test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' +
'while non-standalone mode shows it -- an empty typed input must never trivially ' +
'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' +
'so this fails if the whole feature -- not just the guard -- were ever removed.', () => {
mockSiteDomain = '';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull();
expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull();
expect(container.textContent).not.toContain('Danger zone');
expect(container.textContent).not.toContain('Reset Entire Site');
unmount();
mockSiteDomain = 'example.com';
render();
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
expect(container.textContent).toContain('Danger zone');
unmount();
});
});
+179
View File
@@ -1,6 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext'; import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { usePages } from '../../state/PageContext';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { SerializedTreeNode } from '../../types/sitesmith';
import { FONT_FAMILIES } from '../../constants/presets'; import { FONT_FAMILIES } from '../../constants/presets';
import { AssetPicker } from '../../ui/AssetPicker';
import { inputStyle } from './styles/shared';
type DesignTab = 'basic' | 'advanced'; type DesignTab = 'basic' | 'advanced';
@@ -155,12 +160,94 @@ const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
</div> </div>
); );
/* ---------- Reset Entire Site (danger zone) ---------- */
// Blank replacement trees for the "Reset Entire Site" escape hatch. Shaped
// as a `SerializedTreeNode` (type + props + nodes), NOT a flat Craft.js
// state entry -- this is what `replaceAllPages`/`setHeader`/`setFooter`
// feed into `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft`
// (see `state/PageContext.tsx`/`utils/craft-tree.ts`). A single `Container`
// root with no children mirrors the (unexported) EMPTY_CANVAS/EMPTY_HEADER/
// EMPTY_FOOTER constants already used elsewhere for "blank".
const BLANK_PAGE_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' },
nodes: [],
};
const BLANK_HEADER_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: {
style: { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' },
tag: 'header',
},
nodes: [],
};
const BLANK_FOOTER_TREE: SerializedTreeNode = {
type: { resolvedName: 'Container' },
props: {
style: { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' },
tag: 'footer',
},
nodes: [],
};
/**
* Single source of truth for "has the user typed enough to arm the confirm
* button" -- used for the `disabled` attribute/cursor/opacity AND, more
* importantly, inside `handleResetSite` itself. `disabled` is a UI
* affordance, not a safety mechanism (it only stops a plain mouse click);
* for the one action in this app that irreversibly wipes a user's whole
* site draft, the real guard has to live in the handler, checked against
* this exact same predicate rather than a second hand-rolled comparison
* that could drift out of sync with it.
*
* An empty/falsy `domain` always returns `false`, even if `typed` is also
* empty -- `''.trim() === ''.trim()` would otherwise "match" trivially.
* Standalone mode (no `WHP_CONFIG`) has `siteDomain === ''`, and while the
* entry point that would let a user reach this code is hidden in that case
* (see the `siteDomain &&` guard around the whole danger zone below), this
* function must not depend on that -- it has to fail safe on its own if
* ever reached with no configured domain.
*
* Exported so it's directly unit-testable without needing a way to render
* the (deliberately unreachable-when-`siteDomain`-is-empty) confirm UI.
*/
export function siteResetConfirmMatches(typed: string, domain: string): boolean {
return !!domain && typed.trim() === domain.trim();
}
/* ---------- Main SiteDesignPanel ---------- */ /* ---------- Main SiteDesignPanel ---------- */
export const SiteDesignPanel: React.FC = () => { export const SiteDesignPanel: React.FC = () => {
const { design, updateDesign, resetToDefaults } = useSiteDesign(); const { design, updateDesign, resetToDefaults } = useSiteDesign();
const { replaceAllPages, setHeader, setFooter } = usePages();
const { whpConfig } = useEditorConfig();
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
// an empty typed input would then trivially "match" an empty siteDomain,
// arming the destructive confirm button with no guard at all. The entry
// point itself is hidden in that case (see the `siteDomain &&` guard below).
const siteDomain = whpConfig?.siteDomain ?? '';
const [siteResetOpen, setSiteResetOpen] = useState(false);
const [siteResetTyped, setSiteResetTyped] = useState('');
const [tab, setTab] = useState<DesignTab>('basic'); const [tab, setTab] = useState<DesignTab>('basic');
const handleResetSite = (): void => {
// Load-bearing guard -- see `siteResetConfirmMatches`'s docstring. Not
// just a UI nicety: this must hold even if the confirm button's
// `disabled` attribute were ever bypassed (a synthetic click, or a
// future refactor that drops it).
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
setHeader(BLANK_HEADER_TREE);
setFooter(BLANK_FOOTER_TREE);
resetToDefaults();
setSiteResetOpen(false);
setSiteResetTyped('');
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Header */} {/* Header */}
@@ -200,6 +287,19 @@ export const SiteDesignPanel: React.FC = () => {
{/* Basic tab */} {/* Basic tab */}
{tab === 'basic' && ( {tab === 'basic' && (
<> <>
<div className="guided-section">
<label className="guided-section-label">Favicon</label>
<AssetPicker
value={design.favicon}
onChange={(url) => updateDesign({ favicon: url })}
mediaType="image"
variant="full"
placeholder="Or paste favicon URL..."
/>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', margin: '4px 0 0' }}>
Shown in browser tabs and bookmarks. Applies site-wide.
</p>
</div>
<ColorField <ColorField
label="Primary Color" label="Primary Color"
value={design.primaryColor} value={design.primaryColor}
@@ -357,6 +457,85 @@ export const SiteDesignPanel: React.FC = () => {
</p> </p>
</div> </div>
{/* Reset Entire Site (danger zone) -- hidden entirely in standalone
mode, where siteDomain is '' and typing nothing would trivially
satisfy an empty-string match. */}
{siteDomain && (
<div
style={{
marginTop: 18, padding: 12,
border: '1px solid rgba(239,68,68,0.35)',
borderRadius: 'var(--radius-sm)',
background: 'rgba(239,68,68,0.06)',
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
Danger zone
</div>
{!siteResetOpen ? (
<button
data-action="open-site-reset"
onClick={() => setSiteResetOpen(true)}
style={{
width: '100%', padding: '8px 12px', fontSize: 11, fontWeight: 600,
color: '#ef4444', background: 'transparent',
border: '1px solid rgba(239,68,68,0.5)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
Reset Entire Site
</button>
) : (
<>
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5, margin: '0 0 8px' }}>
This blanks <strong>every page</strong>, the header, the footer, all
design tokens (colors, fonts, radii), and your custom head code and
favicon, leaving one empty Home page. Uploaded images are kept.
<strong> This cannot be undone.</strong> Your published site stays as it
is until you publish again — but the editor auto-saves roughly every 30
seconds, so the blank version becomes your saved draft shortly after.
</p>
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '0 0 4px' }}>
Type <code>{siteDomain}</code> to confirm:
</p>
<input
data-testid="site-reset-domain"
value={siteResetTyped}
onChange={(e) => setSiteResetTyped(e.target.value)}
placeholder={siteDomain}
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button
data-action="confirm-site-reset"
disabled={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
onClick={handleResetSite}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
color: '#fff', background: '#ef4444', border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 'pointer' : 'not-allowed',
opacity: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 1 : 0.5,
}}
>
Reset everything
</button>
<button
onClick={() => { setSiteResetOpen(false); setSiteResetTyped(''); }}
style={{
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
Cancel
</button>
</div>
</>
)}
</div>
)}
</div> </div>
); );
}; };
@@ -1,6 +1,7 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared'; import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
import { useLayerFocus } from '../../left/LayerFocusContext';
/* ---------- Shared array-item field editor ---------- /* ---------- Shared array-item field editor ----------
Extracted from SectionTypePanel and GenericPropsEditor, which both had a Extracted from SectionTypePanel and GenericPropsEditor, which both had a
@@ -18,7 +19,26 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
const sampleItem = arrayItems[0] || {}; const sampleItem = arrayItems[0] || {};
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : []; const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
// Layers panel -> array editor "scroll to this item" hookup. The outer
// <div ref={rootRef}> wraps ArrayPropEditor's rendered cards (the actual
// per-item background box lives in shared.tsx's ArrayPropEditor, which is
// also used by MediaStylePanel/FormStylePanel -- rather than touch that
// shared component for one consumer, the data-array-item tag below goes
// on the content renderItem returns, which is enough for scrollIntoView
// to bring the right card into the viewport.
const { focus } = useLayerFocus();
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!focus || focus.prop !== propKey) return;
const card = rootRef.current?.querySelector(`[data-array-item="${propKey}:${focus.index}"]`);
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
// `focus.nonce` is in the dep list so clicking the SAME row twice
// re-scrolls (the request object is otherwise identical).
}, [focus?.nonce, focus?.prop, focus?.index, propKey]);
return ( return (
<div ref={rootRef}>
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}> <CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
<ArrayPropEditor <ArrayPropEditor
selectedId={selectedId} selectedId={selectedId}
@@ -29,6 +49,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
return ( return (
<input <input
type="text" type="text"
data-array-item={`${propKey}:${index}`}
value={String(item)} value={String(item)}
onChange={(e) => { onChange={(e) => {
actions.setProp(selectedId, (props: any) => { actions.setProp(selectedId, (props: any) => {
@@ -42,7 +63,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
); );
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <div data-array-item={`${propKey}:${index}`} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{itemFields.map((field) => { {itemFields.map((field) => {
const fieldVal = item[field]; const fieldVal = item[field];
if (typeof fieldVal === 'boolean') { if (typeof fieldVal === 'boolean') {
@@ -123,5 +144,6 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
} }
/> />
</CollapsibleSection> </CollapsibleSection>
</div>
); );
}; };
@@ -4,6 +4,7 @@ import {
BG_COLORS, BG_COLORS,
RADIUS_PRESETS, RADIUS_PRESETS,
SPACING_PRESETS, SPACING_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -11,16 +12,49 @@ import {
ColorSwatchGrid, ColorSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
ColorPickerField,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
autoTextColor, autoTextColor,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
/* ---------- BUTTON ---------- */ /* ---------- BUTTON ---------- */
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const setButtonColor = useCallback( const setButtonColor = useCallback(
(bgColor: string) => { (bgColor: string) => {
@@ -61,6 +95,16 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
actions.setProp(selectedId, (props: any) => { props.href = v; }); actions.setProp(selectedId, (props: any) => { props.href = v; });
}} }}
/> />
<div className="guided-section">
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
<input
type="checkbox"
checked={nodeProps.target === '_blank'}
onChange={(e) => setProp('target', e.target.checked ? '_blank' : '_self')}
/>
Open in new tab
</label>
</div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid <PresetButtonGrid
@@ -77,6 +121,70 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
onSelect={(v) => setPropStyle('padding', v)} onSelect={(v) => setPropStyle('padding', v)}
/> />
</div> </div>
{/* Hover state -- rendered into a scoped <style>...:hover{} block by
ButtonLink.toHtml (published export only; not shown live in the
editor canvas beyond the hover preview ButtonLink itself does). */}
<CollapsibleSection title="Hover State" defaultOpen={false}>
<ColorPickerField
label="Hover Background"
value={nodeProps.hoverBg || ''}
onChange={(v) => setProp('hoverBg', v)}
/>
<ColorPickerField
label="Hover Text Color"
value={nodeProps.hoverColor || ''}
onChange={(v) => setProp('hoverColor', v)}
/>
</CollapsibleSection>
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
/>
</CollapsibleSection>
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
</> </>
); );
}; };
@@ -10,18 +10,40 @@ import {
ColorSwatchGrid, ColorSwatchGrid,
GradientSwatchGrid, GradientSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
NumericUnitInput,
labelStyle, labelStyle,
inputStyle, inputStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
/* ---------- CONTAINER / SECTION ---------- */ // Vertical Alignment options shown to the user identically regardless of
// which CSS property they end up mapped to (align-items for the Columns
// flex ROW vs. justify-content for Container/Section's flex COLUMN root --
// see the per-type branch below).
const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
{ label: 'Top', value: 'flex-start' },
{ label: 'Center', value: 'center' },
{ label: 'Bottom', value: 'flex-end' },
{ label: 'Stretch', value: 'stretch' },
];
/* ---------- CONTAINER / SECTION / COLUMNS ---------- */
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
// flex-ROW case (align its columns via align-items, aligning uneven
// column heights) from the flex-COLUMN case (Container/Section, which
// vertically center/position their OWN content via justify-content,
// paired with a Min Height control so centering is meaningful).
const isColumns = nodeProps.columns !== undefined || nodeProps.split !== undefined;
const vAlignKey = isColumns ? 'alignItems' : 'justifyContent';
return ( return (
<> <>
{nodeProps.cssId !== undefined && ( {nodeProps.cssId !== undefined && (
@@ -94,6 +116,30 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
))} ))}
</div> </div>
</div> </div>
<div className="guided-section">
<SectionLabel>Vertical Alignment</SectionLabel>
<PresetButtonGrid
presets={VERTICAL_ALIGN_OPTIONS}
activeValue={style[vAlignKey] as string}
onSelect={(v) => setPropStyle(vAlignKey, v)}
/>
</div>
{!isColumns && (
<div className="guided-section">
<SectionLabel>Min Height</SectionLabel>
<NumericUnitInput
value={(style.minHeight as string) || ''}
onChange={(v) => setPropStyle('minHeight', v)}
units={['px', 'vh', '%']}
placeholder="auto"
/>
</div>
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
</> </>
); );
}; };
@@ -1,7 +1,8 @@
import React from 'react'; import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { labelStyle, inputStyle, sectionGap } from './shared'; import { labelStyle, inputStyle, sectionGap } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { useLayerFocus } from '../../left/LayerFocusContext';
interface Feature { interface Feature {
title?: string; description?: string; icon?: string; title?: string; description?: string; icon?: string;
@@ -27,10 +28,24 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]); mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; }); const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
// Layers panel -> array editor "scroll to this item" hookup. See
// ArrayItemFields.tsx for the generic-editor counterpart; this component
// keeps its own per-feature cards, so it tags/scrolls them directly.
const { focus } = useLayerFocus();
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!focus || focus.prop !== 'features') return;
const card = rootRef.current?.querySelector(`[data-array-item="features:${focus.index}"]`);
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
// `focus.nonce` is in the dep list so clicking the SAME row twice
// re-scrolls (the request object is otherwise identical).
}, [focus?.nonce, focus?.prop, focus?.index]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div ref={rootRef} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((feat, i) => ( {list.map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}> <div key={i} data-array-item={`features:${i}`} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} /> <input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button> <button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
@@ -1,8 +1,11 @@
import React from 'react'; import React, { useState } from 'react';
import { useEditor } from '@craftjs/core';
import { storeWebhookSecret } from '../../../utils/form-webhook-secret';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
RADIUS_PRESETS, RADIUS_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -10,29 +13,353 @@ import {
ColorSwatchGrid, ColorSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ArrayPropEditor,
SpacingControl,
BorderControl,
BorderValue,
AnimationControl,
VisibilityControl,
buildBorderShorthand,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
fake "types" (textarea/select) that take their own ContactForm render
branch instead of an <input type>. Kept as a local list (rather than
importing the runtime array from utils/escape.ts) since this is
presentation-only -- the actual security boundary is enforced in
ContactForm.toHtml via sanitizeInputType, not here. */
const CONTACT_FIELD_TYPES = [
'text', 'email', 'tel', 'number', 'password', 'url', 'search', 'date',
'checkbox', 'radio', 'textarea', 'select',
];
const moveBtnStyle: React.CSSProperties = {
flex: 1, padding: '3px 6px', fontSize: 10, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
function parseBorderValue(v: unknown): BorderValue {
const s = typeof v === 'string' ? v.trim() : '';
if (!s || s === 'none') return { width: '', style: 'none', color: '' };
const m = s.match(/^(\S+)\s+(\S+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '' };
return { width: m[1], style: m[2], color: m[3] };
}
const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'Top' | 'Right' | 'Bottom' | 'Left' }[] = [
{ side: 'top', suffix: 'Top' },
{ side: 'right', suffix: 'Right' },
{ side: 'bottom', suffix: 'Bottom' },
{ side: 'left', suffix: 'Left' },
];
const hintStyle: React.CSSProperties = { fontSize: 10, color: '#71717a', margin: '4px 0 0' };
/**
* Does this look like a webhook URL the publish step will accept?
*
* Mirrors the shape `FormRelayProvisioner::upsertWebhookToken()` enforces
* (absolute https, a host, no whitespace/control characters) closely enough to
* warn in the panel. It is a HINT, not a gate -- the server-side check is the
* real one, and this deliberately never edits or blocks the value.
*/
export function isHttpsWebhookUrl(value: unknown): boolean {
const v = typeof value === 'string' ? value.trim() : '';
if (v === '' || /[\s\x00-\x1F\x7F]/.test(v)) return false;
try {
const u = new URL(v);
return u.protocol === 'https:' && u.hostname !== '';
} catch {
return false;
}
}
/* ---------- Webhook shared secret: WRITE-ONLY field ----------
The raw secret lives in this component's local state and nowhere else. On
blur it is POSTed to the panel endpoint, which returns an opaque id; only
that id is handed to `onStored` (and thence to a craft prop). The field is
then cleared, because there is no read route and nothing to show back --
the UI offers set / replace / clear, never "view".
MOUNT THIS WITH `key={selectedId}`. GuidedStyles renders <FormStylePanel>
with no key, so a selection change re-renders this component rather than
remounting it, and React keeps `draft`/`status`. A failed store deliberately
RETAINS the draft (so a 429 or a network blip doesn't make the customer
retype a pasted key) -- which means without the key, clicking a second
contact form shows node A's raw secret in node B's field, and the next blur
assigns the returned id to the wrong form and burns a slot against the
per-site cap. The `status` banner leaks the same way. */
export const WebhookSecretField: React.FC<{
secretId: string;
onStored: (secretId: string) => void;
onRemoved: () => void;
}> = ({ secretId, onStored, onRemoved }) => {
const [draft, setDraft] = useState('');
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [error, setError] = useState('');
const save = async () => {
const raw = draft.trim();
if (raw === '' || status === 'saving') return;
setStatus('saving');
setError('');
const result = await storeWebhookSecret(raw);
if (result.ok && result.secretId) {
// Only the id crosses this line. The raw value is dropped here and is
// never written to a prop, to storage, or back into the input.
onStored(result.secretId);
setDraft('');
setStatus('saved');
} else {
setStatus('error');
setError(result.error || 'Could not store the secret.');
}
};
return (
<div style={sectionGap}>
<label style={labelStyle} htmlFor="whp-webhook-secret">Shared secret (optional)</label>
<input
id="whp-webhook-secret"
data-testid="webhook-secret-input"
type="password"
autoComplete="new-password"
value={draft}
onChange={(e) => { setDraft(e.target.value); if (status !== 'idle') { setStatus('idle'); setError(''); } }}
onBlur={() => { void save(); }}
placeholder={secretId ? 'Paste a new secret to replace' : 'Paste the secret from your receiver'}
style={inputStyle}
/>
{secretId && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
{/* "Clear", not "Remove": this only drops the form's reference to the
key. The stored key file stays on the server and still counts
toward the per-site cap -- nothing deletes one, so a button
labelled Remove would be telling the customer they had reclaimed
a slot right up until the 429 that says otherwise. */}
<button
data-testid="webhook-secret-remove"
onClick={onRemoved}
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
>
Clear
</button>
</div>
<p style={hintStyle}>
Clearing stops this form using the secret; the stored key stays on the server.
</p>
</>
)}
{status === 'saving' && <p style={hintStyle}>Storing…</p>}
{status === 'saved' && <p data-testid="webhook-secret-saved" style={{ ...hintStyle, color: '#22c55e' }}>Secret stored.</p>}
{status === 'error' && <p data-testid="webhook-secret-error" style={{ ...hintStyle, color: '#f87171' }}>{error}</p>}
<p style={hintStyle}>
Stored on the server and never shown again — paste a new one to replace it. Used to sign
(or authorise) each delivery so your receiver can verify it came from this site.
</p>
</div>
);
};
/* ---------- FORM ---------- */ /* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const style = nodeProps.style || {}; const style = nodeProps.style || {};
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
// so their relay controls stay exactly as they were.
const isWebhook = nodeProps.destinationType === 'webhook';
const updateField = (index: number, patch: Record<string, any>) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.fields || [])];
updated[index] = { ...updated[index], ...patch };
props.fields = updated;
});
};
const moveField = (index: number, direction: -1 | 1) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.fields || [])];
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= updated.length) return;
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
props.fields = updated;
});
};
return ( return (
<> <>
{/* ContactForm field editor: add/remove/reorder fields, set each
field's label, name, type (full allowlist + textarea/select),
options (select only), and required flag. */}
{nodeProps.fields !== undefined && Array.isArray(nodeProps.fields) && (
<CollapsibleSection title="Fields">
<ArrayPropEditor
selectedId={selectedId}
propKey="fields"
items={nodeProps.fields}
renderItem={(item: any, index: number) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
type="text"
value={item.label || ''}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="Label"
style={smallInputStyle}
/>
<input
type="text"
value={item.name || ''}
onChange={(e) => updateField(index, { name: e.target.value })}
placeholder="Field name (e.g. email)"
style={smallInputStyle}
/>
<input
type="text"
value={item.placeholder || ''}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
placeholder="Placeholder"
style={smallInputStyle}
/>
<select
value={item.type || 'text'}
onChange={(e) => updateField(index, { type: e.target.value })}
style={{ ...smallInputStyle, cursor: 'pointer' }}
>
{CONTACT_FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{item.type === 'select' && (
<input
type="text"
value={(item.options || []).join(', ')}
onChange={(e) => updateField(index, {
options: e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean),
})}
placeholder="Options (comma-separated)"
style={smallInputStyle}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!item.required}
onChange={(e) => updateField(index, { required: e.target.checked })}
/>
Required
</label>
<div style={{ display: 'flex', gap: 4 }}>
<button disabled={index === 0} onClick={() => moveField(index, -1)} style={{ ...moveBtnStyle, opacity: index === 0 ? 0.4 : 1 }} title="Move up">
<i className="fa fa-arrow-up" />
</button>
<button disabled={index === nodeProps.fields.length - 1} onClick={() => moveField(index, 1)} style={{ ...moveBtnStyle, opacity: index === nodeProps.fields.length - 1 ? 0.4 : 1 }} title="Move down">
<i className="fa fa-arrow-down" />
</button>
</div>
</div>
)}
emptyItem={{ type: 'text', label: 'New Field', name: 'field', placeholder: '', required: false }}
/>
</CollapsibleSection>
)}
{/* Destination: email (default, unchanged behaviour) or webhook. Gated on
the `destinationType` default in ContactForm.craft.props -- a prop
missing from those defaults is `undefined` here and the control would
simply never render. */}
{nodeProps.destinationType !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Send submissions to</label>
<div style={{ display: 'flex', gap: 4 }}>
{[{ v: 'email', l: 'Email' }, { v: 'webhook', l: 'Webhook' }].map((o) => (
<button
key={o.v}
data-testid={`destination-${o.v}`}
onClick={() => setProp('destinationType', o.v)}
style={btnActiveStyle((nodeProps.destinationType || 'email') === o.v)}
>
{o.l}
</button>
))}
</div>
</div>
)}
{nodeProps.destinationType !== undefined && isWebhook && (
<>
<div style={sectionGap}>
<label style={labelStyle}>Webhook URL</label>
<input
type="text"
data-testid="webhook-url"
value={nodeProps.webhookUrl || ''}
onChange={(e) => setProp('webhookUrl', e.target.value)}
placeholder="https://hooks.example.com/..."
style={inputStyle}
/>
{/* The publish step DOES refuse a blank/non-https URL -- loudly, but
into an error_log the customer never reads, leaving them with a
form that just doesn't work. Warn here instead. Deliberately a
warning only: not blanking the value and not blocking the
publish, since either would trade a loud server-side refusal for
a silently inert form. */}
{!isHttpsWebhookUrl(nodeProps.webhookUrl) && (
<p data-testid="webhook-url-warning" style={{ ...hintStyle, color: '#fbbf24' }}>
{nodeProps.webhookUrl
? 'This must be an absolute https:// URL — submissions to this form won\'t be delivered until it is.'
: 'Enter the https:// URL to POST submissions to — this form won\'t deliver anything until you do.'}
</p>
)}
<p style={hintStyle}>
Must be an absolute <strong>https://</strong> URL. Each submission is POSTed as JSON;
failures are retried, then emailed to the fallback address below.
</p>
</div>
<div style={sectionGap}>
<label style={labelStyle}>Authentication</label>
<select
data-testid="webhook-authmode"
value={nodeProps.webhookAuthMode || 'signature'}
onChange={(e) => setProp('webhookAuthMode', e.target.value)}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="signature">Signature (HMAC-SHA256 header)</option>
<option value="bearer">Bearer token (Authorization header)</option>
</select>
</div>
<WebhookSecretField
/* Remounts on every selection change, so a retained draft (and its
status banner) can never follow the customer to another form --
see the comment on WebhookSecretField. */
key={selectedId}
secretId={nodeProps.webhookSecretId || ''}
onStored={(id) => setProp('webhookSecretId', id)}
onRemoved={() => setProp('webhookSecretId', '')}
/>
</>
)}
{/* Contact-form relay: where submissions are emailed. Present on ContactForm {/* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */} and FormContainer (both have recipientEmail/thankYouUrl props). With a
webhook destination this same address is the FALLBACK the relay uses
when delivery is exhausted. */}
{nodeProps.recipientEmail !== undefined && ( {nodeProps.recipientEmail !== undefined && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Send submissions to (email)</label> <label style={labelStyle}>{isWebhook ? 'Fallback email (if the webhook fails)' : 'Send submissions to (email)'}</label>
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} /> <input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}> <p style={hintStyle}>
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead. {isWebhook
? 'Emailed here if the webhook keeps failing after retries. Leave blank to skip the fallback.'
: "Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead."}
</p> </p>
</div> </div>
)} )}
@@ -43,13 +370,35 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</div> </div>
)} )}
{/* Form action/method */} {/* Form action/method (FormContainer). SearchBar also has an `action`
{nodeProps.action !== undefined && ( prop but is distinguished via its unique `showButton` prop -- see
the dedicated Search block below -- so it doesn't get this label. */}
{nodeProps.action !== undefined && nodeProps.showButton === undefined && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Form Action URL</label> <label style={labelStyle}>Form Action URL</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} /> <input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
</div> </div>
)} )}
{/* SearchBar: where the GET search request is submitted. */}
{nodeProps.showButton !== undefined && nodeProps.action !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Search Results Page</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Submits a GET request with the query as ?q=... to this URL.
</p>
</div>
)}
{nodeProps.showButton !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.showButton !== false} onChange={(e) => setProp('showButton', e.target.checked)} />
Show Search Button
</label>
</div>
)}
{nodeProps.method !== undefined && ( {nodeProps.method !== undefined && (
<div style={sectionGap}> <div style={sectionGap}>
<label style={labelStyle}>Method</label> <label style={labelStyle}>Method</label>
@@ -142,6 +491,64 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} /> <PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{/* Box model: margin/padding (per-side), border, shadow, opacity --
common enh-batch rollout, applies to the whole form family since
they all spread `style` onto their root element. */}
<CollapsibleSection title="Spacing, Border & Effects" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<SpacingControl
label="Padding (per side)"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<BorderControl
value={parseBorderValue(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Box Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div className="guided-section">
<SectionLabel>Opacity</SectionLabel>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined && style.opacity !== '' ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Animation & Visibility -- gated on the blank/false defaults added to
each owned component's craft.props (ContactForm, FormContainer,
InputField, TextareaField, FormButton, SubscribeForm, SearchBar).
No toHtml change needed: the export's buildDataAttrs() already
emits data-animation/data-hide-* from these exact prop names for
every node. */}
{nodeProps.animation !== undefined && (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
onChange={(v) => {
setProp('hideOnDesktop', v.hideOnDesktop);
setProp('hideOnTablet', v.hideOnTablet);
setProp('hideOnMobile', v.hideOnMobile);
}}
/>
</CollapsibleSection>
)}
</> </>
); );
}; };
@@ -0,0 +1,323 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM harness + craftjs mock as NavStylePanel.test.tsx (no
@testing-library/react in this repo). `lastProps` IS the node's prop bag: the
mocked setProp mutates it exactly as Craft.js would, which is what lets the
"no raw secret ever reaches a prop" assertion below be a real check on
everything the panel writes rather than on a hand-picked key. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function rerender(ui: React.ReactElement) {
act(() => { root.render(ui); });
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
act(() => {
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function click(el: Element | null) {
if (!el) throw new Error('element not found');
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
/* React 17+ implements onBlur with the native `focusout` event (which bubbles),
not `blur`. */
async function blur(el: Element) {
await act(async () => {
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
}
const testid = (id: string) => container.querySelector(`[data-testid="${id}"]`);
function contactFormProps(over: Record<string, any> = {}) {
return {
fields: [],
style: {},
recipientEmail: '',
thankYouUrl: '',
destinationType: 'email',
webhookUrl: '',
webhookSecretId: '',
webhookAuthMode: 'signature',
...over,
};
}
beforeEach(() => {
setPropSpy.mockClear();
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 42 };
});
afterEach(() => {
if (container) unmount();
delete (window as any).WHP_CONFIG;
vi.unstubAllGlobals();
});
describe('FormStylePanel destination selector', () => {
test('choosing Webhook writes destinationType and reveals the webhook fields', () => {
lastProps = contactFormProps();
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url')).toBeNull();
click(testid('destination-webhook'));
expect(lastProps.destinationType).toBe('webhook');
rerender(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url')).toBeTruthy();
expect(testid('webhook-authmode')).toBeTruthy();
expect(testid('webhook-secret-input')).toBeTruthy();
});
test('the URL and auth-mode controls write their props', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
setValue(testid('webhook-url') as HTMLInputElement, 'https://hooks.example.com/x');
expect(lastProps.webhookUrl).toBe('https://hooks.example.com/x');
setValue(testid('webhook-authmode') as HTMLSelectElement, 'bearer');
expect(lastProps.webhookAuthMode).toBe('bearer');
});
test('the recipient field is relabelled as the fallback address for a webhook', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
expect(labels.some((t) => t?.includes('Fallback email'))).toBe(true);
expect(labels.some((t) => t === 'Send submissions to (email)')).toBe(false);
});
test('a component without destinationType (FormContainer) shows neither the selector nor the webhook fields', () => {
lastProps = { recipientEmail: '', thankYouUrl: '', style: {} };
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('destination-webhook')).toBeNull();
expect(testid('webhook-url')).toBeNull();
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
expect(labels).toContain('Send submissions to (email)');
});
});
describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
test('blurring the secret POSTs it and persists ONLY the returned id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
});
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
// The flow really ran (otherwise the assertions below would pass vacuously).
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).secret).toBe(RAW_SECRET);
expect(lastProps.webhookSecretId).toBe('whs_42_deadbeefdeadbeef');
// THE PROPERTY: nothing the panel wrote to the node's props contains the raw
// secret, under any key -- craft props are serialised into the saved project
// and into published output.
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
for (const [, updater] of setPropSpy.mock.calls) {
const probe: any = {};
(updater as (p: any) => void)(probe);
expect(JSON.stringify(probe)).not.toContain(RAW_SECRET);
}
// ...and the input is cleared, so it isn't sitting in the DOM either.
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
expect(testid('webhook-secret-saved')).toBeTruthy();
});
test('typing a secret without blurring writes nothing at all', () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
setValue(testid('webhook-secret-input') as HTMLInputElement, RAW_SECRET);
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
});
test('a failed store surfaces the endpoint message and persists no id', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 429,
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
}));
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
expect(testid('webhook-secret-error')!.textContent).toContain('Too many webhook secrets stored for this site');
expect(lastProps.webhookSecretId).toBe('');
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
});
test('the stored secret is never shown -- only its state, with a Clear action', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
// The field is a password input that starts empty: there is no read route to
// populate it from, and nothing anywhere renders the value.
const input = testid('webhook-secret-input') as HTMLInputElement;
expect(input.type).toBe('password');
expect(input.value).toBe('');
click(testid('webhook-secret-remove'));
expect(lastProps.webhookSecretId).toBe('');
});
test('blurring an empty secret field posts nothing', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
await blur(testid('webhook-secret-input')!);
expect(fetchMock).not.toHaveBeenCalled();
});
test('the Clear action says what it does: the stored key stays on the server', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
// "Remove" would be a lie: nothing deletes the key file, and it keeps
// counting toward the per-site cap the customer eventually 429s against.
expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
expect(container.textContent).toContain('the stored key stays on the server');
});
});
describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
/* GuidedStyles renders <FormStylePanel> with no key, so a selection change
re-renders rather than remounts. A failed store deliberately KEEPS the
draft, so without a remount the next form's field would open holding the
previous form's raw secret -- and the next blur would POST it and assign
the returned id to the wrong node. */
test('after a failed store on node A, selecting node B shows an empty field and no status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 429,
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
}));
const nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
lastProps = nodeA;
render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
// Precondition: the draft really was retained on node A (otherwise this
// test would pass for the wrong reason).
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
expect(testid('webhook-secret-error')).toBeTruthy();
const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
lastProps = nodeB;
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
expect(container.textContent).not.toContain(RAW_SECRET);
expect(testid('webhook-secret-error')).toBeNull();
});
test('a "Secret stored." banner does not follow the selection either', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
}));
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
const input = testid('webhook-secret-input') as HTMLInputElement;
setValue(input, RAW_SECRET);
await blur(input);
expect(testid('webhook-secret-saved')).toBeTruthy();
const nodeB = contactFormProps({ destinationType: 'webhook' });
lastProps = nodeB;
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
expect(testid('webhook-secret-saved')).toBeNull();
expect(testid('webhook-secret-status')).toBeNull();
});
});
describe('isHttpsWebhookUrl / the inline URL warning', () => {
test.each([
['https://hooks.example.com/x', true],
['https://hooks.example.com/x?a=1&b=2', true],
['http://hooks.example.com/x', false],
['hooks.example.com/x', false],
['/relative/path', false],
['', false],
[' ', false],
['https://hooks.example.com/x\nHost: evil', false],
['javascript:alert(1)', false],
])('%s -> %s', (value, expected) => {
expect(isHttpsWebhookUrl(value)).toBe(expected);
});
test('a blank URL warns that nothing will be delivered', () => {
lastProps = contactFormProps({ destinationType: 'webhook' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
});
test('a non-https URL warns without altering the value', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
// Warning only -- the panel must not blank or rewrite the prop, which would
// turn the publish step's loud refusal into a silently inert form.
expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
});
test('a valid https URL shows no warning', () => {
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
expect(testid('webhook-url-warning')).toBeNull();
});
});
@@ -17,12 +17,13 @@ import {
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { ArrayItemFieldsEditor } from './ArrayItemFields'; import { ArrayItemFieldsEditor } from './ArrayItemFields';
import { HtmlCodeField } from './HtmlCodeField';
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */ /* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
selectedId, nodeProps, typeName, selectedId, nodeProps, typeName,
}) => { }) => {
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']); const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass', 'code']);
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId); const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
@@ -35,9 +36,15 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val)); const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
const style = nodeProps.style || {}; const style = nodeProps.style || {};
const hasCodeProp = typeof nodeProps.code === 'string';
return ( return (
<> <>
{/* Raw HTML (HtmlBlock's `code` prop) -- dedicated CodeEditor modal */}
{hasCodeProp && (
<HtmlCodeField value={nodeProps.code} onChange={(v) => setPropValue('code', v)} />
)}
{/* String props */} {/* String props */}
{stringProps.length > 0 && ( {stringProps.length > 0 && (
<CollapsibleSection title="Properties"> <CollapsibleSection title="Properties">
@@ -0,0 +1,82 @@
import React, { useRef, useState } from 'react';
import { CollapsibleSection, sectionGap } from './shared';
import { Modal } from '../../../ui/Modal';
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
import { HtmlToolbar } from './HtmlToolbar';
import { formatHtml } from '../../../utils/format-html';
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
(potentially many lines, embedded <style>/<script>), so it gets a
dedicated syntax-highlighted CodeEditor in a modal rather than the
generic single-line/textarea string-prop rendering. */
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
const editorRef = useRef<CodeEditorHandle>(null);
const handleFormat = (): void => {
const current = editorRef.current?.getValue() ?? value;
onChange(formatHtml(current));
};
return (
<CollapsibleSection title="HTML Code">
<div style={sectionGap}>
<button
onClick={() => setOpen(true)}
style={{
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
borderRadius: 6, cursor: 'pointer',
}}
>
<i className="fa fa-code" /> Edit HTML
</button>
</div>
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
<div
style={{
background: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
<button
onClick={() => setOpen(false)}
style={{
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
}}
>
<i className="fa fa-times" />
</button>
</div>
<div style={{ padding: 16 }}>
<HtmlToolbar editorRef={editorRef} onFormat={handleFormat} />
<CodeEditor ref={editorRef} value={value} onChange={onChange} language="html" height={420} />
</div>
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => setOpen(false)}
style={{
padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
}}
>
Done
</button>
</div>
</div>
</Modal>
</CollapsibleSection>
);
};
@@ -0,0 +1,44 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: vi.fn() }, query: {} }),
}));
import { HtmlStylePanel } from './HtmlStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
describe('HtmlStylePanel', () => {
test('renders the Edit HTML control', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.textContent).toContain('Edit HTML');
});
test('renders NO colour or style controls (they never reach the live page)', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.querySelector('input[type="color"]')).toBeNull();
expect(container.textContent).not.toContain('Background');
expect(container.textContent).not.toContain('Text Color');
expect(container.textContent).not.toContain('Padding');
expect(container.textContent).not.toContain('Border Radius');
});
test('renders the security advisory', () => {
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
expect(container.textContent).toContain('Use this block with care.');
expect(container.textContent).toContain('Scripts and event handlers are stripped');
});
});
@@ -0,0 +1,37 @@
import React from 'react';
import { useNodeProp } from './shared';
import { HtmlCodeField } from './HtmlCodeField';
/**
* The HTML block's entire style panel.
*
* Deliberately ONLY the code editor. `HtmlBlock.toHtml()` emits nothing but
* the purified `code`, so every colour/padding/alignment control the generic
* editor used to offer here was dead on the published page. Styling an HTML
* block is done inside the user's own markup.
*/
export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }> = ({
selectedId,
nodeProps,
}) => {
const { setProp: setPropValue } = useNodeProp(selectedId);
return (
<>
<HtmlCodeField
value={typeof nodeProps.code === 'string' ? nodeProps.code : ''}
onChange={(v) => setPropValue('code', v)}
/>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
Style this block inside your own markup — a wrapper set here would show
in the editor but not on the published page.
</p>
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
<strong>Use this block with care.</strong> It renders your markup as-is
on the published site. Scripts and event handlers are stripped
automatically, but anything that survives — forms, iframes, images —
can still send data to wherever it points. Only paste code you
understand or trust.
</p>
</>
);
};
@@ -0,0 +1,65 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { HtmlToolbar, SNIPPETS } from './HtmlToolbar';
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function fakeHandle() {
return { insertAtCursor: vi.fn(), getValue: vi.fn(() => '') } as unknown as CodeEditorHandle;
}
describe('HtmlToolbar', () => {
test('every snippet button inserts its snippet with its caret offset', () => {
const handle = fakeHandle();
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
for (const snippet of SNIPPETS) {
const btn = container.querySelector(`[data-snippet="${snippet.label}"]`) as HTMLButtonElement;
expect(btn, `missing button for ${snippet.label}`).not.toBeNull();
act(() => { btn.click(); });
expect(handle.insertAtCursor).toHaveBeenCalledWith(snippet.text, snippet.caret);
}
});
test('the colour input inserts a style attribute at the caret', () => {
const handle = fakeHandle();
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
const colour = container.querySelector('input[type="color"]') as HTMLInputElement;
colour.value = '#ff8800';
act(() => { colour.dispatchEvent(new Event('input', { bubbles: true })); });
expect(handle.insertAtCursor).toHaveBeenCalledWith(' style="color: #ff8800"');
});
test('Format calls onFormat', () => {
const onFormat = vi.fn();
const ref = { current: fakeHandle() } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={onFormat} />);
const btn = container.querySelector('[data-action="format"]') as HTMLButtonElement;
act(() => { btn.click(); });
expect(onFormat).toHaveBeenCalledTimes(1);
});
test('a null editor ref is a no-op, not a crash', () => {
const ref = { current: null } as React.RefObject<CodeEditorHandle>;
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
const btn = container.querySelector('[data-snippet="div"]') as HTMLButtonElement;
expect(() => act(() => { btn.click(); })).not.toThrow();
});
});
@@ -0,0 +1,92 @@
import React from 'react';
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
/**
* Snippet buttons for the Edit HTML modal. `caret` is the offset from the
* start of the inserted text where the caret should land -- i.e. between the
* open and close tags, so the next keystroke types content rather than
* landing after the closing tag.
*/
export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[] = [
{ label: 'div', icon: 'fa-square-o', title: 'Insert a div', text: '<div></div>', caret: 5 },
{ label: 'section', icon: 'fa-window-maximize', title: 'Insert a section', text: '<section></section>', caret: 9 },
{ label: 'h2', icon: 'fa-header', title: 'Insert a heading', text: '<h2></h2>', caret: 4 },
{ label: 'p', icon: 'fa-paragraph', title: 'Insert a paragraph', text: '<p></p>', caret: 3 },
{ label: 'a', icon: 'fa-link', title: 'Insert a link', text: '<a href="#"></a>', caret: 12 },
{ label: 'ul', icon: 'fa-list-ul', title: 'Insert a list', text: '<ul>\n <li></li>\n</ul>', caret: 11 },
{ label: 'img', icon: 'fa-image', title: 'Insert an image', text: '<img src="" alt="">', caret: 10 },
];
const btnStyle: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 28, height: 28, padding: '0 7px',
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 5,
fontSize: 11, cursor: 'pointer',
};
export const HtmlToolbar: React.FC<{
editorRef: React.RefObject<CodeEditorHandle>;
onFormat: () => void;
}> = ({ editorRef, onFormat }) => {
const insert = (text: string, caret?: number): void => {
// The ref is null until CodeEditor mounts; clicking early must no-op.
// Only forward a second argument when a caret offset was actually
// given -- `insertAtCursor(text, undefined)` is a distinct call from
// `insertAtCursor(text)` (an explicit undefined still occupies the
// argument list), and the colour control relies on the latter so the
// caret lands at the end of the inserted attribute, not mid-string.
if (caret === undefined) {
editorRef.current?.insertAtCursor(text);
} else {
editorRef.current?.insertAtCursor(text, caret);
}
};
return (
<div
role="toolbar"
aria-label="HTML editing tools"
style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10, alignItems: 'center' }}
>
{SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
data-snippet={s.label}
title={s.title}
aria-label={s.title}
style={btnStyle}
onClick={() => insert(s.text, s.caret)}
>
<i className={`fa ${s.icon}`} aria-hidden="true" />
</button>
))}
<span style={{ width: 1, height: 20, background: '#3f3f46', margin: '0 3px' }} aria-hidden="true" />
<label
title="Insert a colour style attribute at the cursor"
style={{ ...btnStyle, padding: 0, overflow: 'hidden', position: 'relative' }}
>
<input
type="color"
aria-label="Insert colour"
defaultValue="#3b82f6"
onInput={(e) => insert(` style="color: ${(e.target as HTMLInputElement).value}"`)}
style={{ width: 40, height: 34, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
/>
</label>
<button
type="button"
data-action="format"
title="Re-indent the markup"
style={{ ...btnStyle, marginLeft: 'auto', fontWeight: 600, gap: 5 }}
onClick={onFormat}
>
<i className="fa fa-indent" aria-hidden="true" /> Format
</button>
</div>
);
};
@@ -0,0 +1,133 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as MediaStylePanel.video.test.tsx -- mock
@craftjs/core's useEditor so setProp calls can be observed without
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
doesn't hit the network. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
vi.mock('../../../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { ImageStylePanel } from './ImageStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function qAll<T extends Element = Element>(testId: string): T[] {
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
}
/** Click the preset button with this exact label inside a given data-testid
* root (AspectRatioControl / PresetButtonGrid render plain buttons keyed by
* label, no per-button testid). */
function clickPresetByLabel(root: Element | null, label: string) {
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
expect(btn).toBeTruthy();
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
beforeEach(() => {
setPropSpy.mockClear();
});
afterEach(() => {
if (container) unmount();
});
describe('ImageStylePanel crop-fills-by-default (fix-anim-image B)', () => {
test('applying a non-empty aspect ratio with objectFit unset also sets objectFit to cover', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
expect(lastProps.style.aspectRatio).toBe('1 / 1');
expect(lastProps.style.objectFit).toBe('cover');
});
test('applying a ratio when objectFit is already "contain" leaves it as contain (no forced override)', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { objectFit: 'contain' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
expect(lastProps.style.aspectRatio).toBe('16 / 9');
expect(lastProps.style.objectFit).toBe('contain');
});
test('clearing the ratio (Original) does not force-clear objectFit', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
expect(lastProps.style.aspectRatio).toBe('');
expect(lastProps.style.objectFit).toBe('cover');
});
test('the Object Fit control (Cover/Contain/Fill/None) remains present and usable', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
// PresetButtonGrid buttons have no dedicated per-button testid; find by label text.
const containBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Contain');
expect(containBtn).toBeTruthy();
act(() => { containBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
expect(lastProps.style.objectFit).toBe('contain');
});
});
describe('ImageStylePanel Height control gated on aspect-ratio (fix-anim-image C)', () => {
test('no aspect-ratio set: both Width and Height SizeControls render', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(2);
});
test('aspect-ratio set: only the Width SizeControl renders (Height is hidden)', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(1);
});
test('applying an aspect ratio clears a stale height so it cannot linger and conflict', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { height: '50%' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '9:16');
expect(lastProps.style.aspectRatio).toBe('9 / 16');
expect(lastProps.style.height).toBe('');
});
});
@@ -2,30 +2,41 @@ import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
IMAGE_RADIUS_PRESETS, IMAGE_RADIUS_PRESETS,
OBJECT_FIT,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
SectionLabel, SectionLabel,
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
SizeControl,
AspectRatioControl,
FocalPointGrid,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock'; import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
/* ---------- IMAGE (with upload/browse/drop) ---------- */ /* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const maxWidthPresets = [ // Applying an aspect-ratio crop should FILL the frame by default (object-fit:
{ label: '25%', value: '25%' }, // cover) rather than leave letterboxed empty bands, and width + ratio + cover
{ label: '50%', value: '50%' }, // fully determine the box -- so a stale `height` can't linger and conflict
{ label: '75%', value: '75%' }, // (kills the %-height no-op that made resize look like it wasn't working).
{ label: '100%', value: '100%' }, // Clearing the ratio (back to 'Original') leaves objectFit as the user left it.
]; const applyAspectRatio = (v: string) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
};
return ( return (
<> <>
@@ -49,6 +60,45 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
}} }}
/> />
{/* Size -- Width absorbs the historical maxWidth presets (25/50/75/100%
are a subset of SizeControl's default preset row), Height is new. */}
<SizeControl
label="Width"
value={(style.maxWidth as string) || ''}
onChange={(v) => setPropStyle('maxWidth', v)}
/>
{/* Height is only meaningful when there's no aspect-ratio crop -- once a
ratio is set, Width + ratio + cover fully determine the box, so a
separate Height control would only conflict/mislead (see
applyAspectRatio, which clears any stale height at that moment). */}
{!style.aspectRatio && (
<SizeControl
label="Height"
value={(style.height as string) || ''}
onChange={(v) => setPropStyle('height', v)}
/>
)}
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
<img> itself is a CSS framing crop (no server-side image processing
needed). */}
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={applyAspectRatio}
/>
<div className="guided-section">
<SectionLabel>Object Fit</SectionLabel>
<PresetButtonGrid
presets={OBJECT_FIT}
activeValue={(style.objectFit as string) || ''}
onSelect={(v) => setPropStyle('objectFit', v)}
/>
</div>
<FocalPointGrid
value={(style.objectPosition as string) || ''}
onChange={(v) => setPropStyle('objectPosition', v)}
/>
{/* Border Radius */} {/* Border Radius */}
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
@@ -59,15 +109,10 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
/> />
</div> </div>
{/* Max Width */} {/* Box model + animation/visibility rollout */}
<div className="guided-section"> <BoxModelSection style={style} setPropStyle={setPropStyle} />
<SectionLabel>Max Width</SectionLabel> <BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<PresetButtonGrid <AnimVisSection nodeProps={nodeProps} setProp={setProp} />
presets={maxWidthPresets}
activeValue={style.maxWidth as string}
onSelect={(v) => setPropStyle('maxWidth', v)}
/>
</div>
</> </>
); );
}; };
@@ -2,7 +2,6 @@ import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS, RADIUS_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
@@ -13,6 +12,8 @@ import {
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
ArrayPropEditor, ArrayPropEditor,
SizeControl,
AspectRatioControl,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
@@ -20,6 +21,14 @@ import {
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
const GALLERY_COLUMN_PRESETS = [
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '5', value: '5' },
];
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */ /* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
@@ -28,13 +37,67 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
const style = nodeProps.style || {}; const style = nodeProps.style || {};
// Same crop-fills-by-default + no stale-height-conflict treatment as
// ImageStylePanel (see there for the full rationale): applying a ratio
// defaults objectFit to 'cover' when unset, and clears height so Width +
// ratio + cover is the single source of truth for the box.
const applyAspectRatio = (v: string) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
};
return ( return (
<> <>
{/* Video URL */} {/* Video source -- upload/browse/paste-URL (paste-URL still handles
YouTube/Vimeo; upload/browse handle files via detectVideoType's
serve_asset resolution). */}
{nodeProps.videoUrl !== undefined && ( {nodeProps.videoUrl !== undefined && (
<div style={sectionGap}> <div className="guided-section">
<label style={labelStyle}>Video URL</label> <SectionLabel>Video Source</SectionLabel>
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} /> <AssetPicker
mediaType="video"
value={nodeProps.videoUrl || ''}
onChange={(url) => setProp('videoUrl', url)}
variant="full"
placeholder="Or paste a YouTube/Vimeo/.mp4 URL..."
/>
</div>
)}
{/* Video size -- width + aspect ratio for the video frame. */}
{nodeProps.videoUrl !== undefined && (
<>
<SizeControl
label="Width"
value={(style.width as string) || ''}
onChange={(v) => setPropStyle('width', v)}
/>
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
themselves from `width` + `aspectRatio` directly (see
VideoBlock.tsx), not from an outer-wrapper height, so adding one
would reintroduce the exact empty-space bug this fix targets. */}
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={applyAspectRatio}
/>
</>
)}
{/* Poster image (optional, file-type videos only in practice but
harmless to offer whenever the component has a poster prop). */}
{nodeProps.poster !== undefined && (
<div className="guided-section">
<SectionLabel>Poster Image (optional)</SectionLabel>
<AssetPicker
mediaType="image"
value={nodeProps.poster || ''}
onChange={(url) => setProp('poster', url)}
variant="full"
/>
</div> </div>
)} )}
@@ -80,6 +143,26 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div> </div>
)} )}
{/* Gallery layout -- columns + lightbox (existing-but-previously-unexposed props). */}
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
<>
<div className="guided-section">
<SectionLabel>Columns</SectionLabel>
<PresetButtonGrid
presets={GALLERY_COLUMN_PRESETS}
activeValue={String(nodeProps.columns ?? 3)}
onSelect={(v) => setProp('columns', Number(v))}
/>
</div>
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={!!nodeProps.lightbox} onChange={(e) => setProp('lightbox', e.target.checked)} />
Enable Lightbox
</label>
</div>
</>
)}
{/* Gallery items */} {/* Gallery items */}
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && ( {nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
<CollapsibleSection title="Images"> <CollapsibleSection title="Images">
@@ -178,21 +261,23 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Background & padding */} {/* Background & radius */}
<CollapsibleSection title="Style" defaultOpen={false}> <CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Background</SectionLabel> <SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} /> <ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div> </div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} /> <PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
</> </>
); );
}; };
@@ -0,0 +1,201 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as MediaStylePanel.slides.test.tsx -- mock
@craftjs/core's useEditor so setProp calls can be observed without
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
doesn't hit the network. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
vi.mock('../../../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { MediaStylePanel } from './MediaStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function qAll<T extends Element = Element>(testId: string): T[] {
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
}
function setValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
beforeEach(() => {
setPropSpy.mockClear();
});
afterEach(() => {
if (container) unmount();
});
describe('MediaStylePanel Video source uses AssetPicker (upload/browse/paste-URL), not a plain text input', () => {
test('renders a full-variant AssetPicker (not the old plain input) for videoUrl', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(q('asset-picker-full')).not.toBeNull();
});
test('applying a pasted URL via the AssetPicker writes videoUrl (not some other key)', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
// The Video Source AssetPicker is the first url-input on the page (the
// Poster picker is a second, separate AssetPicker instance below it).
const urlInput = qAll<HTMLInputElement>('asset-picker-url-input')[0];
expect(urlInput).toBeTruthy();
setValue(urlInput, 'https://example.com/clip.mp4');
const applyBtn = qAll<HTMLButtonElement>('asset-picker-apply')[0];
act(() => {
applyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(setPropSpy).toHaveBeenCalled();
expect(lastProps.videoUrl).toBe('https://example.com/clip.mp4');
});
test('a video-shaped selection also renders a Poster AssetPicker (image) that writes `poster`', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
const pickers = qAll('asset-picker-full');
expect(pickers.length).toBe(2); // Video Source + Poster Image
const urlInputs = qAll<HTMLInputElement>('asset-picker-url-input');
const posterInput = urlInputs[1];
setValue(posterInput, 'https://example.com/poster.jpg');
const applyBtns = qAll<HTMLButtonElement>('asset-picker-apply');
act(() => {
applyBtns[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(lastProps.poster).toBe('https://example.com/poster.jpg');
});
});
describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated on videoUrl', () => {
test('a video-shaped selection renders SizeControl + AspectRatioControl', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(q('size-control')).not.toBeNull();
expect(q('aspect-ratio-control')).not.toBeNull();
});
test('a Map-shaped selection (no videoUrl) does NOT render the video size controls', () => {
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {} };
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
expect(q('size-control')).toBeNull();
expect(q('aspect-ratio-control')).toBeNull();
});
test('a video-shaped selection only renders one SizeControl (Width) -- no separate Height control', () => {
// See MediaStylePanel.tsx's note: VideoBlock sizes its <video>/iframe from
// width + aspectRatio directly, not from an outer-wrapper height, so a
// Height control would reintroduce the empty-space bug this fix targets.
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(1);
});
});
/* FIX (fix-anim-image contract, B+C): applying a crop aspect-ratio to a video
should fill by default (objectFit defaults to 'cover' when unset) and clear
any stale height, matching ImageStylePanel's treatment -- for prop-schema
consistency even though VideoBlock's file-type <video> already hardcodes
object-fit: cover today. */
describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default treatment (fix-anim-image B+C)', () => {
function clickPresetByLabel(root: Element | null, label: string) {
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
expect(btn).toBeTruthy();
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
test('applying a non-empty ratio with objectFit unset also sets objectFit to cover', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
expect(lastProps.style.aspectRatio).toBe('1 / 1');
expect(lastProps.style.objectFit).toBe('cover');
});
test('applying a ratio clears a stale height', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { height: '50%' } };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
expect(lastProps.style.aspectRatio).toBe('16 / 9');
expect(lastProps.style.height).toBe('');
});
test('clearing the ratio (Original) does not force-clear objectFit', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
expect(lastProps.style.aspectRatio).toBe('');
expect(lastProps.style.objectFit).toBe('cover');
});
});
function openCollapsibleByTitle(title: string) {
const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes(title));
expect(btn).toBeTruthy();
act(() => {
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
describe('MediaStylePanel box-model + animation/visibility rollout renders for every media type', () => {
test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (collapsed by default, open on click)', () => {
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {}, animation: '', hideOnDesktop: false };
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
openCollapsibleByTitle('Spacing');
expect(q('spacing-control')).not.toBeNull();
openCollapsibleByTitle('Border & Effects');
expect(q('border-control')).not.toBeNull();
openCollapsibleByTitle('Animation & Visibility');
expect(q('animation-control')).not.toBeNull();
expect(q('visibility-control')).not.toBeNull();
});
});
@@ -0,0 +1,282 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* NavStylePanel (via useNodeProp/LinkPicker in this file) needs useEditor
from @craftjs/core and usePages from PageContext. Mock both following the
DOM-harness pattern used across this repo's other *StylePanel tests (no
@testing-library/react here) -- PageContext itself is Wave-2's territory,
this package only READS pages via usePages(), so mocking it is the
correct boundary for these tests. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
vi.mock('../../../state/PageContext', () => ({
usePages: () => ({ pages: mockPages }),
}));
vi.mock('../../../ui/AssetPicker', () => ({
AssetPicker: () => null,
}));
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function rerender(ui: React.ReactElement) {
act(() => { root.render(ui); });
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
act(() => {
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function click(el: Element | null) {
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
beforeEach(() => {
setPropSpy.mockClear();
mockPages = [
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
{ id: 'about', name: 'About', slug: 'about', craftState: null },
];
});
afterEach(() => {
if (container) unmount();
});
describe('pageHref (landing page is always "/")', () => {
test('index 0 (landing page) -> "/"', () => {
expect(pageHref(mockPages[0], 0)).toBe('/');
});
test('any other page -> "/{slug}"', () => {
expect(pageHref(mockPages[1], 1)).toBe('/about');
});
});
describe('LinkPicker (F1: link-to-page picker + manual URL/anchor/tel/mailto)', () => {
test('a value matching a page href starts in "Page" mode and lists every page', () => {
render(<LinkPicker value="/about" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('page');
const optionText = Array.from(container.querySelectorAll('option')).map((o) => o.textContent);
expect(optionText).toContain('Home');
expect(optionText).toContain('About');
});
test('picking a different page from the page dropdown emits that page\'s href', () => {
const onChange = vi.fn();
render(<LinkPicker value="/about" onChange={onChange} />);
const [, pageSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
setValue(pageSelect, '/');
expect(onChange).toHaveBeenCalledWith('/');
});
test('a "#section" value starts in Anchor mode', () => {
render(<LinkPicker value="#pricing" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('anchor');
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('#pricing');
});
test('switching mode to Anchor seeds a bare "#"', () => {
const onChange = vi.fn();
render(<LinkPicker value="/about" onChange={onChange} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
setValue(modeSelect, 'anchor');
expect(onChange).toHaveBeenCalledWith('#');
});
test('mailto: helper strips the scheme for editing and re-adds it on change', () => {
const onChange = vi.fn();
render(<LinkPicker value="mailto:foo@example.com" onChange={onChange} />);
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('foo@example.com');
setValue(input, 'bar@example.com');
expect(onChange).toHaveBeenCalledWith('mailto:bar@example.com');
});
test('tel: helper strips the scheme for editing and re-adds it on change', () => {
const onChange = vi.fn();
render(<LinkPicker value="tel:5551234567" onChange={onChange} />);
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('5551234567');
setValue(input, '5559876543');
expect(onChange).toHaveBeenCalledWith('tel:5559876543');
});
test('a plain https:// URL falls back to Custom URL mode', () => {
render(<LinkPicker value="https://example.com" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('url');
});
});
describe('NavStylePanel Links section: href set via LinkPicker (F1 wired into the panel)', () => {
test('choosing a page for a Navbar link writes that page\'s href onto the link', () => {
lastProps = {
links: [{ text: 'Home', href: '/old-home' }],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
// First select for the one link item is its LinkPicker mode select (the
// href starts as a Custom URL, so it opens on "url" mode); switch it to
// "page" then pick the About page from the resulting page dropdown.
const modeSelect = selects[0];
setValue(modeSelect, 'page');
expect(setPropSpy).toHaveBeenCalled();
expect(lastProps.links[0].href).toBe('/'); // defaults to the first page
// The mock setProp mutates `lastProps` in place rather than triggering a
// real Craft.js state update, so force a re-render (passing the same,
// now-mutated, object) to get the LinkPicker to reflect its new "page"
// mode and render the page <select>.
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const pageSelect = Array.from(container.querySelectorAll('select'))[1] as HTMLSelectElement;
setValue(pageSelect, '/about');
expect(lastProps.links[0].href).toBe('/about');
});
test('toggling the Download checkbox for a link sets download:true', () => {
lastProps = { links: [{ text: 'Brochure', href: '/brochure.pdf' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
act(() => { checkbox.click(); });
expect(lastProps.links[0].download).toBe(true);
});
});
describe('NavStylePanel: "Sync links with Pages" (F2, regression vs legacy builder)', () => {
test('populates links from the mocked pages list and preserves the CTA link', () => {
lastProps = {
links: [
{ text: 'Old Home', href: '/old' },
{ text: 'Old About', href: '/old-about' },
{ text: 'Get Started', href: '#signup', isCta: true },
],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
expect(syncBtn).toBeTruthy();
click(syncBtn);
expect(lastProps.links).toEqual([
{ text: 'Home', href: '/' },
{ text: 'About', href: '/about' },
{ text: 'Get Started', href: '#signup', isCta: true },
]);
});
test('with no CTA link, sync just replaces links 1:1 with the pages list', () => {
lastProps = { links: [{ text: 'Stale', href: '/stale' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
click(syncBtn);
expect(lastProps.links).toEqual([
{ text: 'Home', href: '/' },
{ text: 'About', href: '/about' },
]);
});
test('the Links section (and its Sync button) is not shown for Footer (no `links` prop)', () => {
lastProps = { text: '© 2026' };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'));
expect(syncBtn).toBeUndefined();
});
});
/** The Spacing/Border & Effects/Animation/Visibility CollapsibleSections all
* default closed (defaultOpen={false}, matching the pre-existing "Spacing"
* section's convention) -- open one by clicking its header button before
* asserting on / interacting with its contents. */
function openSection(title: string) {
const header = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes(title))!;
click(header);
}
describe('NavStylePanel: box-model + animation + visibility controls always present', () => {
test('Spacing (Margin/Padding), Border & Effects, Animation, and Visibility sections render for a Navbar', () => {
lastProps = {
backgroundColor: '#ffffff',
style: {},
animation: 'none',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Spacing');
openSection('Border & Effects');
openSection('Animation');
openSection('Visibility');
expect(container.querySelector('[data-testid="spacing-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="border-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="animation-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="visibility-control"]')).toBeTruthy();
});
test('checking "Hide on Mobile" writes hideOnMobile:true via setProp', () => {
lastProps = { hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Visibility');
const checkbox = container.querySelector('[data-testid="visibility-hideOnMobile"]') as HTMLInputElement;
act(() => {
checkbox.click();
});
expect(lastProps.hideOnMobile).toBe(true);
});
test('picking an entrance animation writes animation via setProp', () => {
lastProps = { animation: 'none', animationDelay: '0' };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Animation');
const fadeInBtn = Array.from(container.querySelectorAll('[data-testid="animation-control"] button'))
.find((b) => b.textContent === 'Fade In')!;
click(fadeInBtn);
expect(lastProps.animation).toBe('fade-in');
});
});
+230 -15
View File
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
SPACING_PRESETS, SPACING_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -16,13 +17,145 @@ import {
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
SpacingControl,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
import { PageData } from '../../../types';
/* ---------- NAV / MENU / LOGO ---------- */ /* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
Mirrors the export convention used elsewhere: the first page is always the
landing page and publishes to '/', every other page publishes to '/{slug}'.
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
in renamePage() for why index 0 is special-cased this way. */
export function pageHref(page: PageData, index: number): string {
return index === 0 ? '/' : `/${page.slug}`;
}
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
const v = value || '';
if (pageHrefs.includes(v)) return 'page';
if (v.startsWith('#')) return 'anchor';
if (v.startsWith('tel:')) return 'tel';
if (v.startsWith('mailto:')) return 'mailto';
return 'url';
}
/* ---------- LinkPicker ----------
Reused for every link-href field in this panel (standalone Logo's href,
Navbar's logoUrl, and each Navbar/Menu link item's href): a dropdown of
the site's PAGES (read-only via usePages() -- PageContext itself is
Wave-2's territory) plus manual URL / #anchor / tel: / mailto: entry.
safeUrl (in toHtml) already allows tel:/mailto: schemes, so no export-side
change is needed for those. */
interface LinkPickerProps {
value: string;
onChange: (value: string) => void;
}
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
const { pages } = usePages();
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
const switchMode = (next: LinkMode) => {
if (next === mode) return;
if (next === 'page') onChange(pageOptions[0]?.href || '/');
else if (next === 'anchor') onChange('#');
else if (next === 'tel') onChange('tel:');
else if (next === 'mailto') onChange('mailto:');
else onChange('');
};
return (
<div style={sectionGap} data-testid="link-picker">
<label style={labelStyle}>Link</label>
<select
value={mode}
onChange={(e) => switchMode(e.target.value as LinkMode)}
style={{ ...inputStyle, marginBottom: 4, cursor: 'pointer' }}
>
<option value="page">Page</option>
<option value="url">Custom URL</option>
<option value="anchor">Anchor (#section)</option>
<option value="tel">Phone (tel:)</option>
<option value="mailto">Email (mailto:)</option>
</select>
{mode === 'page' && (
pageOptions.length > 0 ? (
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{pageOptions.map((p) => (
<option key={p.id} value={p.href}>{p.name}</option>
))}
</select>
) : (
<div style={{ fontSize: 11, color: '#71717a' }}>No pages yet</div>
)
)}
{mode === 'anchor' && (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
placeholder="#section-id"
style={inputStyle}
/>
)}
{mode === 'tel' && (
<input
type="text"
value={(value || '').replace(/^tel:/, '')}
onChange={(e) => onChange(`tel:${e.target.value}`)}
placeholder="+15551234567"
style={inputStyle}
/>
)}
{mode === 'mailto' && (
<input
type="text"
value={(value || '').replace(/^mailto:/, '')}
onChange={(e) => onChange(`mailto:${e.target.value}`)}
placeholder="name@example.com"
style={inputStyle}
/>
)}
{mode === 'url' && (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="https://example.com or /page"
style={inputStyle}
/>
)}
</div>
);
};
/* Parses a `border` shorthand ("2px solid #hex" / "none") back into the
{ width, style, color } shape BorderControl edits. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = v.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const { pages } = usePages();
const links: any[] = nodeProps.links || []; const links: any[] = nodeProps.links || [];
@@ -48,6 +181,20 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
}); });
}, [actions, selectedId]); }, [actions, selectedId]);
/* F2: (re)populate the links array from the current pages list --
label = page name, href = '/' for the landing page else '/{slug}'.
Any existing CTA link (isCta: true) is preserved (appended after the
freshly-generated page links) rather than being wiped, matching the
legacy GrapesJS builder's "Sync with Pages" behavior. */
const syncWithPages = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
const existing: any[] = props.links || [];
const ctaLinks = existing.filter((l) => l.isCta);
const pageLinks = pages.map((p, i) => ({ text: p.name, href: pageHref(p, i) }));
props.links = [...pageLinks, ...ctaLinks];
});
}, [actions, selectedId, pages]);
/* Detect standalone Logo vs Navbar/Menu */ /* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined; const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
@@ -66,6 +213,15 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
); );
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g })); const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
/* Box-model / animation / visibility values, read off `style` (margin,
padding, border, boxShadow, opacity) or top-level props (animation,
hideOn*). This panel is only ever mounted for the 4 owned components
(Navbar/Menu/Logo/Footer), which all now carry these props (see each
component's .craft.props), so -- unlike the Links/Colors sections above,
which are shared across a genuinely disparate prop schema -- these
sections render unconditionally rather than gating on presence. */
const style = nodeProps.style || {};
return ( return (
<> <>
{/* Standalone Logo component settings */} {/* Standalone Logo component settings */}
@@ -124,10 +280,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div> </div>
</> </>
)} )}
<div style={sectionGap}> <LinkPicker value={nodeProps.href || '/'} onChange={(v) => setProp('href', v)} />
<label style={labelStyle}>Link URL</label> <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginTop: -8, marginBottom: 12 }}>
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} /> <input type="checkbox" checked={!!nodeProps.download} onChange={(e) => setProp('download', e.target.checked)} />
</div> Download (link points at a file)
</label>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -145,17 +302,22 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div> </div>
)} )}
{nodeProps.logoUrl !== undefined && ( {nodeProps.logoUrl !== undefined && (
<div style={sectionGap}> <LinkPicker value={nodeProps.logoUrl || '/'} onChange={(v) => setProp('logoUrl', v)} />
<label style={labelStyle}>Logo Link URL</label>
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
</div>
)} )}
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Links (not shown for standalone Logo) */} {/* Links (not shown for standalone Logo, or for components -- like
{!isStandaloneLogo && ( Footer -- that don't carry a `links` array at all). */}
{!isStandaloneLogo && nodeProps.links !== undefined && (
<CollapsibleSection title="Links"> <CollapsibleSection title="Links">
<button
onClick={syncWithPages}
title="Replace these links with one per page (preserves any CTA link)"
style={{ marginBottom: 8, width: '100%', padding: '6px', fontSize: 11, background: 'rgba(59,130,246,0.12)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.4)', borderRadius: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<i className="fa fa-refresh" /> Sync links with Pages
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => ( {links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}> <div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -165,7 +327,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} /> <LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
Download (points at a file)
</label>
</div> </div>
))} ))}
</div> </div>
@@ -234,12 +400,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Style overrides */} {/* Box model: margin + padding (per-side, via style.*). The old
single "Padding" preset row is folded into the Padding SpacingControl
below (still writes to style.padding when linked, matching the
previous behavior exactly). */}
<CollapsibleSection title="Spacing" defaultOpen={false}> <CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${capitalize(side)}`, v)}
presets={SPACING_PRESETS}
/>
</CollapsibleSection>
{/* Border & Effects: border, box-shadow, opacity */}
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Padding</SectionLabel> <SectionLabel>Box Shadow</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} /> <PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow || 'none'} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div> </div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity</label>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(parseFloat(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Entrance animation */}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.animation = v.animation; p.animationDelay = v.animationDelay; })}
/>
</CollapsibleSection>
{/* Responsive visibility */}
<CollapsibleSection title="Visibility" defaultOpen={false}>
<VisibilityControl
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.hideOnDesktop = v.hideOnDesktop; p.hideOnTablet = v.hideOnTablet; p.hideOnMobile = v.hideOnMobile; })}
/>
</CollapsibleSection> </CollapsibleSection>
</> </>
); );
@@ -4,20 +4,69 @@ import {
FONT_FAMILIES, FONT_FAMILIES,
TEXT_SIZES, TEXT_SIZES,
FONT_WEIGHTS, FONT_WEIGHTS,
LINE_HEIGHTS,
LETTER_SPACINGS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
SectionLabel, SectionLabel,
ColorSwatchGrid, ColorSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
NumericUnitInput,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
/* Text-transform is a small fixed enum with no natural home in the shared
foundation presets (constants/presets.ts is import-only for this
package), so it lives here as a package-local preset list. */
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
{ label: 'None', value: 'none' },
{ label: 'UPPER', value: 'uppercase' },
{ label: 'lower', value: 'lowercase' },
{ label: 'Capitalize', value: 'capitalize' },
];
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
/* ---------- TEXT ---------- */ /* ---------- TEXT ---------- */
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
return ( return (
<> <>
@@ -44,6 +93,15 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
activeValue={style.fontSize as string} activeValue={style.fontSize as string}
onSelect={(v) => setPropStyle('fontSize', v)} onSelect={(v) => setPropStyle('fontSize', v)}
/> />
<div style={{ marginTop: 6 }}>
<NumericUnitInput
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
onChange={(v) => setPropStyle('fontSize', v)}
units={['px', 'em', 'rem', '%']}
placeholder="custom"
testId="text-fontsize-custom"
/>
</div>
</div> </div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Font Weight</SectionLabel> <SectionLabel>Font Weight</SectionLabel>
@@ -53,6 +111,53 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
onSelect={(v) => setPropStyle('fontWeight', v)} onSelect={(v) => setPropStyle('fontWeight', v)}
/> />
</div> </div>
<div className="guided-section">
<SectionLabel>Style</SectionLabel>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className={`preset-btn ${isItalic ? 'active' : ''}`}
style={{ flex: 1, fontStyle: 'italic' }}
onClick={() => setPropStyle('fontStyle', isItalic ? 'normal' : 'italic')}
title="Italic"
>
<i className="fa fa-italic" /> Italic
</button>
<button
type="button"
className={`preset-btn ${isUnderline ? 'active' : ''}`}
style={{ flex: 1, textDecoration: 'underline' }}
onClick={() => setPropStyle('textDecoration', isUnderline ? 'none' : 'underline')}
title="Underline"
>
<i className="fa fa-underline" /> Underline
</button>
</div>
</div>
<div className="guided-section">
<SectionLabel>Text Transform</SectionLabel>
<PresetButtonGrid
presets={TEXT_TRANSFORMS}
activeValue={(style.textTransform as string) || 'none'}
onSelect={(v) => setPropStyle('textTransform', v === 'none' ? '' : v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Line Height</SectionLabel>
<PresetButtonGrid
presets={LINE_HEIGHTS}
activeValue={String(style.lineHeight || '')}
onSelect={(v) => setPropStyle('lineHeight', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Letter Spacing</SectionLabel>
<PresetButtonGrid
presets={LETTER_SPACINGS}
activeValue={String(style.letterSpacing || '')}
onSelect={(v) => setPropStyle('letterSpacing', v)}
/>
</div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Alignment</SectionLabel> <SectionLabel>Alignment</SectionLabel>
<div className="preset-grid align-grid"> <div className="preset-grid align-grid">
@@ -68,6 +173,59 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
))} ))}
</div> </div>
</div> </div>
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop as string, right: style.paddingRight as string, bottom: style.paddingBottom as string, left: style.paddingLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`padding${capitalize(side)}`, v)}
/>
</CollapsibleSection>
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
</> </>
); );
}; };
@@ -0,0 +1,135 @@
import React from 'react';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
} from './shared';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
CONTAINERS package's single shared panel (ContainerStylePanel, used for
Container / Section / Columns). Kept local to this package (not in
shared.tsx, which is foundation/import-only) since it's just DRY-ing the
identical JSX block across those 3 components rather than a genuinely
cross-package reusable control. Mirrors the equivalent helper in the
media package (mediaBoxModel.tsx) -- same shape, independently duplicated
per-package by design (packages are developed and merged in parallel).
========================================================================== */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand --
* not arbitrary author-supplied CSS. */
export function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
</CollapsibleSection>
);
};
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
* integer for the range input / label. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
);
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
);
+1
View File
@@ -11,3 +11,4 @@ export { SectionTypePanel } from './SectionTypePanel';
export { PricingStylePanel } from './PricingStylePanel'; export { PricingStylePanel } from './PricingStylePanel';
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel'; export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
export { GenericPropsEditor } from './GenericPropsEditor'; export { GenericPropsEditor } from './GenericPropsEditor';
export { HtmlStylePanel } from './HtmlStylePanel';
@@ -0,0 +1,133 @@
import React from 'react';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
} from './shared';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
MEDIA package's panels (ImageStylePanel + MediaStylePanel). Kept local to
this package (not in shared.tsx, which is foundation/import-only) since
it's just DRY-ing the identical JSX block across ImageBlock/VideoBlock/
Gallery/ContentSlider/MapEmbed rather than a genuinely cross-package
reusable control.
========================================================================== */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand --
* not arbitrary author-supplied CSS. */
export function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
</CollapsibleSection>
);
};
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
* integer for the range input / label. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
);
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
);
@@ -0,0 +1,85 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { HeadCodeModal } from './HeadCodeModal';
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- DOM test harness -- same react-dom/client + `act` pattern used
throughout src/ui/*.test.tsx (no @testing-library/react in this repo). ---------- */
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
document.body.style.overflow = '';
});
// Exposes the current headCode so assertions can read it back after a
// simulated edit -- CodeEditor writes through `updateDesign`, this just
// surfaces the result.
function Harness({ onReady }: { onReady: (headCode: string) => void }) {
const { design } = useSiteDesign();
onReady(design.headCode);
return null;
}
describe('HeadCodeModal', () => {
// HeadCodeModal portals its content to document.body (see the comment in
// HeadCodeModal.tsx), so the rendered DOM lives outside `container` --
// query document.body instead.
test('renders the CodeEditor (fallback textarea path) seeded with the current headCode', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
</SiteDesignProvider>,
);
// CodeMirror loads via async dynamic import (see CodeEditor.test.tsx);
// synchronously after mount the fallback textarea is what's live.
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
expect(textarea).not.toBeNull();
expect(textarea!.value).toBe('');
expect(textarea!.dataset.language).toBe('html');
});
test('typing in the editor writes through to SiteDesignContext.headCode', () => {
let latestHeadCode = '';
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
<Harness onReady={(v) => { latestHeadCode = v; }} />
</SiteDesignProvider>,
);
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]')!;
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
setter.call(textarea, '<meta name="x" content="y">');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(latestHeadCode).toBe('<meta name="x" content="y">');
});
test('does not render when closed', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open={false} onClose={vi.fn()} />
</SiteDesignProvider>,
);
expect(document.body.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More