Compare commits

...
Author SHA1 Message Date
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
shadowdaoandClaude Opus 4.8 8bf525c600 feat(builder): surface hidden section/pricing/social props + box-model rollout
SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/
CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and
SocialStylePanel all gain a Spacing & Border section (margin/padding
per-side, border, box-shadow, opacity), an Animation section, and a
Visibility section, wired to the shared SpacingControl/BorderControl/
AnimationControl/VisibilityControl.

PricingTable's per-card colors (cardBg/textColor/subColor/featColor/
checkColor/btnBg/btnColor) were previously hard-coded literals computed
from featuredBg inside toHtml -- promoted to real optional props (each
falling back to the exact prior literal when unset) and exposed via
ColorPickerField in PricingStylePanel.

SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built
props with no control), plus Icon's bgColor/bgShape/bgSize/link and
StarRating's filledColor/emptyColor, which the panel's generic
iconBgColor/starColor checks never matched since those aren't Icon's or
StarRating's real prop names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:58 -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
jknapp 1d9460c173 Merge PR #13: enhancement foundation 2026-07-14 13:12:22 +00:00
shadowdaoandClaude Opus 4.8 8d69982a0b chore(builder): install CodeMirror deps for the upcoming code-editor feature
Adds @codemirror/state, view, commands, autocomplete, language,
lang-html, lang-javascript, lang-css, and theme-one-dark to package.json
now (unused for now, tree-shaken out of the build) so the code-editor
feature branch doesn't need to touch package.json/package-lock.json itself
and conflict with other enh-batch branches doing the same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:25 -07:00
shadowdaoandClaude Opus 4.8 fb40e3ece4 feat(builder): add reusable StylePanel controls (foundation for enh batch)
Adds NumericUnitInput, SizeControl, AspectRatioControl, FocalPointGrid,
SpacingControl, BorderControl, AnimationControl, and VisibilityControl to
src/panels/right/styles/shared.tsx -- presentational value/onChange controls
that upcoming feature panels will import instead of reinventing size,
spacing, border, and animation/visibility UI per panel.

AnimationControl/VisibilityControl emit the exact prop names
(animation/animationDelay, hideOnDesktop/hideOnTablet/hideOnMobile) already
consumed by html-export.ts's buildDataAttrs(), verified by reading that file
directly. ANIMATIONS matches the export's actual data-animation set
(fade-in/slide-up/slide-left/slide-right/zoom-in/bounce) rather than the
slide-down variant that doesn't exist in the export.

No existing shared.tsx exports were touched, and nothing is wired into any
feature panel yet -- that's the downstream feature branches' job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:20 -07:00
shadowdaoandClaude Opus 4.8 9591fcb8a7 feat(builder): add shared style presets for size/aspect-ratio/shadow/typography rollout
ASPECT_RATIOS, SHADOW_PRESETS, LINE_HEIGHTS, LETTER_SPACINGS, OBJECT_FIT,
BORDER_STYLES, and SIZE_PRESETS -- foundation for the new shared StylePanel
controls (SizeControl, AspectRatioControl, BorderControl) so upcoming
feature panels share one preset source instead of each defining their own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:14 -07:00
jknapp f80811318b Merge PR #12: real-Editor integration test harness 2026-07-14 00:12:49 +00:00
shadowdaoandClaude Opus 4.8 cb9fea6656 test(builder): real-@craftjs/core integration test harness (duplicate/paste, template load, AI apply-response)
Mocked unit tests for regenerateTreeIds, TemplateModal.addTemplateComponents,
and buildNodeTree each let a real bug ship (DataCloneError on duplicate/paste,
dropped template children, no-op AI section-replace/insert) because their
fake @craftjs/core query/actions never exercised Craft's real node shape
(data.type as a live component reference) or real parseFreshNode validation.

Adds src/test-utils/editorHarness.tsx, which mounts a REAL <Editor>+<Frame>
(no vi.mock('@craftjs/core') anywhere) via react-dom/client + act, plus the
jsdom shims Craft/this component library actually needs (ResizeObserver,
matchMedia, and an HTMLElement.prototype.innerText polyfill -- jsdom has no
native innerText, which Heading/TextBlock rely on to paint their text).

Adds 3 integration suites under src/test-utils/integration/ driving the real
useNodeActions/useKeyboardShortcuts, TemplateModal's real tree-build pipeline,
and the real useApplyAiResponse hook against a live EditorStore, asserting on
both the real rendered DOM and query.serialize()/exportBodyHtml output.

Red-proofed the duplicate/paste suite: temporarily reverted
regenerateTreeIds to structuredClone(oldNode.data) and confirmed both tests
fail with the historical DataCloneError before restoring the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:11:40 -07:00
jknapp b1afdcb585 Merge PR #11: mobile fast-follows 2026-07-13 16:19:53 +00:00
shadowdaoandClaude Opus 4.8 f00e4db3dc test(builder): cover useVisualViewport; fix: disable Select-Parent at ROOT in desktop context menu
- useVisualViewport.test.tsx: covers the visualViewport-undefined fallback,
  the keyboardInset math on a mocked visualViewport resize, and
  listener add/remove (resize + scroll) across mount/unmount.
- ContextMenu.tsx: Select Parent was only disabled at isRoot, so selecting
  a top-level section and choosing Select Parent silently landed on the
  un-editable ROOT (no outline, no toolbar) -- a dead end. Switched the
  guard to useNodeActions' canSelectParent (false whenever the node's
  parent is ROOT or missing), matching the mobile selection toolbar's
  existing identical guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:19:00 -07:00
jknapp b9552faa30 Merge PR #10: Mobile Phase B (touch editing) 2026-07-13 15:43:29 +00:00
shadowdaoandClaude Opus 4.8 77f35c4e9e fix(builder): duplicate inserts+selects after source; select-parent/styles-sheet/canvas-pad mobile fixes
Ship-blocking fix (Fable consult): useNodeActions.duplicate() appended the
regenerated tree at the end of the parent while leaving the ORIGINAL
selected, so duplicating a top-level section landed the copy off-screen at
the bottom of the page with no visible change -- shared by both the mobile
selection toolbar and the desktop right-click ContextMenu. Now inserts the
copy immediately after the source (actions.addNodeTree(tree, parentId,
sourceIndex + 1)) and selects it (actions.selectNode(tree.rootNodeId)),
falling back to append-at-end if the source's index can't be resolved.
Mobile also scrolls the new node into view.

Three cheap fast-follows:
- canSelectParent on useNodeActions (false when the node's parent is ROOT
  or missing); MobileSelectionToolbar disables "Select Parent" instead of
  dead-ending on a page-wide ROOT outline with no toolbar of its own.
- Opening the Styles sheet on mobile now scrolls the selected node above
  the 65dvh sheet; a temporary generous bottom-padding class handles the
  case where the node is the last thing on the page and there'd otherwise
  be no room left to scroll it into view.
- Canvas gets bottom padding equal to the fixed selection toolbar's height
  while it's visible, so the last section of a short page isn't stuck
  permanently underneath it.

Desktop duplicate behavior improves (inserts after + selects) via the
shared hook; no toHtml changes. Verified live via Playwright at 375px and
1280px (screenshots in craft/scratchpad/mobileB2/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:41:33 -07:00
shadowdaoandClaude Opus 4.8 2c8425ffb0 feat(builder): mobile-B touch editing -- selection toolbar, tap-to-add, swipe-dismiss
Phase B makes the Craft.js editor genuinely usable by touch on top of Phase
A's responsive shell, gated entirely behind useIsMobile()/<=768px:

- Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/
  delete/select-parent), shared by the desktop right-click menu (behavior
  unchanged) and the new mobile MobileSelectionToolbar.
- MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down,
  Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden
  while a sheet is open.
- BlocksPanel: tap-to-add on mobile (insert after selection, close sheet,
  select + scroll the new node into view); desktop drag/double-click
  unchanged.
- LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body
  (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss
  and on-screen-keyboard clearance via a new useVisualViewportInsets hook.

Also fixes two pre-existing bugs surfaced only by driving a real Craft.js
document with Playwright touch input (masked by tests that mock
@craftjs/core): regenerateTreeIds structuredClone'd a live node's whole
data object, including the component function reference in data.type,
throwing DataCloneError and silently breaking Duplicate/Paste for every
node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown
inside a useEditor collector closed over nodeId, which goes stale for one
render whenever the selection changes without an unrelated store event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:07:51 -07:00
jknapp 2a071bc7ab Merge PR #9: Mobile Phase A 2026-07-13 14:31:23 +00:00
shadowdaoandClaude Opus 4.8 2a8a26687b fix(builder): mobile-A2 hardening -- 16px inputs, shared sheet/modal chrome, z-scale + portal
- Force font-size:16px !important on Styles-sheet/topbar/Sitesmith inputs
  inside the mobile media query so inline 12px/14px styles stop triggering
  iOS zoom-on-focus.
- Lift sheet-open + Templates/Head Code modal-open state out of private
  useState into a shared MobileChromeContext (EditorShell), so Phase B can
  open/close sheets from outside MobilePanelBar.
- Add an explicit z-index layer scale, portal TemplateModal to
  document.body (was trapped under the tab bar inside .topbar's stacking
  context), align Sitesmith to the same --z-modal layer, and make opening
  a sheet close any open modal. Also fix modal backdrops swallowing tab
  bar taps (mirrors the sheet backdrop's existing tab-bar cutout).
- Drop BottomSheet's incorrect aria-modal; mobile-aware AssetsPanel empty
  state copy.
- Tests: useIsMobile (matchMedia mock incl. legacy fallback + cleanup),
  MobileChromeContext invariants (one sheet open, sheet closes modals),
  MobilePanelBar wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:30:12 -07:00
shadowdaoandClaude Opus 4.8 979331b12d feat(builder): mobile-responsive editor chrome (Phase A)
Makes the Craft.js editor usable on phones (≤768px) without touching desktop
layout/behavior: a useIsMobile() hook gates a bottom tab bar + sheets (hosting
the existing Blocks/Pages/Layers/Assets/Styles panels unchanged) in place of
the side panels, a collapsed TopBar with a "..." overflow menu, 44px touch
targets, 16px inputs, dvh/safe-area-aware sizing, and small copy/overflow
fixes (empty-canvas hint, Templates modal tabs + close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:54:15 -07:00
jknapp 329a782052 Merge PR #8: fix templates dropping nested content 2026-07-13 13:28:39 +00:00
136 changed files with 10176 additions and 307 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Site Builder</title> <title>Site Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+217
View File
@@ -8,6 +8,15 @@
"name": "whp-site-builder", "name": "whp-site-builder",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10", "@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7", "@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5", "dompurify": "^3.4.5",
@@ -373,6 +382,133 @@
"specificity": "bin/cli.js" "specificity": "bin/cli.js"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@craftjs/core": { "node_modules/@craftjs/core": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
@@ -1093,6 +1229,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@@ -1873,6 +2072,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-color-keywords": { "node_modules/css-color-keywords": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -2686,6 +2891,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/styled-components": { "node_modules/styled-components": {
"version": "6.3.12", "version": "6.3.12",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
@@ -3096,6 +3307,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+9
View File
@@ -14,6 +14,15 @@
"test:unit:watch": "vitest" "test:unit:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10", "@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7", "@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5", "dompurify": "^3.4.5",
@@ -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,
@@ -44,3 +44,29 @@ describe('Icon.toHtml XSS hardening', () => {
expect(html).not.toMatch(/"\s+onclick="/); expect(html).not.toMatch(/"\s+onclick="/);
}); });
}); });
describe('Icon.toHtml bgShape/bgColor/bgSize rendering (built but, until this panel update, unexposed)', () => {
test('bgShape="circle" + bgColor render a colored 50%-radius background box', () => {
const { html } = toHtml({ icon: 'fa-star', bgShape: 'circle', bgColor: '#3b82f6', bgSize: '64px' }, '');
expect(html).toContain('background-color:#3b82f6');
expect(html).toContain('border-radius:50%');
expect(html).toContain('width:64px');
expect(html).toContain('height:64px');
});
test('bgShape="none" (default) renders the bare icon with no background wrapper', () => {
const { html } = toHtml({ icon: 'fa-star' }, '');
expect(html).not.toContain('background-color');
});
});
describe('Icon.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Icon as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -12,6 +12,11 @@ interface IconProps {
bgSize?: string; bgSize?: string;
link?: string; link?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
function getBgBorderRadius(shape: string): string { function getBgBorderRadius(shape: string): string {
@@ -96,6 +101,11 @@ Icon.craft = {
bgSize: '56px', bgSize: '56px',
link: '', link: '',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -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>`,
@@ -51,3 +51,43 @@ describe('SocialLinks.toHtml XSS hardening (iconSize/iconColor/iconBgColor/gap i
expect(html).not.toContain('javascript:alert(1)'); expect(html).not.toContain('javascript:alert(1)');
}); });
}); });
describe('SocialLinks.toHtml iconShape/gap emission (previously built but unexposed in SocialStylePanel)', () => {
test('iconShape="circle" emits a 50% border-radius on the wrapping <a>', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'circle' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:50%[^"]*"/);
});
test('iconShape="square" emits border-radius:0', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'square' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:0[^"]*"/);
});
test('iconShape="rounded" emits a 6px border-radius', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'rounded' }, '');
expect(html).toMatch(/<a[^>]*style="[^"]*border-radius:6px[^"]*"/);
});
test('iconShape="none" omits the background box entirely (transparent bg, no fixed box size)', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], iconShape: 'none' }, '');
const aTag = html.match(/<a[^>]*>/)![0];
expect(aTag).toContain('background-color:transparent');
expect(aTag).not.toContain('border-radius');
});
test('gap emits on the wrapper <div> style', () => {
const { html } = toHtml({ links: [{ platform: 'facebook', url: '#' }], gap: '24px' }, '');
expect(html).toMatch(/<div[^>]*style="[^"]*gap:24px[^"]*"/);
});
});
describe('SocialLinks.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (SocialLinks as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -17,6 +17,11 @@ interface SocialLinksProps {
gap?: string; gap?: string;
alignment?: 'left' | 'center' | 'right'; alignment?: 'left' | 'center' | 'right';
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const platformIcons: Record<string, string> = { const platformIcons: Record<string, string> = {
@@ -151,6 +156,11 @@ SocialLinks.craft = {
gap: '10px', gap: '10px',
alignment: 'center', alignment: 'center',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -75,3 +75,14 @@ describe('StarRating.toHtml XSS hardening (rating/maxStars into aria-label, F2.2
expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/); expect(html).toMatch(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
}); });
}); });
describe('StarRating.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (StarRating as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -10,6 +10,11 @@ interface StarRatingProps {
filledColor?: string; filledColor?: string;
emptyColor?: string; emptyColor?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
export const StarRating: UserComponent<StarRatingProps> = ({ export const StarRating: UserComponent<StarRatingProps> = ({
@@ -87,6 +92,11 @@ StarRating.craft = {
filledColor: '#f59e0b', filledColor: '#f59e0b',
emptyColor: '#d1d5db', emptyColor: '#d1d5db',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -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,
@@ -126,3 +126,73 @@ 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,
});
});
});
+20 -1
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,11 @@ interface ContactFormProps {
inputBorder?: string; inputBorder?: string;
recipientEmail?: string; recipientEmail?: string;
thankYouUrl?: string; thankYouUrl?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultFields: ContactFormField[] = [ const defaultFields: ContactFormField[] = [
@@ -157,6 +171,11 @@ ContactForm.craft = {
inputBorder: '#d1d5db', inputBorder: '#d1d5db',
recipientEmail: '', recipientEmail: '',
thankYouUrl: '', thankYouUrl: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -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>`,
}; };
}; };
@@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest';
import { Accordion } from './Accordion';
const toHtml = (Accordion as any).toHtml;
const items = [
{ title: 'Q1', content: 'A1', isOpen: true },
{ title: 'Q2', content: 'A2', isOpen: false },
];
describe('Accordion.toHtml basic export', () => {
test('renders a <details> per item with the open attribute honored', () => {
const { html } = toHtml({ items }, '');
const detailsBlocks = html.match(/<details[^>]*>/g) || [];
expect(detailsBlocks.length).toBe(2);
expect(detailsBlocks[0]).toContain(' open');
expect(detailsBlocks[1]).not.toContain(' open');
});
test('headerBg/headerColor/contentBg/borderColor emit into the panel styles', () => {
const { html } = toHtml({ items, headerBg: '#111111', headerColor: '#222222', contentBg: '#333333', borderColor: '#444444' }, '');
expect(html).toContain('#111111');
expect(html).toContain('#222222');
expect(html).toContain('#333333');
expect(html).toContain('#444444');
});
});
describe('Accordion.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Accordion as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -17,6 +17,11 @@ interface AccordionProps {
contentBg?: string; contentBg?: string;
borderColor?: string; borderColor?: string;
anchorId?: string; anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultItems: AccordionItem[] = [ const defaultItems: AccordionItem[] = [
@@ -135,6 +140,11 @@ Accordion.craft = {
contentBg: '#ffffff', contentBg: '#ffffff',
borderColor: '#e2e8f0', borderColor: '#e2e8f0',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -0,0 +1,37 @@
import { describe, test, expect } from 'vitest';
import { CTASection } from './CTASection';
const toHtml = (CTASection as any).toHtml;
describe('CTASection.toHtml basic export', () => {
test('renders heading, description, and CTA buttons', () => {
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
expect(html).toContain('Hi');
expect(html).toContain('Sub');
expect(html).toContain('Go');
});
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
const { html } = toHtml({
heading: 'Hi',
description: 'Sub',
style: { marginTop: '10px', paddingLeft: '5px', border: '1px solid #000', boxShadow: '0 1px 2px rgba(0,0,0,0.1)', opacity: '0.5' },
}, '');
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-left:5px');
expect(html).toContain('border:1px solid #000');
expect(html).toContain('box-shadow:0 1px 2px rgba(0,0,0,0.1)');
expect(html).toContain('opacity:0.5');
});
});
describe('CTASection.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (CTASection as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -14,6 +14,11 @@ interface CTASectionProps {
gradient?: string; gradient?: string;
anchorId?: string; anchorId?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)'; const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
@@ -83,6 +88,11 @@ CTASection.craft = {
gradient: defaultGradient, gradient: defaultGradient,
anchorId: '', anchorId: '',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -0,0 +1,37 @@
import { describe, test, expect } from 'vitest';
import { CallToAction } from './CallToAction';
const toHtml = (CallToAction as any).toHtml;
describe('CallToAction.toHtml basic export', () => {
test('renders heading, description, and CTA buttons', () => {
const { html } = toHtml({ heading: 'Hi', description: 'Sub', ctas: [{ text: 'Go', href: '#', variant: 'primary' }] }, '');
expect(html).toContain('Hi');
expect(html).toContain('Sub');
expect(html).toContain('Go');
});
test('box-model style props (margin/padding/border/boxShadow/opacity) flow through to the section style=""', () => {
const { html } = toHtml({
heading: 'Hi',
description: 'Sub',
style: { marginBottom: '12px', paddingRight: '6px', border: '2px dashed #333', boxShadow: '0 4px 8px rgba(0,0,0,0.12)', opacity: '0.75' },
}, '');
expect(html).toContain('margin-bottom:12px');
expect(html).toContain('padding-right:6px');
expect(html).toContain('border:2px dashed #333');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.75');
});
});
describe('CallToAction.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (CallToAction as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -21,6 +21,11 @@ interface CallToActionProps {
buttonColor?: string; buttonColor?: string;
anchorId?: string; anchorId?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)'; const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
@@ -131,6 +136,11 @@ CallToAction.craft = {
textColor: '#ffffff', textColor: '#ffffff',
buttonColor: '#ffffff', buttonColor: '#ffffff',
style: {}, style: {},
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -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,
@@ -62,3 +62,14 @@ describe('Countdown.toHtml script nit: ticking interval stops at zero', () => {
expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/); expect(html).toMatch(/if\s*\(\s*target\s*-\s*Date\.now\(\)\s*>\s*0\s*\)\s*\{/);
}); });
}); });
describe('Countdown.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Countdown as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -11,6 +11,11 @@ interface CountdownProps {
labelColor?: string; labelColor?: string;
bgColor?: string; bgColor?: string;
anchorId?: string; anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
interface TimeLeft { interface TimeLeft {
@@ -139,6 +144,11 @@ Countdown.craft = {
labelColor: 'rgba(255,255,255,0.7)', labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b', bgColor: '#18181b',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -21,3 +21,14 @@ describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml a
expect(html).toMatch(/<a href=""/); expect(html).toMatch(/<a href=""/);
}); });
}); });
describe('FeaturesGrid.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (FeaturesGrid as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -17,6 +17,11 @@ interface FeaturesGridProps {
features?: FeatureItem[]; features?: FeatureItem[];
style?: CSSProperties; style?: CSSProperties;
anchorId?: string; anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so // Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
@@ -98,6 +103,11 @@ FeaturesGrid.craft = {
features: defaultFeatures, features: defaultFeatures,
style: { backgroundColor: '#ffffff' }, style: { backgroundColor: '#ffffff' },
anchorId: '', anchorId: '',
animation: '',
animationDelay: '',
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,
@@ -73,3 +73,14 @@ describe('NumberCounter.toHtml counter.number is NOT runtime-type-checked -- mus
expect(html).toContain('data-target="150"'); expect(html).toContain('data-target="150"');
}); });
}); });
describe('NumberCounter.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (NumberCounter as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -16,6 +16,11 @@ interface NumberCounterProps {
labelColor?: string; labelColor?: string;
numberSize?: string; numberSize?: string;
style?: CSSProperties; style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultCounters: Counter[] = [ const defaultCounters: Counter[] = [
@@ -99,6 +104,11 @@ NumberCounter.craft = {
labelColor: '#6b7280', labelColor: '#6b7280',
numberSize: '48px', numberSize: '48px',
style: { backgroundColor: '#ffffff' }, style: { backgroundColor: '#ffffff' },
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -0,0 +1,105 @@
import { describe, test, expect } from 'vitest';
import { PricingTable } from './PricingTable';
const toHtml = (PricingTable as any).toHtml;
const plans = [
{ name: 'Basic', price: '$9', period: '/month', features: ['Feature A'], buttonText: 'Buy', buttonHref: '#', isFeatured: false },
{ name: 'Pro', price: '$29', period: '/month', features: ['Feature B'], buttonText: 'Buy', buttonHref: '#', isFeatured: true },
];
describe('PricingTable.toHtml regular-card color overrides (previously hard-coded, now real props)', () => {
test('cardBg emits as the non-featured card background', () => {
const { html } = toHtml({ plans, cardBg: '#f0f0f0' }, '');
expect(html).toContain('background-color:#f0f0f0');
});
test('textColor emits as the non-featured heading/price color', () => {
const { html } = toHtml({ plans, textColor: '#123456' }, '');
expect(html).toContain('color:#123456');
});
test('subColor emits as the non-featured period text color', () => {
const { html } = toHtml({ plans, subColor: '#abcdef' }, '');
expect(html).toContain('color:#abcdef');
});
test('featColor emits as the non-featured feature list text color', () => {
const { html } = toHtml({ plans, featColor: '#334455' }, '');
expect(html).toContain('color:#334455');
});
test('checkColor emits as the non-featured bullet color', () => {
const { html } = toHtml({ plans, checkColor: '#00ff00' }, '');
expect(html).toContain('color:#00ff00');
});
test('btnBg/btnColor emit as the non-featured button colors', () => {
const { html } = toHtml({ plans, btnBg: '#111111', btnColor: '#eeeeee' }, '');
expect(html).toContain('background-color:#111111');
expect(html).toContain('color:#eeeeee');
});
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
const { html } = toHtml({ plans }, '');
// Regular (non-featured) card literals unchanged from before these props existed.
expect(html).toContain('background-color:#ffffff');
expect(html).toContain('color:#18181b');
expect(html).toContain('color:#64748b');
expect(html).toContain('color:#4b5563');
expect(html).toContain('color:#10b981');
});
test('unset btnBg falls back to featuredBg (original derivation)', () => {
const { html } = toHtml({ plans, featuredBg: '#654321' }, '');
expect(html).toContain('background-color:#654321');
});
});
describe('PricingTable.toHtml XSS hardening (new card color props into style=)', () => {
test('a cardBg breakout string cannot escape style=""', () => {
const malicious = '#fff" onmouseover="alert(1)';
const { html } = toHtml({ plans, cardBg: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a textColor breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, textColor: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a btnBg breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, btnBg: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
test('a btnColor breakout string cannot escape style=""', () => {
const malicious = '#000" onmouseover="alert(1)';
const { html } = toHtml({ plans, btnColor: malicious }, '');
expect(html).not.toMatch(/"\s+onmouseover="/);
});
});
describe('PricingTable.craft.props includes the box-model/animation/visibility rollout props', () => {
test('the new regular-card color props are declared blank by default', () => {
const props = (PricingTable as any).craft.props;
expect(props).toHaveProperty('cardBg', '');
expect(props).toHaveProperty('textColor', '');
expect(props).toHaveProperty('subColor', '');
expect(props).toHaveProperty('featColor', '');
expect(props).toHaveProperty('checkColor', '');
expect(props).toHaveProperty('btnBg', '');
expect(props).toHaveProperty('btnColor', '');
});
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (PricingTable as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+71 -15
View File
@@ -19,6 +19,23 @@ interface PricingTableProps {
featuredBg?: string; featuredBg?: string;
bulletType?: string; bulletType?: string;
anchorId?: string; anchorId?: string;
/* ---- Regular (non-featured) card colors ----
All optional; each falls back to the exact literal the card was
previously hard-coded to (or, for the button, to featuredBg -- the
button's original derivation) when left unset, so existing saved
projects render pixel-identical until a color is explicitly picked. */
cardBg?: string;
textColor?: string;
subColor?: string;
featColor?: string;
checkColor?: string;
btnBg?: string;
btnColor?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const bulletChars: Record<string, string> = { const bulletChars: Record<string, string> = {
@@ -61,6 +78,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
featuredBg = '#3b82f6', featuredBg = '#3b82f6',
bulletType = 'check', bulletType = 'check',
anchorId, anchorId,
cardBg,
textColor,
subColor,
featColor,
checkColor,
btnBg,
btnColor,
}) => { }) => {
const { const {
connectors: { connect, drag }, connectors: { connect, drag },
@@ -69,6 +93,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
selected: node.events.selected, selected: node.events.selected,
})); }));
const regCardBg = cardBg || '#ffffff';
const regTextColor = textColor || '#18181b';
const regSubColor = subColor || '#64748b';
const regFeatColor = featColor || '#4b5563';
const regCheckColor = checkColor || '#10b981';
const regBtnBg = btnBg || featuredBg;
const regBtnColor = btnColor || '#ffffff';
return ( return (
<section <section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }} ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
@@ -95,7 +127,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
style={{ style={{
flex: '1 1 280px', flex: '1 1 280px',
maxWidth: '360px', maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff', backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0', border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
borderRadius: '16px', borderRadius: '16px',
padding: '40px 32px', padding: '40px 32px',
@@ -127,7 +159,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<h3 style={{ <h3 style={{
fontSize: '20px', fontSize: '20px',
fontWeight: '600', fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : '#18181b', color: plan.isFeatured ? '#ffffff' : regTextColor,
marginBottom: '8px', marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0', marginTop: plan.isFeatured ? '8px' : '0',
}}> }}>
@@ -137,14 +169,14 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<span style={{ <span style={{
fontSize: '48px', fontSize: '48px',
fontWeight: '700', fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : '#18181b', color: plan.isFeatured ? '#ffffff' : regTextColor,
lineHeight: '1', lineHeight: '1',
}}> }}>
{plan.price} {plan.price}
</span> </span>
<span style={{ <span style={{
fontSize: '16px', fontSize: '16px',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b', color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
}}> }}>
{plan.period} {plan.period}
</span> </span>
@@ -161,12 +193,12 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => ( {(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
<li key={fi} style={{ <li key={fi} style={{
fontSize: '14px', fontSize: '14px',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563', color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '8px',
}}> }}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span> <span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature} {feature}
</li> </li>
))} ))}
@@ -178,8 +210,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
marginTop: 'auto', marginTop: 'auto',
display: 'inline-block', display: 'inline-block',
padding: '14px 32px', padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg, backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
color: plan.isFeatured ? featuredBg : '#ffffff', color: plan.isFeatured ? featuredBg : regBtnColor,
textDecoration: 'none', textDecoration: 'none',
borderRadius: '8px', borderRadius: '8px',
fontWeight: '600', fontWeight: '600',
@@ -207,6 +239,18 @@ PricingTable.craft = {
featuredBg: '#3b82f6', featuredBg: '#3b82f6',
bulletType: 'check', bulletType: 'check',
anchorId: '', anchorId: '',
cardBg: '',
textColor: '',
subColor: '',
featColor: '',
checkColor: '',
btnBg: '',
btnColor: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -228,16 +272,28 @@ PricingTable.craft = {
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives // Sanitized -- featuredBg is a raw string-interpolation sink below (drives
// cardBg/btnBg/btnColor, all raw-interpolated into style="..."). // cardBg/btnBg/btnColor, all raw-interpolated into style="...").
const featuredBg = cssValue(props.featuredBg) || '#3b82f6'; const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
// Sanitized -- regular (non-featured) card color overrides, all raw
// string-interpolation sinks into style="..." below. Each falls back to
// the exact literal the card was previously hard-coded to (or, for the
// button, to featuredBg) when unset, so unmodified pricing tables render
// identically to before these props existed.
const regCardBg = cssValue(props.cardBg) || '#ffffff';
const regTextColor = cssValue(props.textColor) || '#18181b';
const regSubColor = cssValue(props.subColor) || '#64748b';
const regFeatColor = cssValue(props.featColor) || '#4b5563';
const regCheckColor = cssValue(props.checkColor) || '#10b981';
const regBtnBg = cssValue(props.btnBg) || featuredBg;
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
const cards = plans.map((plan) => { const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff'; const cardBg = plan.isFeatured ? featuredBg : regCardBg;
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;'; const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : '#18181b'; const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b'; const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563'; const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981'; const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg; const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
const btnColor = plan.isFeatured ? featuredBg : '#ffffff'; const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
const scale = plan.isFeatured ? 'transform:scale(1.05);' : ''; const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);'; const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
@@ -80,3 +80,14 @@ describe('Tabs.toHtml deterministic + unique ids (thread node id, resolves id-co
expect(html1).toBe(html2); expect(html1).toBe(html2);
}); });
}); });
describe('Tabs.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Tabs as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
+10
View File
@@ -17,6 +17,11 @@ interface TabsProps {
inactiveTabColor?: string; inactiveTabColor?: string;
contentBg?: string; contentBg?: string;
anchorId?: string; anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultTabs: TabItem[] = [ const defaultTabs: TabItem[] = [
@@ -114,6 +119,11 @@ Tabs.craft = {
inactiveTabColor: '#64748b', inactiveTabColor: '#64748b',
contentBg: '#ffffff', contentBg: '#ffffff',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -71,3 +71,14 @@ describe('Testimonials.toHtml rating aria-label sink (attacker-controlled `ratin
expect(html).toContain('aria-label="Rating: 4 out of 5"'); expect(html).toContain('aria-label="Rating: 4 out of 5"');
}); });
}); });
describe('Testimonials.craft.props includes the box-model/animation/visibility rollout props', () => {
test('animation, animationDelay, and all 3 hideOn* flags are declared (blank/false defaults)', () => {
const props = (Testimonials as any).craft.props;
expect(props).toHaveProperty('animation', '');
expect(props).toHaveProperty('animationDelay', '');
expect(props).toHaveProperty('hideOnDesktop', false);
expect(props).toHaveProperty('hideOnTablet', false);
expect(props).toHaveProperty('hideOnMobile', false);
});
});
@@ -18,6 +18,11 @@ interface TestimonialsProps {
cardBg?: string; cardBg?: string;
starColor?: string; starColor?: string;
anchorId?: string; anchorId?: string;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
} }
const defaultTestimonials: Testimonial[] = [ const defaultTestimonials: Testimonial[] = [
@@ -131,6 +136,11 @@ Testimonials.craft = {
cardBg: '#f8fafc', cardBg: '#f8fafc',
starColor: '#f59e0b', starColor: '#f59e0b',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
+65
View File
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
tablet: '768px', tablet: '768px',
mobile: '375px', mobile: '375px',
}; };
/* ---------- ENH-Foundation shared presets ----------
Consumed by the reusable StylePanel controls in
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
// Width/height preset row used by SizeControl (image + video sizing).
export const SIZE_PRESETS = [
{ label: '25%', value: '25%' },
{ label: '50%', value: '50%' },
{ label: '75%', value: '75%' },
{ label: '100%', value: '100%' },
{ label: 'Auto', value: 'auto' },
{ label: 'Full', value: '100vw' },
];
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
export const ASPECT_RATIOS = [
{ label: 'Original', value: '' },
{ label: '1:1', value: '1 / 1' },
{ label: '4:3', value: '4 / 3' },
{ label: '3:2', value: '3 / 2' },
{ label: '16:9', value: '16 / 9' },
{ label: '4:5', value: '4 / 5' },
{ label: '9:16', value: '9 / 16' },
];
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
export const SHADOW_PRESETS = [
{ label: 'None', value: 'none' },
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
];
export const LINE_HEIGHTS = [
{ label: 'Tight', value: '1.1' },
{ label: 'Snug', value: '1.25' },
{ label: 'Normal', value: '1.5' },
{ label: 'Relaxed', value: '1.75' },
{ label: 'Loose', value: '2' },
];
export const LETTER_SPACINGS = [
{ label: 'Tight', value: '-0.02em' },
{ label: 'Normal', value: 'normal' },
{ label: 'Wide', value: '0.05em' },
{ label: 'Wider', value: '0.1em' },
];
export const OBJECT_FIT = [
{ label: 'Cover', value: 'cover' },
{ label: 'Contain', value: 'contain' },
{ label: 'Fill', value: 'fill' },
{ label: 'None', value: 'none' },
];
export const BORDER_STYLES = [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' },
];
+49 -2
View File
@@ -5,6 +5,8 @@ import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets'; import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
interface CanvasProps { interface CanvasProps {
device: DeviceMode; device: DeviceMode;
@@ -112,13 +114,18 @@ export const EmptyCanvasHint: React.FC = () => {
isDragging: state.events.dragged.size > 0, isDragging: state.events.dragged.size > 0,
}; };
}); });
const isMobile = useIsMobile();
if (!isEmpty || isDragging) return null; if (!isEmpty || isDragging) return null;
return ( return (
<div className="empty-canvas-hint"> <div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden /> <i className="fa fa-cubes" aria-hidden />
<span>Drag blocks from the left panel, or pick a Template to start.</span> <span>
{isMobile
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
: 'Drag blocks from the left panel, or pick a Template to start.'}
</span>
</div> </div>
); );
}; };
@@ -129,6 +136,42 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const isEditingRegularPage = !isEditingHeader && !isEditingFooter; const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
// tall, just above the tab bar), it covers whatever content was at the
// very bottom of the canvas's own scroll area -- on a short page, the
// last section could sit permanently under the toolbar with no way to
// scroll past it. Mirrors that toolbar's own visibility condition
// (`selectedId && activeSheet === null`) exactly, computed independently
// here since Canvas has no other reason to depend on the toolbar
// component itself.
const isMobile = useIsMobile();
const { activeSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
// selected node up towards the top of the canvas so it stays visible
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
// by the canvas's own natural scroll range. For a selected node near the
// END of the content (very plausibly the last section on the page --
// exactly the kind of node someone just added/duplicated and wants to
// style), there may not be enough scrollable distance below it to bring
// its top all the way up to the visible band above the sheet; the browser
// simply clamps at its existing max scrollTop, leaving the node's top
// stuck behind the sheet with nothing anyone can do about it (verified
// live: the last section of a page landed under the sheet even after the
// scroll-into-view ran). Pad the canvas with a full extra viewport's
// worth of scroll room while the sheet is open with a selection, exactly
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
// fixed toolbar -- generous enough that ANY node, including the very
// last one, can always be scrolled with its top reaching the very top of
// the canvas (comfortably within the "top ~30%" target).
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
const frameStyle = isEditingHeader const frameStyle = isEditingHeader
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' } ? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
: isEditingFooter : isEditingFooter
@@ -138,7 +181,11 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div'; const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
return ( return (
<div className="editor-canvas"> <div
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
}`}
>
<div <div
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`} className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
style={{ style={{
+48 -21
View File
@@ -1,12 +1,16 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar'; import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel'; import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel'; import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu'; import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
@@ -21,7 +25,21 @@ function loadShowGuides(): boolean {
} }
export const EditorShell: React.FC = () => { export const EditorShell: React.FC = () => {
const isMobile = useIsMobile();
const [device, setDevice] = useState<DeviceMode>('desktop'); const [device, setDevice] = useState<DeviceMode>('desktop');
// Phase A: default the canvas to the "mobile" preview width the first
// time we detect a mobile viewport, so the frame fits without the user
// having to reach for the device switcher (now tucked in TopBar's
// overflow menu on mobile). Only fires once, and only if the device is
// still at its initial default -- it must not fight a device the user
// has already picked (e.g. from the overflow menu) on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted // Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by // so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but // TopBar or Canvas alone) because the toggle button lives in TopBar but
@@ -59,27 +77,36 @@ export const EditorShell: React.FC = () => {
}, [query, showMenu]); }, [query, showMenu]);
return ( return (
<div className="editor-app"> // Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
<TopBar // provided around the whole shell so TopBar's Templates/Head Code modal
device={device} // state and MobilePanelBar's sheet state live in one place. Desktop
onDeviceChange={setDevice} // doesn't render MobilePanelBar and TopBar's desktop branch behaves
showGuides={showGuides} // identically to before (same booleans, just sourced from context).
onToggleGuides={() => setShowGuides(!showGuides)} <MobileChromeProvider>
/> <div className="editor-app">
<div className="editor-container"> <TopBar
<LeftPanel /> device={device}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}> onDeviceChange={setDevice}
<Canvas device={device} showGuides={showGuides} /> showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
</div>
{!isMobile && <RightPanel />}
</div> </div>
<RightPanel /> {isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div> </div>
<ContextMenu </MobileChromeProvider>
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
); );
}; };
+153
View File
@@ -0,0 +1,153 @@
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 { useIsMobile } from './useIsMobile';
/**
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
* from, so it needs direct coverage of: (a) it reflects matchMedia's
* `matches`, (b) the `change` listener is registered AND cleaned up on
* unmount (a leaked listener would keep re-rendering an unmounted tree /
* leak the component instance), and (c) the legacy Safari<14
* addListener/removeListener fallback is used when addEventListener isn't
* available.
*
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
* (not a project dependency).
*/
let container: HTMLDivElement;
let root: Root;
let originalMatchMedia: typeof window.matchMedia;
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();
}
/** A minimal fake MediaQueryList. `mode` picks which listener API it
* exposes, so tests can force the legacy fallback path. */
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
const listeners: Array<() => void> = [];
const mql: any = {
matches,
media: '(max-width: 768px)',
};
if (mode === 'modern') {
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
} else {
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
mql.removeListener = vi.fn((fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
}
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
}
let probedValue: boolean | null = null;
const Probe: React.FC = () => {
probedValue = useIsMobile();
return <span data-value={String(probedValue)} />;
};
beforeEach(() => {
originalMatchMedia = window.matchMedia;
probedValue = null;
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
describe('useIsMobile', () => {
test('returns true when the mobile media query matches', () => {
const { mql } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(true);
unmount();
});
test('returns false when the mobile media query does not match', () => {
const { mql } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
unmount();
});
test('registers the change listener via addEventListener and updates on change', () => {
const { mql, fire, listenerCount } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(1);
// Flip the query result and fire the registered 'change' listener --
// the hook must re-read mql.matches, not just toggle blindly.
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
});
test('cleans up the addEventListener listener on unmount', () => {
const { mql, listenerCount } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(listenerCount()).toBe(1);
unmount();
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(0);
});
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(1);
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
* CSS and this hook must agree on where mobile chrome kicks in. */
export const MOBILE_BREAKPOINT_PX = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
function computeIsMobile(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
try {
return window.matchMedia(MOBILE_QUERY).matches;
} catch {
// Fall through to the width check below (e.g. some older/embedded
// WebViews expose a matchMedia that throws instead of omitting it).
}
}
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
/**
* Reports whether the viewport is at or below the mobile editor breakpoint.
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
* above the breakpoint, so this is the single source of truth both React
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
*
* Falls back to a `resize` listener + `window.innerWidth` when
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
* hook degrades gracefully rather than throwing.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'function') {
let mql: MediaQueryList | null = null;
try {
mql = window.matchMedia(MOBILE_QUERY);
} catch {
mql = null;
}
if (mql) {
const handleChange = () => setIsMobile(mql!.matches);
handleChange();
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handleChange);
return () => mql!.removeEventListener('change', handleChange);
}
// Safari < 14 only supports the deprecated addListener/removeListener pair.
const legacyMql = mql as MediaQueryList & {
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
};
legacyMql.addListener?.(handleChange);
return () => legacyMql.removeListener?.(handleChange);
}
}
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
}
+258
View File
@@ -0,0 +1,258 @@
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 type { NodeTree, Node } from '@craftjs/core';
import { useNodeActions, type NodeActions } from './useNodeActions';
/**
* Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so
* the desktop right-click menu and the mobile selection toolbar
* (`MobileSelectionToolbar`) share one implementation. This suite exercises
* the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the
* pattern in `useKeyboardShortcuts.test.tsx`), covering:
* - moveUp/moveDown call `actions.move` with the right target index, and
* are no-ops at the respective boundary
* - canMoveUp/canMoveDown reflect the node's position among its siblings
* - duplicate regenerates ids, inserts the copy IMMEDIATELY AFTER the
* source node (`actions.addNodeTree(tree, parentId, sourceIndex + 1)`),
* and selects the new copy (`actions.selectNode(tree.rootNodeId)`) --
* Phase B fast-follow: the previous append-at-end behavior left the copy
* off-screen with the ORIGINAL still selected, so users couldn't see
* what "Duplicate" had just done.
* - selectParent calls `actions.selectNode` with the parent id
* - canSelectParent reflects whether the node's parent is itself real
* (not ROOT/missing) -- false at a top-level section, true one level in
* - deleteNode routes through `findDeletableTarget` (also covering the
* "no deletable target" no-op)
* - ROOT/null nodeId disables every action safely (no-ops, all
* canMoveUp/canMoveDown/canDelete/canSelectParent false)
*/
const moveMock = vi.fn();
const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
const deleteMock = vi.fn();
interface FakeNodeData {
parent: string | null;
nodes: string[];
}
let fakeNodes: Record<string, { data: FakeNodeData }> = {};
const COPIED_TREE: NodeTree = {
rootNodeId: 'child-1',
nodes: {
'child-1': {
id: 'child-1',
data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as Node,
},
};
function makeQuery() {
return {
node: (id: string) => ({
get: () => fakeNodes[id] ?? null,
toNodeTree: () => {
if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
return COPIED_TREE;
},
}),
};
}
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: any, query: any) => any) => {
const state = {
nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
};
const query = makeQuery();
const collected = collector ? collector(state, query) : {};
return {
...collected,
actions: {
move: moveMock,
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
delete: deleteMock,
},
query,
};
},
}));
vi.mock('../utils/craft-tree', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
return { ...actual, regenerateTreeIds: vi.fn(actual.regenerateTreeIds) };
});
import { regenerateTreeIds } from '../utils/craft-tree';
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
let container: HTMLDivElement;
let root: Root;
let captured: NodeActions | null = null;
const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
captured = useNodeActions(nodeId);
return null;
};
function render(nodeId: string | null) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<Probe nodeId={nodeId} />);
});
}
function rerender(nodeId: string | null) {
act(() => {
root.render(<Probe nodeId={nodeId} />);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
beforeEach(() => {
moveMock.mockClear();
addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
deleteMock.mockClear();
regenerateTreeIdsMock.mockClear();
captured = null;
fakeNodes = {
ROOT: { data: { parent: null, nodes: ['parent-1'] } },
'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
'child-0': { data: { parent: 'parent-1', nodes: [] } },
'child-1': { data: { parent: 'parent-1', nodes: [] } },
'child-2': { data: { parent: 'parent-1', nodes: [] } },
};
});
afterEach(() => {
if (root) unmount();
});
describe('useNodeActions', () => {
test('a middle child can move both up and down', () => {
render('child-1');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(true);
});
test('the first child cannot move up, but can move down', () => {
render('child-0');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(true);
});
test('the last child can move up, but not down', () => {
render('child-2');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(false);
});
test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
render('child-1');
act(() => captured!.moveUp());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
moveMock.mockClear();
act(() => captured!.moveDown());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
});
test('moveUp/moveDown at a boundary are no-ops', () => {
render('child-0');
act(() => captured!.moveUp());
expect(moveMock).not.toHaveBeenCalled();
rerender('child-2');
act(() => captured!.moveDown());
expect(moveMock).not.toHaveBeenCalled();
});
test('duplicate inserts the copy immediately after the source (parentId, sourceIndex + 1) and selects it', () => {
render('child-1');
let returned: string | null | undefined;
act(() => {
returned = captured!.duplicate();
});
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [tree, parentId, index] = addNodeTreeMock.mock.calls[0];
// child-1 is at index 1 among ['child-0', 'child-1', 'child-2'] -> insert at 2.
expect(parentId).toBe('parent-1');
expect(index).toBe(2);
expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
expect(selectNodeMock).toHaveBeenCalledTimes(1);
expect(selectNodeMock).toHaveBeenCalledWith(tree.rootNodeId);
expect(returned).toBe(tree.rootNodeId);
});
test('selectParent calls actions.selectNode with the parent id', () => {
render('child-1');
act(() => captured!.selectParent());
expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
});
test('canSelectParent is false at a top-level node (parent is ROOT), true one level deeper', () => {
render('parent-1'); // parent-1's own parent is 'ROOT'
expect(captured!.canSelectParent).toBe(false);
rerender('child-1'); // child-1's parent is 'parent-1', a real node
expect(captured!.canSelectParent).toBe(true);
});
test('deleteNode calls actions.delete with the node id when deletable', () => {
render('child-1');
expect(captured!.canDelete).toBe(true);
act(() => captured!.deleteNode());
expect(deleteMock).toHaveBeenCalledWith('child-1');
});
test('ROOT nodeId disables every action and is a safe no-op', () => {
render('ROOT');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
act(() => {
captured!.moveUp();
captured!.moveDown();
captured!.duplicate();
captured!.selectParent();
captured!.deleteNode();
});
expect(moveMock).not.toHaveBeenCalled();
expect(addNodeTreeMock).not.toHaveBeenCalled();
expect(selectNodeMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
});
test('null nodeId disables every action and is a safe no-op', () => {
render(null);
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { useEditor } from '@craftjs/core';
import { regenerateTreeIds } from '../utils/craft-tree';
import { findDeletableTarget } from '../utils/craft-helpers';
export interface NodeActions {
moveUp: () => void;
moveDown: () => void;
/** Duplicates `nodeId`, inserting the copy immediately after the source in
* the parent's children and selecting it. Returns the new node's id (so
* callers -- e.g. `MobileSelectionToolbar` -- can scroll it into view),
* or null if the duplicate could not be performed (no-op for ROOT/null,
* or a caught error). */
duplicate: () => string | null;
deleteNode: () => void;
selectParent: () => void;
/** True if `nodeId` has an earlier sibling under the same parent (so
* `moveUp` would actually move it). False for ROOT/null/no-parent. */
canMoveUp: boolean;
/** True if `nodeId` has a later sibling under the same parent (so
* `moveDown` would actually move it). False for ROOT/null/no-parent. */
canMoveDown: boolean;
/** True if `findDeletableTarget` resolves to a real, deletable node (either
* `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node
* slot whose siblings are all also empty -- see `craft-helpers.ts`). */
canDelete: boolean;
/** True if `nodeId` has a real parent to select -- i.e. the parent is
* neither ROOT nor missing. `selectParent` on a top-level section (whose
* parent IS 'ROOT') would only select the page-wide ROOT node, which has
* no on-canvas outline and no toolbar of its own -- a dead end for a
* mobile user with no way back. False for ROOT/null/no-parent too. */
canSelectParent: boolean;
}
/**
* Shared node-action logic (move up/down, duplicate, delete, select parent)
* extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click
* menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`)
* drive the exact same behavior from one place instead of two independent
* copies drifting apart.
*
* IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor`
* collector (`@craftjs/utils`' `useCollector`) only synchronously computes
* the collector function ONCE, on this hook's very first mount. After that,
* it updates purely in reaction to the underlying store's OWN change
* notifications, invoking whatever collector closure is current AT THAT
* NOTIFICATION -- so a collector that closes over `nodeId` (an argument that
* changes across renders of the SAME mounted hook instance, e.g. every time
* `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale
* for exactly one render: the cached value from the last store notification
* (computed against the PREVIOUS nodeId) is what gets returned, until some
* unrelated store event happens to trigger a fresh computation. An earlier
* version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside
* such a collector and was caught showing the PREVIOUS selection's move
* boundaries in the mobile toolbar for one render after tapping a new node
* (verified with real Playwright touch taps against a real Craft.js
* document -- a plain mocked `useEditor` in a unit test doesn't reproduce
* this, since a hand-rolled mock has no reason to replicate the real
* library's caching).
*
* The fix: get `actions`/`query` from a collector-FREE `useEditor()` call
* (an always-live reference, same pattern `ContextMenu.tsx` used before this
* extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain
* synchronous code during render using that live `query` -- never cached.
*
* That still leaves one gap: `nodeId` staying the SAME across renders while
* its position changes (e.g. tapping "Move Up" repeatedly on the same
* still-selected node) needs SOMETHING to trigger a re-render so the boundary
* flags below get recomputed. An earlier version of this hook forced that
* via a second `useEditor((state) => ({ _: state.nodes }))` subscription --
* but subscribing to the WHOLE node map reacts to every `dom` ref
* assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM`
* synchronously from a React ref callback during COMMIT, i.e. while some
* OTHER component is still mounting), which trips React's "Cannot update a
* component while rendering a different component" warning the moment a
* freshly-added container with children mounts. Callers that need
* `canMoveUp`/`canMoveDown` to refresh after a move they themselves
* triggered (`MobileSelectionToolbar`) should instead bump their OWN local
* state right after calling `moveUp`/`moveDown` -- a plain, local,
* event-handler-triggered re-render, not a store-wide subscription.
*/
export function useNodeActions(nodeId: string | null | undefined): NodeActions {
const { actions, query } = useEditor();
const isRootOrNull = !nodeId || nodeId === 'ROOT';
let canMoveUp = false;
let canMoveDown = false;
let canSelectParent = false;
if (!isRootOrNull) {
try {
const node = query.node(nodeId).get();
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
canSelectParent = parentId !== 'ROOT';
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
canMoveUp = idx > 0;
canMoveDown = idx !== -1 && idx < siblings.length - 1;
}
} catch {
// Node no longer exists (e.g. deleted out from under a stale
// reference) -- leave both false.
}
}
const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId);
const getParentId = (): string | null => {
if (!nodeId) return null;
try {
const node = query.node(nodeId).get();
return node?.data?.parent || null;
} catch {
return null;
}
};
const duplicate = (): string | null => {
if (!nodeId || nodeId === 'ROOT') return null;
try {
const parentId = getParentId();
if (!parentId) return null;
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
// Insert the copy IMMEDIATELY AFTER the source node, not appended at
// the end of the parent -- on a top-level section, "append at end"
// landed the copy at the bottom of the page, off-screen, with the
// ORIGINAL still selected, so a duplicate looked like nothing had
// happened at all. Resolve the source's index among its siblings so
// the copy lands right next to what it was copied from; if that can't
// be resolved for any reason, fall back to the old append behavior
// rather than guessing at an index (or throwing).
let insertIndex: number | undefined;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const sourceIndex = siblings.indexOf(nodeId);
if (sourceIndex !== -1) insertIndex = sourceIndex + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
if (insertIndex !== undefined) {
actions.addNodeTree(tree, parentId, insertIndex);
} else {
actions.addNodeTree(tree, parentId);
}
// Select the new copy (not the original) so the toolbar/context menu
// and any on-canvas outline immediately reflect what was just created.
actions.selectNode(tree.rootNodeId);
return tree.rootNodeId;
} catch (e) {
console.error('Duplicate failed:', e);
return null;
}
};
const moveUp = () => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
};
const moveDown = () => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
};
const selectParent = () => {
if (!nodeId || nodeId === 'ROOT') return;
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
};
const deleteNode = () => {
const target = findDeletableTarget(query, nodeId);
if (!target) return;
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
};
return {
moveUp,
moveDown,
duplicate,
deleteNode,
selectParent,
canMoveUp,
canMoveDown,
canDelete,
canSelectParent,
};
}
+146
View File
@@ -0,0 +1,146 @@
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 { useVisualViewportInsets } from './useVisualViewport';
/**
* Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's
* keyboard-avoidance source of truth). Mirrors the bare createRoot + act
* harness `useIsMobile.test.tsx` uses (no @testing-library/react in this
* repo). Covers:
* - the no-`visualViewport` fallback (older WebViews) returns
* `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no
* listener (there's nothing to listen to).
* - the keyboard-open math: `keyboardInset = innerHeight - (vv.height +
* vv.offsetTop)`, recomputed when the mocked vv fires `resize`.
* - both `resize` and `scroll` listeners are added on mount and the SAME
* handler is removed for both on unmount (a leaked listener would keep
* reacting after the owning component -- e.g. BottomSheet -- is gone).
*/
let container: HTMLDivElement;
let root: Root;
let originalInnerHeight: number;
let originalVisualViewport: VisualViewport | null;
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();
}
/** A minimal fake `visualViewport`: just enough surface for the hook
* (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */
function makeVisualViewport(height: number, offsetTop: number) {
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
const vv: any = {
height,
offsetTop,
addEventListener: vi.fn((type: string, fn: () => void) => {
listeners[type].push(fn);
}),
removeEventListener: vi.fn((type: string, fn: () => void) => {
const i = listeners[type].indexOf(fn);
if (i !== -1) listeners[type].splice(i, 1);
}),
};
return {
vv,
fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()),
listenerCount: (type: 'resize' | 'scroll') => listeners[type].length,
};
}
let probed: { height: number; keyboardInset: number } | null = null;
const Probe: React.FC = () => {
probed = useVisualViewportInsets();
return <span data-height={probed.height} data-inset={probed.keyboardInset} />;
};
beforeEach(() => {
originalInnerHeight = window.innerHeight;
originalVisualViewport = window.visualViewport;
probed = null;
});
afterEach(() => {
Object.defineProperty(window, 'innerHeight', {
value: originalInnerHeight,
configurable: true,
writable: true,
});
Object.defineProperty(window, 'visualViewport', {
value: originalVisualViewport,
configurable: true,
});
});
describe('useVisualViewportInsets', () => {
test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
render(<Probe />);
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
unmount();
});
test('computes keyboardInset from visualViewport and recomputes on resize', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
const { vv, fire } = makeVisualViewport(800, 0);
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
render(<Probe />);
// No keyboard open: vv fills the layout viewport exactly.
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
// Simulate the on-screen keyboard opening: the visual viewport shrinks.
vv.height = 500;
vv.offsetTop = 0;
act(() => {
fire('resize');
});
expect(probed).toEqual({ height: 500, keyboardInset: 300 });
unmount();
});
test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
const { vv, listenerCount } = makeVisualViewport(800, 0);
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
render(<Probe />);
expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(listenerCount('resize')).toBe(1);
expect(listenerCount('scroll')).toBe(1);
const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1];
const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1];
expect(resizeHandler).toBe(scrollHandler);
unmount();
expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler);
expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler);
expect(listenerCount('resize')).toBe(0);
expect(listenerCount('scroll')).toBe(0);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
export interface VisualViewportInsets {
/** The visual viewport's current height (shrinks when the on-screen
* keyboard opens). Falls back to `window.innerHeight` when
* `visualViewport` isn't supported. */
height: number;
/** Extra inset a `position: fixed`, bottom-anchored element should add to
* its own `bottom` offset to stay clear of the on-screen keyboard --
* `window.innerHeight` minus the visual viewport's bottom edge (its
* height + offsetTop). Zero whenever no keyboard is open, or
* `visualViewport` isn't supported (a safe no-op fallback). */
keyboardInset: number;
}
const ZERO_INSETS: VisualViewportInsets = { height: 0, keyboardInset: 0 };
function computeInsets(): VisualViewportInsets {
if (typeof window === 'undefined') return ZERO_INSETS;
const vv = window.visualViewport;
if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
const keyboardInset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
return { height: vv.height, keyboardInset };
}
/**
* Tracks `window.visualViewport`'s height/offset (item 5, Phase B) so
* `BottomSheet` can stay clear of the on-screen keyboard. `position: fixed`
* elements are positioned against the LAYOUT viewport, which does NOT shrink
* when a mobile keyboard opens -- only the visual viewport does -- so a
* bottom-anchored sheet's inputs can otherwise end up hidden underneath the
* keyboard with no visual indication anything is wrong.
*
* Guards for browsers without `visualViewport` (older WebViews): falls back
* to `{ height: window.innerHeight, keyboardInset: 0 }`, i.e. a no-op, so
* the sheet just keeps its existing (keyboard-unaware) sizing there.
*/
export function useVisualViewportInsets(): VisualViewportInsets {
const [insets, setInsets] = useState<VisualViewportInsets>(computeInsets);
useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const vv = window.visualViewport;
const handleChange = () => setInsets(computeInsets());
handleChange();
vv.addEventListener('resize', handleChange);
vv.addEventListener('scroll', handleChange);
return () => {
vv.removeEventListener('resize', handleChange);
vv.removeEventListener('scroll', handleChange);
};
}, []);
return insets;
}
+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();
});
});
+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');
});
}); });
+11 -2
View File
@@ -149,6 +149,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 +172,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,8 +301,8 @@ 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
+23 -58
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
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 { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -65,19 +65,16 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
} }
}, [nodeId, query]); }, [nodeId, query]);
const duplicate = useCallback(() => { // Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
if (!nodeId || nodeId === 'ROOT') return; // extracted into `useNodeActions` so the mobile selection toolbar drives
try { // the exact same behavior. Wrapped here purely to also `onClose()` the
const parentId = getParentId(); // menu after each action, same as before the extraction.
if (!parentId) return; const nodeActions = useNodeActions(nodeId);
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree()); const duplicate = useCallback(() => {
actions.addNodeTree(tree, parentId); nodeActions.duplicate();
} catch (e) {
console.error('Duplicate failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
@@ -118,47 +115,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, actions, query, onClose]); }, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => { const moveUp = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveUp();
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const moveDown = useCallback(() => { const moveDown = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveDown();
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const selectParent = useCallback(() => { const selectParent = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.selectParent();
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
onClose(); onClose();
}, [nodeId, actions, getParentId, onClose]); }, [nodeActions, onClose]);
const askSitesmith = useCallback(() => { const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
@@ -172,18 +141,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, query, openSitesmith, onClose]); }, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => { const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId); nodeActions.deleteNode();
if (!target) {
onClose();
return;
}
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, onClose]); }, [nodeActions, onClose]);
if (!visible) return null; if (!visible) return null;
@@ -235,7 +195,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
label: 'Select Parent', label: 'Select Parent',
icon: 'level-up', icon: 'level-up',
action: selectParent, action: selectParent,
disabled: isRoot, // Not just `isRoot`: the real dead-end is when nodeId's PARENT is
// ROOT (selecting a top-level section -> Select Parent would only
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
// `canSelectParent` (useNodeActions) already encodes that and matches
// the mobile selection toolbar's identical guard.
disabled: !nodeActions.canSelectParent,
dividerAfter: true, dividerAfter: true,
}, },
{ {
+5 -1
View File
@@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets'; import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard'; import { copyToClipboard } from '../../utils/clipboard';
import { useIsMobile } from '../../hooks/useIsMobile';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */ /** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000; const DELETE_CONFIRM_TIMEOUT_MS = 4000;
export const AssetsPanel: React.FC = () => { export const AssetsPanel: React.FC = () => {
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets(); const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
const isMobile = useIsMobile();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null); const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
@@ -139,7 +141,9 @@ export const AssetsPanel: React.FC = () => {
{assets.length === 0 && ( {assets.length === 0 && (
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} /> <i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
)} )}
{assets.length === 0 ? 'Drag images here or click to upload' : 'Drop files here to upload'} {assets.length === 0
? (isMobile ? 'Tap to upload images' : 'Drag images here or click to upload')
: 'Drop files here to upload'}
</div> </div>
{/* Error message */} {/* Error message */}
+103 -19
View File
@@ -1,5 +1,7 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container'; import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section'; import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout'; import { ColumnLayout } from '../../components/layout/ColumnLayout';
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
export const BlocksPanel: React.FC = () => { export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor(); const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => { const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => { categories.forEach((cat, index) => {
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] })); setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
}; };
/** Default insertion target when there's no usable selection to anchor
* to: the first real Craft.js canvas in the document (falls back to
* ROOT). Extracted from the pre-existing onDoubleClick handler so
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
* the exact same fallback. */
const findDefaultCanvasId = useCallback((): string => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
for (const id of nodeIds) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}, [query]);
/**
* Builds a fresh node tree for `block` and inserts it into the canvas,
* returning the new node's id (or null on failure). Shared by desktop's
* double-click (unchanged behavior/position: always appended to
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
* prefers inserting as a sibling right after the current selection --
* `insertAfterSelection: true` only from the mobile tap handler below.
*/
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
try {
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
let inserted = false;
if (insertAfterSelection) {
try {
const selectedIds = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const selectedNode = query.node(selectedId).get();
const parentId = selectedNode?.data?.parent;
if (parentId) {
const parent = query.node(parentId).get();
const siblings: string[] = parent?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. lives in a
// linkedNodes slot) -- fall through to the default canvas below.
}
}
if (!inserted) {
actions.addNodeTree(tree, findDefaultCanvasId());
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}, [query, actions, findDefaultCanvasId]);
/**
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
* closes the Blocks sheet, then selects the new node and scrolls it into
* view so the user immediately sees it (and gets the selection toolbar).
* The select/scroll step is deferred two animation frames past the
* `addNodeTree` call -- Craft.js's own state update (and thus the new
* node's real DOM element) lands asynchronously after this handler
* returns, so `query.node(id).get().dom` isn't populated yet if read
* synchronously here.
*/
const handleTapToAdd = useCallback((block: BlockDef) => {
const newId = addBlockNode(block, true);
closeSheet();
if (!newId) return;
requestAnimationFrame(() => {
try {
actions.selectNode(newId);
} catch {
// Node may have failed to mount -- nothing to select.
}
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [addBlockNode, closeSheet, actions, query]);
return ( return (
<div> <div>
{categories.map((category) => { {categories.map((category) => {
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
key={block.id} key={block.id}
className="block-item" className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }} ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => { onDoubleClick={() => addBlockNode(block, false)}
try { onClick={isMobile ? () => handleTapToAdd(block) : undefined}
const serialized = JSON.parse(query.serialize()); title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
const nodeIds = Object.keys(serialized);
let canvasId = 'ROOT';
for (const nodeId of nodeIds) {
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
canvasId = nodeId;
break;
}
}
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
actions.addNodeTree(tree, canvasId);
} catch (e) {
console.error('Failed to add block:', e);
}
}}
title={`Drag or double-click to add ${block.label}`}
> >
<i className={`fa ${block.icon} block-item-icon`} /> <i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span> <span className="block-item-label">{block.label}</span>
+1
View File
@@ -146,6 +146,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
{...clickableProps(handleActivate)} {...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className="layer-node-row"
style={{ style={{
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
+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',
};
+31
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePages } from '../../state/PageContext'; import { usePages } 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,6 +15,7 @@ export const PagesPanel: React.FC = () => {
addPage, addPage,
deletePage, deletePage,
renamePage, renamePage,
updatePageSeo,
} = usePages(); } = usePages();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -22,6 +24,8 @@ 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 [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;
@@ -343,6 +347,26 @@ export const PagesPanel: React.FC = () => {
style={{ display: 'flex', gap: 4, flexShrink: 0 }} style={{ display: 'flex', gap: 4, flexShrink: 0 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button
onClick={() => setSeoSettingsPageId(page.id)}
data-tooltip="Page Settings (SEO)"
aria-label={`Page settings for ${page.name}`}
style={{
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-cog" aria-hidden="true" />
</button>
<button <button
onClick={() => startEditing(page)} onClick={() => startEditing(page)}
data-tooltip="Rename" data-tooltip="Rename"
@@ -492,6 +516,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>
); );
}; };
+184
View File
@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
export interface BottomSheetProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
/** Vertical drag distance (px) past which releasing the handle/header
* commits to closing the sheet, rather than snapping back open. */
const SWIPE_DISMISS_THRESHOLD_PX = 80;
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
* (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged,
* as the mobile replacement for the desktop fixed left/right panel columns.
* See `MobilePanelBar`, which owns which sheet (if any) is open.
*
* Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
*
* Phase B adds two more deferred behaviors on top of Phase A's static sheet:
* - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle
* row + header (not the scrollable body, so it never fights a panel's own
* vertical scroll) that follows the finger while dragging down and either
* commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back
* with a short transition otherwise.
* - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how
* much the visual viewport has shrunk from the bottom (i.e. the
* keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom
* property (see editor.css) which both lifts the whole backdrop/sheet
* clear of the keyboard AND caps the sheet's own max-height so it never
* extends into the space the keyboard occupies. A `focusin` listener also
* scrolls the newly-focused input into view once the keyboard has had a
* moment to animate in, as a second line of defense for a field that's
* still off-screen after the resize alone (e.g. deep in a long panel).
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
const { keyboardInset } = useVisualViewportInsets();
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const bodyEl = bodyRef.current;
if (!bodyEl) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) {
// Give the on-screen keyboard a moment to finish animating in
// before scrolling -- doing it immediately measures the pre-keyboard
// layout and can undershoot.
window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 300);
}
};
bodyEl.addEventListener('focusin', handleFocusIn);
return () => bodyEl.removeEventListener('focusin', handleFocusIn);
}, [open]);
// Swipe-to-dismiss: tracked only on the handle row + header (never the
// scrollable `.mobile-sheet-body`, which needs its own vertical touch
// scrolling to keep working unimpeded).
//
// The authoritative drag distance lives in a REF (`dragOffsetRef`),
// updated synchronously and imperatively on every touchmove -- NOT solely
// in the `dragOffset` state used for rendering the drag transform. Two
// touchmoves can fire back-to-back inside the same synchronous event
// dispatch (e.g. a fast real swipe, or synthetic touch events fired
// without a yield between them), and React batches their `setDragOffset`
// calls into a single pending update that hasn't committed yet by the
// time `touchend` runs in that same tick -- reading the `dragOffset`
// STATE value directly in `handleTouchEnd` would then see a stale
// (pre-drag) value and wrongly decide the swipe didn't clear the
// threshold. The ref sidesteps that entirely (plain synchronous mutation,
// no batching). Relatedly, `onClose()` mutates an ANCESTOR component's
// state (MobileChromeContext's `closeSheet`) -- it must be called as a
// plain statement in the event handler, never from inside a `setState`
// functional updater (that runs during this component's own render phase
// and trips React's "Cannot update a component while rendering a
// different component" warning).
const dragStartYRef = useRef<number | null>(null);
const dragOffsetRef = useRef(0);
const [dragOffset, setDragOffset] = useState(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
dragStartYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (dragStartYRef.current === null) return;
const delta = e.touches[0].clientY - dragStartYRef.current;
// Only follow downward drags -- dragging up shouldn't do anything (the
// sheet is already fully open; there's no "expand further" state).
const next = Math.max(0, delta);
dragOffsetRef.current = next;
setDragOffset(next);
}, []);
const handleTouchEnd = useCallback(() => {
dragStartYRef.current = null;
const finalOffset = dragOffsetRef.current;
dragOffsetRef.current = 0;
setDragOffset(0);
if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose();
}, [onClose]);
if (!open) return null;
return (
<div
className="mobile-sheet-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
>
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */}
<div
className="mobile-sheet"
role="dialog"
aria-label={title}
style={{
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
transition: dragOffset ? 'none' : 'transform 0.2s ease',
}}
>
<div
className="mobile-sheet-handle-row"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-handle" aria-hidden="true" />
</div>
<div
className="mobile-sheet-header"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-title">{title}</span>
<button
type="button"
className="mobile-sheet-close"
onClick={onClose}
aria-label={`Close ${title}`}
>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,145 @@
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 { MobilePanelBar } from './MobilePanelBar';
import { MobileChromeProvider } from '../../state/MobileChromeContext';
/**
* Mobile-A2 (review item 5): the real BlocksPanel/PagesPanel/LayersPanel/
* AssetsPanel/GuidedStyles all pull in Craft.js's `useEditor` and other
* heavy dependencies not relevant here, so they're stubbed out -- this
* suite is only exercising MobilePanelBar's OWN wiring: tapping a tab opens
* that tab's sheet with only its panel rendered, tapping the active tab
* again closes it, and (mirroring MobileChromeContext.test.tsx) only one
* sheet's content is ever mounted at once.
*/
vi.mock('../left/BlocksPanel', () => ({ BlocksPanel: () => <div data-testid="panel-blocks" /> }));
vi.mock('../left/PagesPanel', () => ({ PagesPanel: () => <div data-testid="panel-pages" /> }));
vi.mock('../left/LayersPanel', () => ({ LayersPanel: () => <div data-testid="panel-layers" /> }));
vi.mock('../left/AssetsPanel', () => ({ AssetsPanel: () => <div data-testid="panel-assets" /> }));
vi.mock('../right/GuidedStyles', () => ({ GuidedStyles: () => <div data-testid="panel-styles" /> }));
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 tabButton(label: string): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll('.mobile-tab-btn')) as HTMLButtonElement[];
const btn = buttons.find((b) => b.textContent?.includes(label));
if (!btn) throw new Error(`No tab button found for "${label}"`);
return btn;
}
function openPanelIds(): string[] {
return Array.from(container.querySelectorAll('[data-testid^="panel-"]')).map(
(el) => (el as HTMLElement).dataset.testid!,
);
}
describe('MobilePanelBar', () => {
test('no sheet is open initially', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
expect(openPanelIds()).toEqual([]);
unmount();
});
test('tapping a tab opens exactly that tab\'s panel', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
unmount();
});
test('switching tabs replaces the open sheet -- only one panel mounted at a time', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
act(() => {
tabButton('Styles').click();
});
expect(openPanelIds()).toEqual(['panel-styles']);
act(() => {
tabButton('Assets').click();
});
expect(openPanelIds()).toEqual(['panel-assets']);
unmount();
});
test('tapping the active tab again closes the sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual(['panel-pages']);
expect(container.querySelector('.mobile-sheet-backdrop')).not.toBeNull();
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual([]);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
unmount();
});
test('active tab button carries aria-pressed=true only for the open sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Layers').click();
});
expect(tabButton('Layers').getAttribute('aria-pressed')).toBe('true');
expect(tabButton('Blocks').getAttribute('aria-pressed')).toBe('false');
unmount();
});
});
@@ -0,0 +1,74 @@
import React from 'react';
import { BottomSheet } from './BottomSheet';
import { BlocksPanel } from '../left/BlocksPanel';
import { PagesPanel } from '../left/PagesPanel';
import { LayersPanel } from '../left/LayersPanel';
import { AssetsPanel } from '../left/AssetsPanel';
import { GuidedStyles } from '../right/GuidedStyles';
import { useMobileChrome, MobileSheetTab } from '../../state/MobileChromeContext';
const TABS: { id: MobileSheetTab; label: string; icon: string }[] = [
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' },
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
{ id: 'assets', label: 'Assets', icon: 'fa-image' },
{ id: 'styles', label: 'Styles', icon: 'fa-paint-brush' },
];
/**
* Mobile replacement for the desktop LeftPanel/RightPanel column layout
* (Phase A). A fixed bottom tab bar (Blocks/Pages/Layers/Assets/Styles)
* toggles a single `BottomSheet` at a time, hosting the SAME panel
* component the desktop side panels use -- unchanged, so behavior/state
* stays identical, only the chrome around it differs. Tapping the active
* tab again, tapping the backdrop, the sheet's close button, or Escape all
* close the open sheet.
*
* "Styles" hosts `GuidedStyles`, which already falls back to the
* site-design panel when nothing is selected and switches to the
* per-type style panel once something is -- this is the natural place to
* edit a tapped element's styling (Phase B deepens the tap-to-select flow;
* this component only wires up the sheet, per the Phase A brief).
*
* Sheet-open state itself lives in `MobileChromeContext` (Mobile-A2), not
* a private `useState`, so Phase B can open/close a sheet from elsewhere
* (e.g. a canvas selection toolbar). The toggle-to-close-on-repeat-tap
* behavior is local to this click handler -- `openSheet` always opens.
*/
export const MobilePanelBar: React.FC = () => {
const { activeSheet, openSheet, closeSheet } = useMobileChrome();
const handleTabClick = (tab: MobileSheetTab) => {
if (activeSheet === tab) closeSheet();
else openSheet(tab);
};
const activeLabel = TABS.find((t) => t.id === activeSheet)?.label ?? '';
return (
<>
<BottomSheet open={activeSheet !== null} onClose={closeSheet} title={activeLabel}>
{activeSheet === 'blocks' && <BlocksPanel />}
{activeSheet === 'pages' && <PagesPanel />}
{activeSheet === 'layers' && <LayersPanel />}
{activeSheet === 'assets' && <AssetsPanel />}
{activeSheet === 'styles' && <GuidedStyles />}
</BottomSheet>
<nav className="mobile-tab-bar" aria-label="Editor panels">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`mobile-tab-btn${activeSheet === tab.id ? ' active' : ''}`}
onClick={() => handleTabClick(tab.id)}
aria-pressed={activeSheet === tab.id}
>
<i className={`fa ${tab.icon}`} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</nav>
</>
);
};
@@ -0,0 +1,213 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useMobileChrome } from '../../state/MobileChromeContext';
/**
* Phase B mobile selection toolbar -- the on-canvas action bar that makes a
* tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
* select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
* Both share the exact same behavior via `useNodeActions` (item 1).
*
* Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
* selection from `useEditor` directly -- NOT per-node -- and positioned
* bottom-fixed just above the tab bar (simplest, robust; a node-anchored
* floating toolbar would have to constantly reposition as the canvas
* scrolls, and risks being clipped at the viewport edge for a node near the
* top or bottom of a tall page). This is a deliberate design call: see the
* report for the alternative (floating over the node) that was passed over.
*
* Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
* sheet supersedes it, and the two must never visually stack.
*
* Delete is a two-tap in-app confirm (tap once -> the button becomes
* "Confirm?" for a few seconds; tap again within that window commits the
* delete; selecting something else cancels it) rather than a native
* `confirm()`, matching the project's no-native-dialogs rule.
*/
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
export const MobileSelectionToolbar: React.FC = () => {
const { activeSheet, openSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
// Collector-free -- an always-live reference (same pattern as
// GuidedStyles.tsx/BlocksPanel.tsx), used only for imperative DOM lookups
// below (scroll-into-view), never for anything that needs to react to
// store changes on its own.
const { query } = useEditor();
const nodeActions = useNodeActions(selectedId);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
// from a live (never-cached) Craft.js query -- see that hook's doc comment
// for why it deliberately does NOT hold its own store subscription to force
// a refresh. Moving/duplicating/deleting via THIS toolbar changes the
// selected node's position/siblings WITHOUT changing `selectedId` itself
// (the node stays selected), so nothing else would otherwise trigger a
// re-render of this component to pick up the new boundary flags after,
// say, tapping "Move Up" until the node reaches the top. Bumping a plain
// local tick from these buttons' own click handlers -- an ordinary,
// locally-owned re-render -- covers exactly that, without the broader
// store-wide subscription that was found to trip React's "setState while
// rendering a different component" warning whenever some unrelated
// container mounts (see useNodeActions.ts).
const [, forceRefresh] = useState(0);
const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
const clearConfirmTimeout = useCallback(() => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
}, []);
// Reset the two-tap delete confirmation whenever the selection changes
// (including being cleared) so a stale "Confirm?" state never lingers onto
// a different node.
const prevSelectedRef = useRef(selectedId);
useEffect(() => {
if (prevSelectedRef.current !== selectedId) {
prevSelectedRef.current = selectedId;
clearConfirmTimeout();
setConfirmingDelete(false);
}
}, [selectedId, clearConfirmTimeout]);
useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
// Fast-follow item 3: the Styles bottom sheet covers the bottom ~65dvh of
// the canvas, so a selected node sitting anywhere in that band goes
// invisible the instant the sheet opens -- live style edits then have no
// visible effect until the user closes the sheet to check. Whenever the
// sheet becomes 'styles' (however it got opened -- currently only this
// toolbar's own "Style" button, but this is keyed on the sheet/selection
// state rather than the button tap so it stays correct if another entry
// point is added later) scroll the selected node to the top of the
// canvas's scroll area, into the sliver of screen the sheet leaves clear.
useEffect(() => {
if (activeSheet !== 'styles' || !selectedId) return;
try {
query.node(selectedId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
// Best-effort -- not fatal if the node/DOM isn't found.
}
}, [activeSheet, selectedId, query]);
const handleDeleteTap = useCallback(() => {
if (confirmingDelete) {
clearConfirmTimeout();
setConfirmingDelete(false);
nodeActions.deleteNode();
bumpTick();
return;
}
setConfirmingDelete(true);
clearConfirmTimeout();
confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
}, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
const handleEditStyles = useCallback(() => {
openSheet('styles');
}, [openSheet]);
const handleMoveUp = useCallback(() => {
nodeActions.moveUp();
bumpTick();
}, [nodeActions, bumpTick]);
const handleMoveDown = useCallback(() => {
nodeActions.moveDown();
bumpTick();
}, [nodeActions, bumpTick]);
const handleDuplicate = useCallback(() => {
const newId = nodeActions.duplicate();
bumpTick();
if (!newId) return;
// Mirror BlocksPanel's tap-to-add pattern: the new node's real DOM
// element isn't attached yet the instant `duplicate()` returns (Craft.js
// mounts it asynchronously after this handler's synchronous state
// update), so defer two animation frames before reading `.dom` off the
// query, same as `handleTapToAdd` does.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [nodeActions, bumpTick, query]);
if (!selectedId || activeSheet !== null) return null;
return (
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveUp}
disabled={!nodeActions.canMoveUp}
aria-label="Move up"
>
<i className="fa fa-arrow-up" aria-hidden="true" />
<span>Up</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveDown}
disabled={!nodeActions.canMoveDown}
aria-label="Move down"
>
<i className="fa fa-arrow-down" aria-hidden="true" />
<span>Down</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleDuplicate}
aria-label="Duplicate"
>
<i className="fa fa-clone" aria-hidden="true" />
<span>Copy</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={nodeActions.selectParent}
disabled={!nodeActions.canSelectParent}
aria-label="Select parent"
>
<i className="fa fa-level-up" aria-hidden="true" />
<span>Parent</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleEditStyles}
aria-label="Edit styles"
>
<i className="fa fa-paint-brush" aria-hidden="true" />
<span>Style</span>
</button>
<button
type="button"
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
onClick={handleDeleteTap}
disabled={!nodeActions.canDelete}
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
>
<i className="fa fa-trash" aria-hidden="true" />
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
</button>
</div>
);
};
@@ -1,6 +1,7 @@
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 { FONT_FAMILIES } from '../../constants/presets'; import { FONT_FAMILIES } from '../../constants/presets';
import { AssetPicker } from '../../ui/AssetPicker';
type DesignTab = 'basic' | 'advanced'; type DesignTab = 'basic' | 'advanced';
@@ -200,6 +201,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}
@@ -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,8 +1,10 @@
import React from 'react'; import React from 'react';
import { useEditor } from '@craftjs/core';
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,21 +12,152 @@ 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' },
];
/* ---------- 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 || {};
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>
)}
{/* 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). */}
{nodeProps.recipientEmail !== undefined && ( {nodeProps.recipientEmail !== undefined && (
@@ -43,13 +176,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 +297,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>
)}
</> </>
); );
}; };
@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
TEXT_COLORS, TEXT_COLORS,
BG_COLORS, BG_COLORS,
@@ -17,12 +17,85 @@ import {
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { ArrayItemFieldsEditor } from './ArrayItemFields'; import { ArrayItemFieldsEditor } from './ArrayItemFields';
import { Modal } from '../../../ui/Modal';
import { CodeEditor } from '../../../ui/CodeEditor';
/* ---------- "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 instead
of falling into the generic single-line/textarea string-prop rendering
below (see GenericPropsEditor's SKIP of the `code` key). */
const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
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 }}>
<CodeEditor 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>
);
};
/* ---------- 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 +108,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">
@@ -2,30 +2,28 @@ 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 = [
{ label: '25%', value: '25%' },
{ label: '50%', value: '50%' },
{ label: '75%', value: '75%' },
{ label: '100%', value: '100%' },
];
return ( return (
<> <>
@@ -49,6 +47,39 @@ 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)}
/>
<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={(v) => setPropStyle('aspectRatio', v)}
/>
<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 +90,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 }) => {
@@ -30,11 +39,48 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
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)}
/>
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={(v) => setPropStyle('aspectRatio', v)}
/>
</>
)}
{/* 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 +126,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 +244,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} />
</> </>
); );
}; };

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