Compare commits

..
Author SHA1 Message Date
shadowdaoandClaude Opus 4.8 c712a69c4a test(topbar): cover publish() warnings wiring into PublishWarnings banner
PublishWarnings.tsx had unit tests for the presentational banner, but
nothing asserted that result.warnings from publish() actually flows
through TopBar's handlePublish into it. That 3-line seam is exactly what
this feature exists to fix -- the backend always returned warnings, and
TopBar discarded them by only checking result.success, so the
contact-form-relay warning was dead code for its entire life.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:34:51 -07:00
jknapp 9bf78fd72d Merge PR #23: fix entrance-animation output 2026-07-14 19:16:59 +00:00
shadowdaoandClaude Opus 4.8 2dcc2b4d21 fix(site-builder): entrance-animation reveal script survives Preview + well-formed void-tag attrs + no-JS fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:12:01 -07:00
jknapp 4e0fc78a30 Merge PR #22: enh pages productivity + cross-page clipboard 2026-07-14 14:48:25 +00:00
shadowdaoandClaude Opus 4.8 85dfe181aa fix(pages): duplicatePage must save outgoing active canvas before teardown (avoid dropping live edits)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:47:44 -07:00
shadowdaoandClaude Opus 4.8 a698f014b0 feat(site-builder): page duplicate/reorder/set-landing + fix cross-page node copy/paste
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:36:00 -07:00
jknapp 204ea5e078 Merge PR #21: enh output/seo/tokens (frontend) 2026-07-14 14:21:10 +00:00
shadowdaoandClaude Opus 4.8 0291ddce9a feat(site-builder): per-page SEO meta + favicon + design-token CSS-var wiring + published-output a11y/perf
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:14:56 -07:00
jknapp f7da654c11 Merge PR #20: enh nav 2026-07-14 13:58:11 +00:00
jknapp d6666f6f79 Merge PR #19: enh containers 2026-07-14 13:56:11 +00:00
shadowdaoandClaude Opus 4.8 4a426e3513 fix(containers): only flex-convert Container/Section when vertical-align set (avoid blockifying inline-block children)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:55:20 -07:00
jknapp 6e628d68dd Merge PR #18: enh sections 2026-07-14 13:54:52 +00:00
jknapp 8f17b74e2b Merge PR #17: enh forms 2026-07-14 13:49:57 +00:00
shadowdaoandClaude Opus 4.8 54572f648a feat(builder): nav/menu link-to-page picker, page sync, download attr, box-model rollout
NavStylePanel (Navbar/Menu/Logo/Footer):
- LinkPicker: dropdown of the site's pages (read-only via usePages()) plus
  manual URL / #anchor / tel: / mailto: entry, wired into every link-href
  field (standalone Logo href, Navbar logoUrl, Navbar/Menu link items).
- "Sync links with Pages" button in the Links section: repopulates the
  links array from the current pages list (label = page name, href = '/'
  for the landing page else '/{slug}'), preserving any existing CTA link.
  Regression-fix vs the legacy GrapesJS builder, which had this.
- `download` checkbox per link (Navbar/Menu links, standalone Logo href)
  emits the `download` attribute on export for links to files.
- Links/Colors sections now gate on the component actually carrying a
  `links`/color prop, so Footer (no links array) no longer shows a dead
  "Add Link" editor.
- Box-model (Margin/Padding via SpacingControl, Border & Effects via
  BorderControl + box-shadow presets + opacity), AnimationControl, and
  VisibilityControl added for all four owned components, backed by new
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props
  (with blank/default values in each component's .craft.props).

Tests: NavStylePanel.test.tsx (new, 17 tests: LinkPicker modes, sync
preserves CTA, download toggle, box-model/animation/visibility wiring) +
extended Navbar/Menu/Logo/Footer .toHtml.test.ts (download attribute,
craft.props defaults). Full suite: 683/683 passing. `npm run build` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:59 -07:00
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 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
97 changed files with 6910 additions and 225 deletions
@@ -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 {
@@ -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,
@@ -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,
+78 -12
View File
@@ -1,29 +1,95 @@
import { describe, test, expect, afterEach } from 'vitest'; import { describe, test, expect, afterEach } from 'vitest';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import type { NodeTree } from '@craftjs/core';
import { getClipboardTree, setClipboardTree } from './clipboard';
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
return {
rootNodeId: rootId,
nodes: {
[rootId]: {
id: rootId,
data: {
type: { resolvedName: 'Container' },
name: 'Container',
displayName: 'Container',
props,
custom: {},
isCanvas: false,
parent: 'wherever-it-originally-lived',
nodes: [],
linkedNodes: {},
hidden: false,
},
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as NodeTree['nodes'][string],
},
};
}
describe('clipboard', () => { describe('clipboard', () => {
afterEach(() => { afterEach(() => {
setClipboardNodeId(null); setClipboardTree(null);
}); });
test('starts empty', () => { test('starts empty', () => {
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
}); });
test('set then get returns the stored node id', () => { test('set then get returns a tree with the same root id and shape', () => {
setClipboardNodeId('node-123'); const tree = makeTree('node-123', { text: 'hello' });
expect(getClipboardNodeId()).toBe('node-123'); setClipboardTree(tree);
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.rootNodeId).toBe('node-123');
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
}); });
test('is a shared module-level store -- overwriting replaces the previous value', () => { test('is a shared module-level store -- overwriting replaces the previous value', () => {
setClipboardNodeId('first'); setClipboardTree(makeTree('first'));
setClipboardNodeId('second'); setClipboardTree(makeTree('second'));
expect(getClipboardNodeId()).toBe('second'); expect(getClipboardTree()!.rootNodeId).toBe('second');
}); });
test('can be cleared back to null', () => { test('can be cleared back to null', () => {
setClipboardNodeId('node-123'); setClipboardTree(makeTree('node-123'));
setClipboardNodeId(null); setClipboardTree(null);
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
});
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
const original = makeTree('node-123', { text: 'original' });
setClipboardTree(original);
// Mutate the original tree's props object directly (as if the source
// node were edited, or the same live node got copied again).
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
});
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
setClipboardTree(original);
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
'https://example.com',
);
});
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
setClipboardTree(tree);
tree = null; // simulate the original page's node/tree going away entirely
const got = getClipboardTree();
expect(got).not.toBeNull();
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
}); });
}); });
+65 -7
View File
@@ -1,3 +1,5 @@
import type { Node, NodeId, NodeTree } from '@craftjs/core';
/** /**
* Tiny shared clipboard for canvas node copy/paste. * Tiny shared clipboard for canvas node copy/paste.
* *
@@ -9,15 +11,71 @@
* Deliberately not React state -- nothing in the UI needs to re-render * Deliberately not React state -- nothing in the UI needs to re-render
* reactively when the clipboard changes; consumers just read the current * reactively when the clipboard changes; consumers just read the current
* value at the moment they need it (on paste, or when a menu opens). * value at the moment they need it (on paste, or when a menu opens).
*
* Historical bug (cross-page copy/paste): this used to store only the copied
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
* at paste time. That works fine same-page, but the moment the user switches
* pages the canvas is re-deserialized to the target page's Craft.js state --
* the copied id no longer exists in `query` at all -- so a cross-page paste
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
* TREE SNAPSHOT at copy time instead means paste never needs to look the
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
* `actions.addNodeTree`, which works identically regardless of which page's
* state is currently loaded on the canvas.
*/ */
let clipboardNodeId: string | null = null; let clipboardTree: NodeTree | null = null;
/** Returns the id of the node currently on the clipboard, or null if empty. */ /**
export function getClipboardNodeId(): string | null { * Deep, detached clone of a live Craft.js `NodeTree` (as returned by
return clipboardNodeId; * `query.node(id).toNodeTree()`).
*
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
* `data.type` is the actual component function/class reference (not a
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
* function and throws `DataCloneError` (see the identical note on
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
* historically). `type` is a stable reference shared by every node of that
* component across the whole app (it doesn't change per page), so it's safe
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
* subsequent paste's `setProp`, or a fresh copy of the same live node)
* can never reach back into this stored snapshot.
*/
function cloneNodeTree(tree: NodeTree): NodeTree {
const nodes: Record<NodeId, Node> = {};
for (const [id, node] of Object.entries(tree.nodes)) {
nodes[id] = {
...node,
data: {
...node.data,
props: structuredClone(node.data.props),
custom: structuredClone(node.data.custom),
nodes: [...(node.data.nodes || [])],
linkedNodes: { ...(node.data.linkedNodes || {}) },
},
};
}
return { rootNodeId: tree.rootNodeId, nodes };
} }
/** Sets (or clears, with `null`) the node id on the clipboard. */ /**
export function setClipboardNodeId(nodeId: string | null): void { * Returns the tree snapshot currently on the clipboard, or null if empty.
clipboardNodeId = nodeId; * The returned tree is safe to hand straight to `regenerateTreeIds` --
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
* same clipboard contents (including across a page switch) all work off the
* same untouched snapshot.
*/
export function getClipboardTree(): NodeTree | null {
return clipboardTree;
}
/**
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
* detached from the live Craft.js node it was captured from -- it survives
* that node being deleted, mutated, or (the whole point) the canvas being
* re-deserialized to a different page entirely.
*/
export function setClipboardTree(tree: NodeTree | null): void {
clipboardTree = tree ? cloneNodeTree(tree) : null;
} }
+53 -17
View File
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core'; import type { NodeTree, Node } from '@craftjs/core';
import { useKeyboardShortcuts } from './useKeyboardShortcuts'; import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import { getClipboardTree, setClipboardTree } from './clipboard';
/** /**
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through * Regression coverage: Ctrl/Cmd+V must run the copied subtree through
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing * ROOT-fallback targeting, the empty-clipboard no-op, and the existing
* input-focus guard. * input-focus guard.
* *
* Also covers the cross-page clipboard fix: copy stores a detached TREE
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
* to re-resolve the original node via `query.node(id)`, which is exactly
* what breaks once the canvas has been re-deserialized to a different page
* (see `hooks/clipboard.ts` and the cross-page integration test in
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
*
* Mock pattern mirrors PageContext.pure-updaters.test.tsx / * Mock pattern mirrors PageContext.pure-updaters.test.tsx /
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`, * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
* mounted via a bare consumer component, with REAL `keydown` events * mounted via a bare consumer component, with REAL `keydown` events
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
*/ */
const addNodeTreeMock = vi.fn(); const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
let selectedIds: string[] = []; let selectedIds: string[] = [];
function makeNode(id: string, parent: string | null, children: string[] = []): Node { function makeNode(id: string, parent: string | null, children: string[] = []): Node {
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
}, },
}; };
const nodeStore: Record<string, { data: { parent: string | null } }> = { const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
'selected-1': { data: { parent: 'parent-container-1' } }, 'selected-1': { data: { parent: 'parent-container-1' } },
ROOT: { data: { parent: null } }, 'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
ROOT: { data: { parent: null, nodes: [] } },
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } }, 'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
}; };
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
}, },
actions: { actions: {
addNodeTree: addNodeTreeMock, addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
history: { undo: vi.fn(), redo: vi.fn() }, history: { undo: vi.fn(), redo: vi.fn() },
delete: vi.fn(), delete: vi.fn(),
clearEvents: vi.fn(), clearEvents: vi.fn(),
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
beforeEach(() => { beforeEach(() => {
selectedIds = []; selectedIds = [];
addNodeTreeMock.mockClear(); addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
regenerateTreeIdsMock.mockClear(); regenerateTreeIdsMock.mockClear();
setClipboardNodeId(null); setClipboardTree(null);
}); });
afterEach(() => { afterEach(() => {
setClipboardNodeId(null); setClipboardTree(null);
if (root) unmount(); if (root) unmount();
}); });
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
test('Ctrl+C copies the selected node id to the clipboard', () => { test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['selected-1']; selectedIds = ['copied-root-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBe('selected-1'); const clip = getClipboardTree();
expect(clip).not.toBeNull();
expect(clip!.rootNodeId).toBe('copied-root-1');
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
}); });
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => { test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['copied-root-1']; selectedIds = ['copied-root-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBe('copied-root-1'); expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('v'); pressKey('v');
// regenerateTreeIds actually ran before the tree was handed to Craft.js. // regenerateTreeIds actually ran before the tree was handed to Craft.js.
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE); expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
expect(addNodeTreeMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0]; const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
// Sibling of the current selection: selected-1's data.parent. // Sibling of the current selection: selected-1's data.parent.
expect(targetParent).toBe('parent-container-1'); expect(targetParent).toBe('parent-container-1');
// Immediately after selected-1 (index 1 among parent-container-1's
// children), matching duplicate()'s "insert right after" UX.
expect(insertIndex).toBe(2);
// The regression this guards: pasted ids must be fresh, never reuse the // The regression this guards: pasted ids must be fresh, never reuse the
// ids the copied node already occupies in the live Craft.js tree. // ids the copied node already occupies in the live Craft.js tree.
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
for (const id of pastedIds) { for (const id of pastedIds) {
expect(originalIds.has(id)).toBe(false); expect(originalIds.has(id)).toBe(false);
} }
// The new copy is selected, same as duplicate()'s existing UX.
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
}); });
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => { test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
render(<Consumer />); render(<Consumer />);
selectedIds = ['copied-root-1']; selectedIds = ['copied-root-1'];
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
pressKey('v'); pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1); expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent] = addNodeTreeMock.mock.calls[0]; const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT'); expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
});
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
render(<Consumer />);
selectedIds = ['copied-root-1'];
pressKey('c');
selectedIds = [];
pressKey('v');
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
expect(targetParent).toBe('ROOT');
expect(insertIndex).toBeUndefined();
}); });
test('Ctrl+V with an empty clipboard is a no-op', () => { test('Ctrl+V with an empty clipboard is a no-op', () => {
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
setClipboardNodeId('copied-root-1'); setClipboardTree(COPIED_TREE);
pressKey('v'); pressKey('v');
expect(addNodeTreeMock).not.toHaveBeenCalled(); expect(addNodeTreeMock).not.toHaveBeenCalled();
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
selectedIds = ['selected-1']; selectedIds = ['selected-1'];
pressKey('c'); pressKey('c');
expect(getClipboardNodeId()).toBeNull(); expect(getClipboardTree()).toBeNull();
activeElementSpy.mockRestore(); activeElementSpy.mockRestore();
}); });
+35 -13
View File
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers'; import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree'; import { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard'; import { getClipboardTree, setClipboardTree } from './clipboard';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+C: copy selected node id to the shared clipboard // Ctrl+C: copy the selected node's subtree (a detached snapshot, not
// just its id -- see clipboard.ts for why: an id-based clipboard can't
// survive a page switch, since the copied id no longer exists in
// `query` once the canvas is re-deserialized to a different page).
if (ctrl && (e.key === 'c' || e.key === 'C')) { if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault(); e.preventDefault();
try { try {
const selected = query.getEvent('selected').all(); const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') { if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardNodeId(selected[0]); setClipboardTree(query.node(selected[0]).toNodeTree());
} }
} catch (err) { } catch (err) {
console.error('Copy failed:', err); console.error('Copy failed:', err);
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
return; return;
} }
// Ctrl+V: paste the clipboard node as a sibling of the current selection // Ctrl+V: paste the clipboard tree as a sibling of the current
// selection (immediately after it, matching duplicate()'s UX), or
// append to ROOT when nothing is selected. Works regardless of which
// page is currently on the canvas -- the clipboard tree is a detached
// snapshot, not a reference to a node that may no longer exist here.
if (ctrl && (e.key === 'v' || e.key === 'V')) { if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault(); e.preventDefault();
try { try {
const sourceId = getClipboardNodeId(); const clip = getClipboardTree();
if (!sourceId || !query.node(sourceId).get()) return; if (!clip) return;
const selected = query.getEvent('selected').all(); const selected = query.getEvent('selected').all();
if (selected.length === 0) return; const selectedId = selected.length > 0 ? selected[0] : null;
const selectedId = selected[0];
let targetParent = 'ROOT'; let targetParentId = 'ROOT';
if (selectedId !== 'ROOT') { let insertIndex: number | undefined;
if (selectedId && selectedId !== 'ROOT') {
const node = query.node(selectedId).get(); const node = query.node(selectedId).get();
targetParent = node?.data?.parent || 'ROOT'; const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
targetParentId = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
} }
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); const tree = regenerateTreeIds(clip);
actions.addNodeTree(tree, targetParent); if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParentId, insertIndex);
} else {
actions.addNodeTree(tree, targetParentId);
}
actions.selectNode(tree.rootNodeId);
} catch (err) { } catch (err) {
console.error('Paste failed:', err); console.error('Paste failed:', err);
} }
+159
View File
@@ -0,0 +1,159 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
/**
* PKG-H §5 round-trip coverage: `load()` must restore per-page `seo`
* (PageSeo) from `proj.pages_craft_state[].seo` back onto the reconstructed
* `PageData`, exactly like it already restores `craftState`. Mocks
* `@craftjs/core`'s `useEditor` (same pattern as
* `PageContext.pure-updaters.test.tsx`) since this test only needs
* `query.serialize`/`actions.deserialize` as inert stubs -- it drives
* `load()`, not the live canvas.
*/
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: deserializeMock },
}),
}));
const whpConfig: WhpConfig = {
user: 'testuser',
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
siteName: 'Test Site',
backUrl: '/panel/sites',
isRoot: false,
};
let container: HTMLDivElement;
let root: Root;
interface Captured {
load: ReturnType<typeof useWhpApi>['load'];
pages: ReturnType<typeof usePages>['pages'];
}
function render(): { get: () => Captured } {
container = document.createElement('div');
document.body.appendChild(container);
let captured: Captured | null = null;
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages } = usePages();
captured = { load, pages };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<PageProvider>
<Consumer />
</PageProvider>
</SiteDesignProvider>
</EditorConfigProvider>,
);
});
return { get: () => captured! };
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
describe('useWhpApi load() restores PageData.seo (PKG-H §5)', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('a saved project with per-page seo restores seo onto the reconstructed PageData', async () => {
const seoPayload = {
metaTitle: 'Custom Title',
metaDescription: 'A custom description.',
ogTitle: 'Custom OG Title',
ogImage: '/uploads/og.jpg',
twitterCard: 'summary_large_image' as const,
noindex: true,
};
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}', seo: seoPayload },
{ id: 'page_2', name: 'About', slug: 'about', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toEqual(seoPayload);
// A page with no seo in the payload stays undefined -- back-compat, not
// coerced into an empty object.
expect(pages.find((p) => p.id === 'page_2')?.seo).toBeUndefined();
unmount();
});
test('a legacy project with no seo on any page loads without adding seo fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
project: {
design: null,
header_craft_state: null,
footer_craft_state: null,
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const harness = render();
await act(async () => {
await harness.get().load();
});
const { pages } = harness.get();
expect(pages.find((p) => p.id === 'home')?.seo).toBeUndefined();
unmount();
});
});
+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
+35 -16
View File
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree'; import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions'; import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps { interface ContextMenuProps {
@@ -79,35 +79,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
try { try {
setClipboardNodeId(nodeId); // Store a detached subtree snapshot, not just the id -- an id-based
// clipboard can't survive a page switch (the copied id no longer
// exists in `query` once the canvas is re-deserialized to a different
// page's Craft.js state). See clipboard.ts.
setClipboardTree(query.node(nodeId).toNodeTree());
} catch (e) { } catch (e) {
console.error('Copy failed:', e); console.error('Copy failed:', e);
} }
onClose(); onClose();
}, [nodeId, onClose]); }, [nodeId, query, onClose]);
const pasteNode = useCallback(() => { const pasteNode = useCallback(() => {
const sourceId = getClipboardNodeId(); const clip = getClipboardTree();
if (!sourceId) { if (!clip) {
onClose(); onClose();
return; return;
} }
try { try {
if (!query.node(sourceId).get()) { // Paste as a SIBLING of the right-clicked node (immediately after it,
onClose(); // matching duplicate()'s UX), not as its child -- using the clicked
return; // node itself as the parent throws when it's a leaf. Falls back to
} // appending into ROOT when nothing valid was right-clicked. This works
// regardless of which page is on the canvas -- the clipboard tree is a
// Paste as a SIBLING of the right-clicked node, not as its child -- // detached snapshot, not a reference to a node that may not exist here.
// using the clicked node itself as the parent throws when it's a leaf.
let targetParent = 'ROOT'; let targetParent = 'ROOT';
let insertIndex: number | undefined;
if (nodeId && nodeId !== 'ROOT') { if (nodeId && nodeId !== 'ROOT') {
const clickedNode = query.node(nodeId).get(); const clickedNode = query.node(nodeId).get();
targetParent = clickedNode?.data?.parent || 'ROOT'; const parentId: string | null | undefined = clickedNode?.data?.parent;
if (parentId) {
targetParent = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
} }
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree()); const tree = regenerateTreeIds(clip);
actions.addNodeTree(tree, targetParent); if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParent, insertIndex);
} else {
actions.addNodeTree(tree, targetParent);
}
actions.selectNode(tree.rootNodeId);
} catch (e) { } catch (e) {
console.error('Paste failed:', e); console.error('Paste failed:', e);
} }
@@ -176,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
icon: 'clipboard', icon: 'clipboard',
shortcut: 'Ctrl+V', shortcut: 'Ctrl+V',
action: pasteNode, action: pasteNode,
disabled: !getClipboardNodeId(), disabled: !getClipboardTree(),
dividerAfter: true, dividerAfter: true,
}, },
{ {
+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',
};
+96 -27
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,10 @@ export const PagesPanel: React.FC = () => {
addPage, addPage,
deletePage, deletePage,
renamePage, renamePage,
duplicatePage,
movePage,
setLandingPage,
updatePageSeo,
} = usePages(); } = usePages();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -22,6 +27,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;
@@ -64,6 +71,33 @@ export const PagesPanel: React.FC = () => {
* differently-colored category. The active/editing state reuses the same * differently-colored category. The active/editing state reuses the same
* accent-outline treatment the page list already uses for the active page, * accent-outline treatment the page list already uses for the active page,
* so there's one consistent "this is what's currently open" affordance. */ * so there's one consistent "this is what's currently open" affordance. */
/* ---------- Per-page-row icon button ----------
* Shared 24x24 icon-button styling used by every action in the page row
* (SEO settings, rename, duplicate, reorder, set-as-home, delete) so a new
* action slots in looking identical to the pre-existing gear/pencil/trash
* buttons -- same dark-theme neutral treatment, `disabled` dims the icon
* (matching how `disabled` already reads elsewhere in this panel, e.g. the
* "Add Page" button), and `danger` reuses the existing delete-button
* accent color. */
const pageActionBtnStyle = (opts: { disabled?: boolean; danger?: boolean } = {}): React.CSSProperties => ({
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: opts.disabled
? 'var(--color-text-dim)'
: opts.danger
? 'var(--color-danger)'
: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: opts.disabled ? 'default' : 'pointer',
opacity: opts.disabled ? 0.5 : 1,
});
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({ const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -340,26 +374,66 @@ export const PagesPanel: React.FC = () => {
</div> </div>
</div> </div>
<div <div
style={{ display: 'flex', gap: 4, flexShrink: 0 }} style={{ display: 'flex', gap: 2, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 96 }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<button
onClick={() => movePage(page.id, 'up')}
disabled={pageIndex === 0}
data-tooltip="Move up"
aria-label={`Move ${page.name} up`}
style={pageActionBtnStyle({ disabled: pageIndex === 0 })}
>
<i className="fa fa-arrow-up" aria-hidden="true" />
</button>
<button
onClick={() => movePage(page.id, 'down')}
disabled={pageIndex === pages.length - 1}
data-tooltip="Move down"
aria-label={`Move ${page.name} down`}
style={pageActionBtnStyle({ disabled: pageIndex === pages.length - 1 })}
>
<i className="fa fa-arrow-down" aria-hidden="true" />
</button>
<button
onClick={() => duplicatePage(page.id)}
data-tooltip="Duplicate"
aria-label={`Duplicate ${page.name}`}
style={pageActionBtnStyle()}
>
<i className="fa fa-clone" aria-hidden="true" />
</button>
{isLanding ? (
<span
data-tooltip="This is the home page"
aria-label={`${page.name} is the home page`}
style={pageActionBtnStyle({ disabled: true })}
>
<i className="fa fa-home" aria-hidden="true" />
</span>
) : (
<button
onClick={() => setLandingPage(page.id)}
data-tooltip="Set as home page"
aria-label={`Set ${page.name} as the home page`}
style={pageActionBtnStyle()}
>
<i className="fa fa-home" aria-hidden="true" />
</button>
)}
<button
onClick={() => setSeoSettingsPageId(page.id)}
data-tooltip="Page Settings (SEO)"
aria-label={`Page settings for ${page.name}`}
style={pageActionBtnStyle()}
>
<i className="fa fa-cog" aria-hidden="true" />
</button>
<button <button
onClick={() => startEditing(page)} onClick={() => startEditing(page)}
data-tooltip="Rename" data-tooltip="Rename"
aria-label={`Rename ${page.name}`} aria-label={`Rename ${page.name}`}
style={{ style={pageActionBtnStyle()}
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
> >
<i className="fa fa-pencil" aria-hidden="true" /> <i className="fa fa-pencil" aria-hidden="true" />
</button> </button>
@@ -368,19 +442,7 @@ export const PagesPanel: React.FC = () => {
onClick={() => setDeleteConfirmId(page.id)} onClick={() => setDeleteConfirmId(page.id)}
data-tooltip="Delete" data-tooltip="Delete"
aria-label={`Delete ${page.name}`} aria-label={`Delete ${page.name}`}
style={{ style={pageActionBtnStyle({ danger: true })}
width: 24,
height: 24,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
color: 'var(--color-text-muted)',
background: 'transparent',
border: 'none',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
}}
> >
<i className="fa fa-trash" aria-hidden="true" /> <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
@@ -492,6 +554,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>
); );
}; };
@@ -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,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">
@@ -0,0 +1,133 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as MediaStylePanel.video.test.tsx -- mock
@craftjs/core's useEditor so setProp calls can be observed without
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
doesn't hit the network. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
vi.mock('../../../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { ImageStylePanel } from './ImageStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function qAll<T extends Element = Element>(testId: string): T[] {
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
}
/** Click the preset button with this exact label inside a given data-testid
* root (AspectRatioControl / PresetButtonGrid render plain buttons keyed by
* label, no per-button testid). */
function clickPresetByLabel(root: Element | null, label: string) {
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
expect(btn).toBeTruthy();
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
beforeEach(() => {
setPropSpy.mockClear();
});
afterEach(() => {
if (container) unmount();
});
describe('ImageStylePanel crop-fills-by-default (fix-anim-image B)', () => {
test('applying a non-empty aspect ratio with objectFit unset also sets objectFit to cover', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
expect(lastProps.style.aspectRatio).toBe('1 / 1');
expect(lastProps.style.objectFit).toBe('cover');
});
test('applying a ratio when objectFit is already "contain" leaves it as contain (no forced override)', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { objectFit: 'contain' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
expect(lastProps.style.aspectRatio).toBe('16 / 9');
expect(lastProps.style.objectFit).toBe('contain');
});
test('clearing the ratio (Original) does not force-clear objectFit', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
expect(lastProps.style.aspectRatio).toBe('');
expect(lastProps.style.objectFit).toBe('cover');
});
test('the Object Fit control (Cover/Contain/Fill/None) remains present and usable', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
// PresetButtonGrid buttons have no dedicated per-button testid; find by label text.
const containBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Contain');
expect(containBtn).toBeTruthy();
act(() => { containBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
expect(lastProps.style.objectFit).toBe('contain');
});
});
describe('ImageStylePanel Height control gated on aspect-ratio (fix-anim-image C)', () => {
test('no aspect-ratio set: both Width and Height SizeControls render', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(2);
});
test('aspect-ratio set: only the Width SizeControl renders (Height is hidden)', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(1);
});
test('applying an aspect ratio clears a stale height so it cannot linger and conflict', () => {
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { height: '50%' } };
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '9:16');
expect(lastProps.style.aspectRatio).toBe('9 / 16');
expect(lastProps.style.height).toBe('');
});
});
@@ -2,30 +2,41 @@ import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
IMAGE_RADIUS_PRESETS, IMAGE_RADIUS_PRESETS,
OBJECT_FIT,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
SectionLabel, SectionLabel,
PresetButtonGrid, PresetButtonGrid,
TextInputField, TextInputField,
SizeControl,
AspectRatioControl,
FocalPointGrid,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock'; import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
/* ---------- IMAGE (with upload/browse/drop) ---------- */ /* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const maxWidthPresets = [ // Applying an aspect-ratio crop should FILL the frame by default (object-fit:
{ label: '25%', value: '25%' }, // cover) rather than leave letterboxed empty bands, and width + ratio + cover
{ label: '50%', value: '50%' }, // fully determine the box -- so a stale `height` can't linger and conflict
{ label: '75%', value: '75%' }, // (kills the %-height no-op that made resize look like it wasn't working).
{ label: '100%', value: '100%' }, // Clearing the ratio (back to 'Original') leaves objectFit as the user left it.
]; const applyAspectRatio = (v: string) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
};
return ( return (
<> <>
@@ -49,6 +60,45 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
}} }}
/> />
{/* Size -- Width absorbs the historical maxWidth presets (25/50/75/100%
are a subset of SizeControl's default preset row), Height is new. */}
<SizeControl
label="Width"
value={(style.maxWidth as string) || ''}
onChange={(v) => setPropStyle('maxWidth', v)}
/>
{/* Height is only meaningful when there's no aspect-ratio crop -- once a
ratio is set, Width + ratio + cover fully determine the box, so a
separate Height control would only conflict/mislead (see
applyAspectRatio, which clears any stale height at that moment). */}
{!style.aspectRatio && (
<SizeControl
label="Height"
value={(style.height as string) || ''}
onChange={(v) => setPropStyle('height', v)}
/>
)}
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
<img> itself is a CSS framing crop (no server-side image processing
needed). */}
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={applyAspectRatio}
/>
<div className="guided-section">
<SectionLabel>Object Fit</SectionLabel>
<PresetButtonGrid
presets={OBJECT_FIT}
activeValue={(style.objectFit as string) || ''}
onSelect={(v) => setPropStyle('objectFit', v)}
/>
</div>
<FocalPointGrid
value={(style.objectPosition as string) || ''}
onChange={(v) => setPropStyle('objectPosition', v)}
/>
{/* Border Radius */} {/* Border Radius */}
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
@@ -59,15 +109,10 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
/> />
</div> </div>
{/* Max Width */} {/* Box model + animation/visibility rollout */}
<div className="guided-section"> <BoxModelSection style={style} setPropStyle={setPropStyle} />
<SectionLabel>Max Width</SectionLabel> <BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<PresetButtonGrid <AnimVisSection nodeProps={nodeProps} setProp={setProp} />
presets={maxWidthPresets}
activeValue={style.maxWidth as string}
onSelect={(v) => setPropStyle('maxWidth', v)}
/>
</div>
</> </>
); );
}; };
@@ -2,7 +2,6 @@ import React from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS, RADIUS_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
@@ -13,6 +12,8 @@ import {
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
ArrayPropEditor, ArrayPropEditor,
SizeControl,
AspectRatioControl,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
@@ -20,6 +21,14 @@ import {
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
const GALLERY_COLUMN_PRESETS = [
{ label: '2', value: '2' },
{ label: '3', value: '3' },
{ label: '4', value: '4' },
{ label: '5', value: '5' },
];
/* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */ /* ---------- MEDIA (Video / Gallery / Map / Slider) ---------- */
export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
@@ -28,13 +37,67 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
const style = nodeProps.style || {}; const style = nodeProps.style || {};
// Same crop-fills-by-default + no stale-height-conflict treatment as
// ImageStylePanel (see there for the full rationale): applying a ratio
// defaults objectFit to 'cover' when unset, and clears height so Width +
// ratio + cover is the single source of truth for the box.
const applyAspectRatio = (v: string) => {
setPropStyle('aspectRatio', v);
if (v) {
if (!style.objectFit) setPropStyle('objectFit', 'cover');
setPropStyle('height', '');
}
};
return ( return (
<> <>
{/* Video URL */} {/* Video source -- upload/browse/paste-URL (paste-URL still handles
YouTube/Vimeo; upload/browse handle files via detectVideoType's
serve_asset resolution). */}
{nodeProps.videoUrl !== undefined && ( {nodeProps.videoUrl !== undefined && (
<div style={sectionGap}> <div className="guided-section">
<label style={labelStyle}>Video URL</label> <SectionLabel>Video Source</SectionLabel>
<input type="text" value={nodeProps.videoUrl || ''} onChange={(e) => setProp('videoUrl', e.target.value)} placeholder="YouTube, Vimeo, or .mp4 URL" style={inputStyle} /> <AssetPicker
mediaType="video"
value={nodeProps.videoUrl || ''}
onChange={(url) => setProp('videoUrl', url)}
variant="full"
placeholder="Or paste a YouTube/Vimeo/.mp4 URL..."
/>
</div>
)}
{/* Video size -- width + aspect ratio for the video frame. */}
{nodeProps.videoUrl !== undefined && (
<>
<SizeControl
label="Width"
value={(style.width as string) || ''}
onChange={(v) => setPropStyle('width', v)}
/>
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
themselves from `width` + `aspectRatio` directly (see
VideoBlock.tsx), not from an outer-wrapper height, so adding one
would reintroduce the exact empty-space bug this fix targets. */}
<AspectRatioControl
value={(style.aspectRatio as string) || ''}
onChange={applyAspectRatio}
/>
</>
)}
{/* Poster image (optional, file-type videos only in practice but
harmless to offer whenever the component has a poster prop). */}
{nodeProps.poster !== undefined && (
<div className="guided-section">
<SectionLabel>Poster Image (optional)</SectionLabel>
<AssetPicker
mediaType="image"
value={nodeProps.poster || ''}
onChange={(url) => setProp('poster', url)}
variant="full"
/>
</div> </div>
)} )}
@@ -80,6 +143,26 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div> </div>
)} )}
{/* Gallery layout -- columns + lightbox (existing-but-previously-unexposed props). */}
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
<>
<div className="guided-section">
<SectionLabel>Columns</SectionLabel>
<PresetButtonGrid
presets={GALLERY_COLUMN_PRESETS}
activeValue={String(nodeProps.columns ?? 3)}
onSelect={(v) => setProp('columns', Number(v))}
/>
</div>
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={!!nodeProps.lightbox} onChange={(e) => setProp('lightbox', e.target.checked)} />
Enable Lightbox
</label>
</div>
</>
)}
{/* Gallery items */} {/* Gallery items */}
{nodeProps.images !== undefined && Array.isArray(nodeProps.images) && ( {nodeProps.images !== undefined && Array.isArray(nodeProps.images) && (
<CollapsibleSection title="Images"> <CollapsibleSection title="Images">
@@ -178,21 +261,23 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Background & padding */} {/* Background & radius */}
<CollapsibleSection title="Style" defaultOpen={false}> <CollapsibleSection title="Style" defaultOpen={false}>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Background</SectionLabel> <SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} /> <ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
</div> </div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Border Radius</SectionLabel> <SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} /> <PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
</> </>
); );
}; };
@@ -0,0 +1,201 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as MediaStylePanel.slides.test.tsx -- mock
@craftjs/core's useEditor so setProp calls can be observed without
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
doesn't hit the network. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
vi.mock('../../../utils/assets', () => ({
uploadAsset: vi.fn(),
listAssets: vi.fn(),
}));
import { MediaStylePanel } from './MediaStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function q<T extends Element = Element>(testId: string): T | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function qAll<T extends Element = Element>(testId: string): T[] {
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
}
function setValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
beforeEach(() => {
setPropSpy.mockClear();
});
afterEach(() => {
if (container) unmount();
});
describe('MediaStylePanel Video source uses AssetPicker (upload/browse/paste-URL), not a plain text input', () => {
test('renders a full-variant AssetPicker (not the old plain input) for videoUrl', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(q('asset-picker-full')).not.toBeNull();
});
test('applying a pasted URL via the AssetPicker writes videoUrl (not some other key)', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
// The Video Source AssetPicker is the first url-input on the page (the
// Poster picker is a second, separate AssetPicker instance below it).
const urlInput = qAll<HTMLInputElement>('asset-picker-url-input')[0];
expect(urlInput).toBeTruthy();
setValue(urlInput, 'https://example.com/clip.mp4');
const applyBtn = qAll<HTMLButtonElement>('asset-picker-apply')[0];
act(() => {
applyBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(setPropSpy).toHaveBeenCalled();
expect(lastProps.videoUrl).toBe('https://example.com/clip.mp4');
});
test('a video-shaped selection also renders a Poster AssetPicker (image) that writes `poster`', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
const pickers = qAll('asset-picker-full');
expect(pickers.length).toBe(2); // Video Source + Poster Image
const urlInputs = qAll<HTMLInputElement>('asset-picker-url-input');
const posterInput = urlInputs[1];
setValue(posterInput, 'https://example.com/poster.jpg');
const applyBtns = qAll<HTMLButtonElement>('asset-picker-apply');
act(() => {
applyBtns[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(lastProps.poster).toBe('https://example.com/poster.jpg');
});
});
describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated on videoUrl', () => {
test('a video-shaped selection renders SizeControl + AspectRatioControl', () => {
lastProps = { videoUrl: '', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(q('size-control')).not.toBeNull();
expect(q('aspect-ratio-control')).not.toBeNull();
});
test('a Map-shaped selection (no videoUrl) does NOT render the video size controls', () => {
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {} };
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
expect(q('size-control')).toBeNull();
expect(q('aspect-ratio-control')).toBeNull();
});
test('a video-shaped selection only renders one SizeControl (Width) -- no separate Height control', () => {
// See MediaStylePanel.tsx's note: VideoBlock sizes its <video>/iframe from
// width + aspectRatio directly, not from an outer-wrapper height, so a
// Height control would reintroduce the empty-space bug this fix targets.
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
expect(qAll('size-control').length).toBe(1);
});
});
/* FIX (fix-anim-image contract, B+C): applying a crop aspect-ratio to a video
should fill by default (objectFit defaults to 'cover' when unset) and clear
any stale height, matching ImageStylePanel's treatment -- for prop-schema
consistency even though VideoBlock's file-type <video> already hardcodes
object-fit: cover today. */
describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default treatment (fix-anim-image B+C)', () => {
function clickPresetByLabel(root: Element | null, label: string) {
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
expect(btn).toBeTruthy();
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
test('applying a non-empty ratio with objectFit unset also sets objectFit to cover', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
expect(lastProps.style.aspectRatio).toBe('1 / 1');
expect(lastProps.style.objectFit).toBe('cover');
});
test('applying a ratio clears a stale height', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { height: '50%' } };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
expect(lastProps.style.aspectRatio).toBe('16 / 9');
expect(lastProps.style.height).toBe('');
});
test('clearing the ratio (Original) does not force-clear objectFit', () => {
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
expect(lastProps.style.aspectRatio).toBe('');
expect(lastProps.style.objectFit).toBe('cover');
});
});
function openCollapsibleByTitle(title: string) {
const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes(title));
expect(btn).toBeTruthy();
act(() => {
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
describe('MediaStylePanel box-model + animation/visibility rollout renders for every media type', () => {
test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (collapsed by default, open on click)', () => {
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {}, animation: '', hideOnDesktop: false };
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
openCollapsibleByTitle('Spacing');
expect(q('spacing-control')).not.toBeNull();
openCollapsibleByTitle('Border & Effects');
expect(q('border-control')).not.toBeNull();
openCollapsibleByTitle('Animation & Visibility');
expect(q('animation-control')).not.toBeNull();
expect(q('visibility-control')).not.toBeNull();
});
});
@@ -0,0 +1,282 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* NavStylePanel (via useNodeProp/LinkPicker in this file) needs useEditor
from @craftjs/core and usePages from PageContext. Mock both following the
DOM-harness pattern used across this repo's other *StylePanel tests (no
@testing-library/react here) -- PageContext itself is Wave-2's territory,
this package only READS pages via usePages(), so mocking it is the
correct boundary for these tests. */
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
vi.mock('../../../state/PageContext', () => ({
usePages: () => ({ pages: mockPages }),
}));
vi.mock('../../../ui/AssetPicker', () => ({
AssetPicker: () => null,
}));
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function rerender(ui: React.ReactElement) {
act(() => { root.render(ui); });
}
function unmount() {
act(() => { root.unmount(); });
container.remove();
}
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
act(() => {
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
});
}
function click(el: Element | null) {
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
beforeEach(() => {
setPropSpy.mockClear();
mockPages = [
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
{ id: 'about', name: 'About', slug: 'about', craftState: null },
];
});
afterEach(() => {
if (container) unmount();
});
describe('pageHref (landing page is always "/")', () => {
test('index 0 (landing page) -> "/"', () => {
expect(pageHref(mockPages[0], 0)).toBe('/');
});
test('any other page -> "/{slug}"', () => {
expect(pageHref(mockPages[1], 1)).toBe('/about');
});
});
describe('LinkPicker (F1: link-to-page picker + manual URL/anchor/tel/mailto)', () => {
test('a value matching a page href starts in "Page" mode and lists every page', () => {
render(<LinkPicker value="/about" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('page');
const optionText = Array.from(container.querySelectorAll('option')).map((o) => o.textContent);
expect(optionText).toContain('Home');
expect(optionText).toContain('About');
});
test('picking a different page from the page dropdown emits that page\'s href', () => {
const onChange = vi.fn();
render(<LinkPicker value="/about" onChange={onChange} />);
const [, pageSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
setValue(pageSelect, '/');
expect(onChange).toHaveBeenCalledWith('/');
});
test('a "#section" value starts in Anchor mode', () => {
render(<LinkPicker value="#pricing" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('anchor');
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('#pricing');
});
test('switching mode to Anchor seeds a bare "#"', () => {
const onChange = vi.fn();
render(<LinkPicker value="/about" onChange={onChange} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
setValue(modeSelect, 'anchor');
expect(onChange).toHaveBeenCalledWith('#');
});
test('mailto: helper strips the scheme for editing and re-adds it on change', () => {
const onChange = vi.fn();
render(<LinkPicker value="mailto:foo@example.com" onChange={onChange} />);
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('foo@example.com');
setValue(input, 'bar@example.com');
expect(onChange).toHaveBeenCalledWith('mailto:bar@example.com');
});
test('tel: helper strips the scheme for editing and re-adds it on change', () => {
const onChange = vi.fn();
render(<LinkPicker value="tel:5551234567" onChange={onChange} />);
const input = container.querySelector('input[type="text"]') as HTMLInputElement;
expect(input.value).toBe('5551234567');
setValue(input, '5559876543');
expect(onChange).toHaveBeenCalledWith('tel:5559876543');
});
test('a plain https:// URL falls back to Custom URL mode', () => {
render(<LinkPicker value="https://example.com" onChange={vi.fn()} />);
const [modeSelect] = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
expect(modeSelect.value).toBe('url');
});
});
describe('NavStylePanel Links section: href set via LinkPicker (F1 wired into the panel)', () => {
test('choosing a page for a Navbar link writes that page\'s href onto the link', () => {
lastProps = {
links: [{ text: 'Home', href: '/old-home' }],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const selects = Array.from(container.querySelectorAll('select')) as HTMLSelectElement[];
// First select for the one link item is its LinkPicker mode select (the
// href starts as a Custom URL, so it opens on "url" mode); switch it to
// "page" then pick the About page from the resulting page dropdown.
const modeSelect = selects[0];
setValue(modeSelect, 'page');
expect(setPropSpy).toHaveBeenCalled();
expect(lastProps.links[0].href).toBe('/'); // defaults to the first page
// The mock setProp mutates `lastProps` in place rather than triggering a
// real Craft.js state update, so force a re-render (passing the same,
// now-mutated, object) to get the LinkPicker to reflect its new "page"
// mode and render the page <select>.
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const pageSelect = Array.from(container.querySelectorAll('select'))[1] as HTMLSelectElement;
setValue(pageSelect, '/about');
expect(lastProps.links[0].href).toBe('/about');
});
test('toggling the Download checkbox for a link sets download:true', () => {
lastProps = { links: [{ text: 'Brochure', href: '/brochure.pdf' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
act(() => { checkbox.click(); });
expect(lastProps.links[0].download).toBe(true);
});
});
describe('NavStylePanel: "Sync links with Pages" (F2, regression vs legacy builder)', () => {
test('populates links from the mocked pages list and preserves the CTA link', () => {
lastProps = {
links: [
{ text: 'Old Home', href: '/old' },
{ text: 'Old About', href: '/old-about' },
{ text: 'Get Started', href: '#signup', isCta: true },
],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
expect(syncBtn).toBeTruthy();
click(syncBtn);
expect(lastProps.links).toEqual([
{ text: 'Home', href: '/' },
{ text: 'About', href: '/about' },
{ text: 'Get Started', href: '#signup', isCta: true },
]);
});
test('with no CTA link, sync just replaces links 1:1 with the pages list', () => {
lastProps = { links: [{ text: 'Stale', href: '/stale' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'))!;
click(syncBtn);
expect(lastProps.links).toEqual([
{ text: 'Home', href: '/' },
{ text: 'About', href: '/about' },
]);
});
test('the Links section (and its Sync button) is not shown for Footer (no `links` prop)', () => {
lastProps = { text: '© 2026' };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const syncBtn = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes('Sync links with Pages'));
expect(syncBtn).toBeUndefined();
});
});
/** The Spacing/Border & Effects/Animation/Visibility CollapsibleSections all
* default closed (defaultOpen={false}, matching the pre-existing "Spacing"
* section's convention) -- open one by clicking its header button before
* asserting on / interacting with its contents. */
function openSection(title: string) {
const header = Array.from(container.querySelectorAll('button'))
.find((b) => b.textContent?.includes(title))!;
click(header);
}
describe('NavStylePanel: box-model + animation + visibility controls always present', () => {
test('Spacing (Margin/Padding), Border & Effects, Animation, and Visibility sections render for a Navbar', () => {
lastProps = {
backgroundColor: '#ffffff',
style: {},
animation: 'none',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Spacing');
openSection('Border & Effects');
openSection('Animation');
openSection('Visibility');
expect(container.querySelector('[data-testid="spacing-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="border-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="animation-control"]')).toBeTruthy();
expect(container.querySelector('[data-testid="visibility-control"]')).toBeTruthy();
});
test('checking "Hide on Mobile" writes hideOnMobile:true via setProp', () => {
lastProps = { hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Visibility');
const checkbox = container.querySelector('[data-testid="visibility-hideOnMobile"]') as HTMLInputElement;
act(() => {
checkbox.click();
});
expect(lastProps.hideOnMobile).toBe(true);
});
test('picking an entrance animation writes animation via setProp', () => {
lastProps = { animation: 'none', animationDelay: '0' };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
openSection('Animation');
const fadeInBtn = Array.from(container.querySelectorAll('[data-testid="animation-control"] button'))
.find((b) => b.textContent === 'Fade In')!;
click(fadeInBtn);
expect(lastProps.animation).toBe('fade-in');
});
});
+230 -15
View File
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { import {
SPACING_PRESETS, SPACING_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -16,13 +17,145 @@ import {
smallInputStyle, smallInputStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
SpacingControl,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
} from './shared'; } from './shared';
import { AssetPicker } from '../../../ui/AssetPicker'; import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
import { PageData } from '../../../types';
/* ---------- NAV / MENU / LOGO ---------- */ /* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
Mirrors the export convention used elsewhere: the first page is always the
landing page and publishes to '/', every other page publishes to '/{slug}'.
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
in renamePage() for why index 0 is special-cased this way. */
export function pageHref(page: PageData, index: number): string {
return index === 0 ? '/' : `/${page.slug}`;
}
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
const v = value || '';
if (pageHrefs.includes(v)) return 'page';
if (v.startsWith('#')) return 'anchor';
if (v.startsWith('tel:')) return 'tel';
if (v.startsWith('mailto:')) return 'mailto';
return 'url';
}
/* ---------- LinkPicker ----------
Reused for every link-href field in this panel (standalone Logo's href,
Navbar's logoUrl, and each Navbar/Menu link item's href): a dropdown of
the site's PAGES (read-only via usePages() -- PageContext itself is
Wave-2's territory) plus manual URL / #anchor / tel: / mailto: entry.
safeUrl (in toHtml) already allows tel:/mailto: schemes, so no export-side
change is needed for those. */
interface LinkPickerProps {
value: string;
onChange: (value: string) => void;
}
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
const { pages } = usePages();
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
const switchMode = (next: LinkMode) => {
if (next === mode) return;
if (next === 'page') onChange(pageOptions[0]?.href || '/');
else if (next === 'anchor') onChange('#');
else if (next === 'tel') onChange('tel:');
else if (next === 'mailto') onChange('mailto:');
else onChange('');
};
return (
<div style={sectionGap} data-testid="link-picker">
<label style={labelStyle}>Link</label>
<select
value={mode}
onChange={(e) => switchMode(e.target.value as LinkMode)}
style={{ ...inputStyle, marginBottom: 4, cursor: 'pointer' }}
>
<option value="page">Page</option>
<option value="url">Custom URL</option>
<option value="anchor">Anchor (#section)</option>
<option value="tel">Phone (tel:)</option>
<option value="mailto">Email (mailto:)</option>
</select>
{mode === 'page' && (
pageOptions.length > 0 ? (
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
{pageOptions.map((p) => (
<option key={p.id} value={p.href}>{p.name}</option>
))}
</select>
) : (
<div style={{ fontSize: 11, color: '#71717a' }}>No pages yet</div>
)
)}
{mode === 'anchor' && (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
placeholder="#section-id"
style={inputStyle}
/>
)}
{mode === 'tel' && (
<input
type="text"
value={(value || '').replace(/^tel:/, '')}
onChange={(e) => onChange(`tel:${e.target.value}`)}
placeholder="+15551234567"
style={inputStyle}
/>
)}
{mode === 'mailto' && (
<input
type="text"
value={(value || '').replace(/^mailto:/, '')}
onChange={(e) => onChange(`mailto:${e.target.value}`)}
placeholder="name@example.com"
style={inputStyle}
/>
)}
{mode === 'url' && (
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder="https://example.com or /page"
style={inputStyle}
/>
)}
</div>
);
};
/* Parses a `border` shorthand ("2px solid #hex" / "none") back into the
{ width, style, color } shape BorderControl edits. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = v.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const { pages } = usePages();
const links: any[] = nodeProps.links || []; const links: any[] = nodeProps.links || [];
@@ -48,6 +181,20 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
}); });
}, [actions, selectedId]); }, [actions, selectedId]);
/* F2: (re)populate the links array from the current pages list --
label = page name, href = '/' for the landing page else '/{slug}'.
Any existing CTA link (isCta: true) is preserved (appended after the
freshly-generated page links) rather than being wiped, matching the
legacy GrapesJS builder's "Sync with Pages" behavior. */
const syncWithPages = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
const existing: any[] = props.links || [];
const ctaLinks = existing.filter((l) => l.isCta);
const pageLinks = pages.map((p, i) => ({ text: p.name, href: pageHref(p, i) }));
props.links = [...pageLinks, ...ctaLinks];
});
}, [actions, selectedId, pages]);
/* Detect standalone Logo vs Navbar/Menu */ /* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined; const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
@@ -66,6 +213,15 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
); );
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g })); const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
/* Box-model / animation / visibility values, read off `style` (margin,
padding, border, boxShadow, opacity) or top-level props (animation,
hideOn*). This panel is only ever mounted for the 4 owned components
(Navbar/Menu/Logo/Footer), which all now carry these props (see each
component's .craft.props), so -- unlike the Links/Colors sections above,
which are shared across a genuinely disparate prop schema -- these
sections render unconditionally rather than gating on presence. */
const style = nodeProps.style || {};
return ( return (
<> <>
{/* Standalone Logo component settings */} {/* Standalone Logo component settings */}
@@ -124,10 +280,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div> </div>
</> </>
)} )}
<div style={sectionGap}> <LinkPicker value={nodeProps.href || '/'} onChange={(v) => setProp('href', v)} />
<label style={labelStyle}>Link URL</label> <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginTop: -8, marginBottom: 12 }}>
<input type="text" value={nodeProps.href || '/'} onChange={(e) => setProp('href', e.target.value)} placeholder="/" style={inputStyle} /> <input type="checkbox" checked={!!nodeProps.download} onChange={(e) => setProp('download', e.target.checked)} />
</div> Download (link points at a file)
</label>
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -145,17 +302,22 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</div> </div>
)} )}
{nodeProps.logoUrl !== undefined && ( {nodeProps.logoUrl !== undefined && (
<div style={sectionGap}> <LinkPicker value={nodeProps.logoUrl || '/'} onChange={(v) => setProp('logoUrl', v)} />
<label style={labelStyle}>Logo Link URL</label>
<input type="text" value={nodeProps.logoUrl || ''} onChange={(e) => setProp('logoUrl', e.target.value)} placeholder="/" style={inputStyle} />
</div>
)} )}
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Links (not shown for standalone Logo) */} {/* Links (not shown for standalone Logo, or for components -- like
{!isStandaloneLogo && ( Footer -- that don't carry a `links` array at all). */}
{!isStandaloneLogo && nodeProps.links !== undefined && (
<CollapsibleSection title="Links"> <CollapsibleSection title="Links">
<button
onClick={syncWithPages}
title="Replace these links with one per page (preserves any CTA link)"
style={{ marginBottom: 8, width: '100%', padding: '6px', fontSize: 11, background: 'rgba(59,130,246,0.12)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.4)', borderRadius: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
>
<i className="fa fa-refresh" /> Sync links with Pages
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => ( {links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}> <div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -165,7 +327,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<i className="fa fa-times" /> <i className="fa fa-times" />
</button> </button>
</div> </div>
<input type="text" value={link.href || ''} onChange={(e) => updateLink(i, 'href', e.target.value)} placeholder="URL" style={{ ...smallInputStyle, color: '#71717a' }} /> <LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
Download (points at a file)
</label>
</div> </div>
))} ))}
</div> </div>
@@ -234,12 +400,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Style overrides */} {/* Box model: margin + padding (per-side, via style.*). The old
single "Padding" preset row is folded into the Padding SpacingControl
below (still writes to style.padding when linked, matching the
previous behavior exactly). */}
<CollapsibleSection title="Spacing" defaultOpen={false}> <CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${capitalize(side)}`, v)}
presets={SPACING_PRESETS}
/>
</CollapsibleSection>
{/* Border & Effects: border, box-shadow, opacity */}
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Padding</SectionLabel> <SectionLabel>Box Shadow</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={(nodeProps.style || {}).padding as string} onSelect={(v) => setPropStyle('padding', v)} /> <PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow || 'none'} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div> </div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity</label>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(parseFloat(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Entrance animation */}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.animation = v.animation; p.animationDelay = v.animationDelay; })}
/>
</CollapsibleSection>
{/* Responsive visibility */}
<CollapsibleSection title="Visibility" defaultOpen={false}>
<VisibilityControl
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.hideOnDesktop = v.hideOnDesktop; p.hideOnTablet = v.hideOnTablet; p.hideOnMobile = v.hideOnMobile; })}
/>
</CollapsibleSection> </CollapsibleSection>
</> </>
); );
@@ -1,16 +1,38 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { SHADOW_PRESETS } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
SectionLabel,
PresetButtonGrid,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp,
SpacingControl,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
} from './shared'; } from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
* buildBorderShorthand() back into its parts for round-tripping through
* BorderControl. See SectionTypePanel.tsx for the identical helper. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
const bulletOptions = [ const bulletOptions = [
{ label: '✓', value: 'check' }, { label: '✓', value: 'check' },
{ label: '●', value: 'dot' }, { label: '●', value: 'dot' },
@@ -26,10 +48,12 @@ const bulletChar: Record<string, string> = {
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const [expandedPlan, setExpandedPlan] = useState<number>(0); const [expandedPlan, setExpandedPlan] = useState<number>(0);
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : []; const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
const currentBullet = nodeProps.bulletType || 'check'; const currentBullet = nodeProps.bulletType || 'check';
const style = nodeProps.style || {};
const updatePlan = useCallback((planIndex: number, field: string, value: any) => { const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
actions.setProp(selectedId, (props: any) => { actions.setProp(selectedId, (props: any) => {
@@ -204,6 +228,79 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
{/* Colors */} {/* Colors */}
<CollapsibleSection title="Colors" defaultOpen={false}> <CollapsibleSection title="Colors" defaultOpen={false}>
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} /> <ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
{/* Regular (non-featured) card colors -- built into PricingTable's
render/toHtml but previously hard-coded literals with no control
surfacing them. Each falls back to the prior literal when blank. */}
<ColorPickerField label="Card Background" value={nodeProps.cardBg || '#ffffff'} onChange={(v) => setProp('cardBg', v)} />
<ColorPickerField label="Heading / Price Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setProp('subColor', v)} />
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setProp('featColor', v)} />
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setProp('checkColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setProp('btnBg', v)} />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setProp('btnColor', v)} />
</CollapsibleSection>
{/* Box model: margin, padding, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" 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, v) => setPropStyle(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
/>
<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 className="guided-section">
<SectionLabel>Opacity</SectionLabel>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Entrance animation */}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
</CollapsibleSection>
{/* Responsive visibility */}
<CollapsibleSection title="Visibility" defaultOpen={false}>
<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> </CollapsibleSection>
</> </>
); );
@@ -3,6 +3,7 @@ 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,
@@ -15,10 +16,31 @@ import {
inputStyle, inputStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
SpacingControl,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
} from './shared'; } from './shared';
import { FeaturesEditor } from './FeaturesEditor'; import { FeaturesEditor } from './FeaturesEditor';
import { ArrayItemFieldsEditor } from './ArrayItemFields'; import { ArrayItemFieldsEditor } from './ArrayItemFields';
/** Parses a border shorthand string ("2px solid #hex") produced by
* buildBorderShorthand() back into its parts for round-tripping through
* BorderControl. Anything that doesn't match (undefined/'none'/legacy
* hand-authored values) falls back to an empty/none border rather than
* throwing, since this is display-only -- the next edit always re-emits a
* clean shorthand via buildBorderShorthand(). */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */ /* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => { export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
@@ -26,7 +48,12 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
const style = nodeProps.style || {}; const style = nodeProps.style || {};
// Find all string/number/boolean props // Find all string/number/boolean props
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']); const SKIP_PROPS = new Set([
'style', 'children', 'cssId', 'cssClass',
// Rendered via dedicated controls below (Animation/Visibility), not the
// generic Content field dump -- otherwise they'd show up twice.
'animation', 'animationDelay', 'hideOnDesktop', 'hideOnTablet', 'hideOnMobile',
]);
const scalarProps = Object.entries(nodeProps).filter( const scalarProps = Object.entries(nodeProps).filter(
([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') ([key, val]) => !SKIP_PROPS.has(key) && (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean')
); );
@@ -126,6 +153,69 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
<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, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" 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, v) => setPropStyle(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
/>
<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 className="guided-section">
<SectionLabel>Opacity</SectionLabel>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Entrance animation */}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
</CollapsibleSection>
{/* Responsive visibility */}
<CollapsibleSection title="Visibility" defaultOpen={false}>
<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>
</> </>
); );
}; };
@@ -3,6 +3,7 @@ import { useEditor } from '@craftjs/core';
import { import {
BG_COLORS, BG_COLORS,
SPACING_PRESETS, SPACING_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
@@ -17,8 +18,33 @@ import {
btnActiveStyle, btnActiveStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
SpacingControl,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
} from './shared'; } from './shared';
/** Parses a border shorthand string ("2px solid #hex") produced by
* buildBorderShorthand() back into its parts for round-tripping through
* BorderControl. See SectionTypePanel.tsx for the identical helper. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
const ICON_SHAPES = [
{ label: 'None', value: 'none' },
{ label: 'Circle', value: 'circle' },
{ label: 'Square', value: 'square' },
{ label: 'Rounded', value: 'rounded' },
];
/* ---------- SOCIAL / ICON / STAR RATING ---------- */ /* ---------- SOCIAL / ICON / STAR RATING ---------- */
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor(); const { actions } = useEditor();
@@ -111,6 +137,72 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
</div> </div>
)} )}
{/* Icon shape (SocialLinks) -- exists on the component (iconShape prop
drives the circle/square/rounded background box) but previously had
no control. */}
{nodeProps.iconShape !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Icon Shape</label>
<div style={{ display: 'flex', gap: 4 }}>
{ICON_SHAPES.map((s) => (
<button key={s.value} onClick={() => setProp('iconShape', s.value)} style={{ ...btnActiveStyle(nodeProps.iconShape === s.value), flex: 1 }}>
{s.label}
</button>
))}
</div>
</div>
)}
{/* Gap between links (SocialLinks) -- exists on the component but
previously had no control. */}
{nodeProps.gap !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Gap</label>
<input type="text" value={nodeProps.gap || '10px'} onChange={(e) => setProp('gap', e.target.value)} placeholder="10px" style={inputStyle} />
</div>
)}
{/* Background shape / size / link (Icon component) -- bgColor, bgShape,
bgSize, link all already exist on Icon.craft.props but had no
control; the generic `color`/`iconColor` checks above never matched
Icon's actual prop names. */}
{nodeProps.bgShape !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Background Shape</label>
<div style={{ display: 'flex', gap: 4 }}>
{ICON_SHAPES.map((s) => (
<button key={s.value} onClick={() => setProp('bgShape', s.value)} style={{ ...btnActiveStyle(nodeProps.bgShape === s.value), flex: 1 }}>
{s.label}
</button>
))}
</div>
</div>
)}
{nodeProps.bgColor !== undefined && (
<ColorPickerField label="Background Color" value={nodeProps.bgColor === 'transparent' ? '' : nodeProps.bgColor} onChange={(v) => setProp('bgColor', v)} />
)}
{nodeProps.bgSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Background Size</label>
<input type="text" value={nodeProps.bgSize || '56px'} onChange={(e) => setProp('bgSize', e.target.value)} placeholder="56px" style={inputStyle} />
</div>
)}
{nodeProps.link !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Link URL</label>
<input type="text" value={nodeProps.link || ''} onChange={(e) => setProp('link', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
{/* Star colors (StarRating) -- exist on the component as filledColor /
emptyColor but had no control (the generic `starColor` check above
never matched StarRating's actual prop names). */}
{nodeProps.filledColor !== undefined && (
<ColorPickerField label="Filled Star Color" value={nodeProps.filledColor || '#f59e0b'} onChange={(v) => setProp('filledColor', v)} />
)}
{nodeProps.emptyColor !== undefined && (
<ColorPickerField label="Empty Star Color" value={nodeProps.emptyColor || '#d1d5db'} onChange={(v) => setProp('emptyColor', v)} />
)}
{/* Colors */} {/* Colors */}
{nodeProps.iconColor !== undefined && ( {nodeProps.iconColor !== undefined && (
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} /> <ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
@@ -164,6 +256,69 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} /> <PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div> </div>
</CollapsibleSection> </CollapsibleSection>
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" 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, v) => setPropStyle(`margin${capSide(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{
top: style.paddingTop as string, right: style.paddingRight as string,
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
}}
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
/>
<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 className="guided-section">
<SectionLabel>Opacity</SectionLabel>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Entrance animation */}
<CollapsibleSection title="Animation" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
</CollapsibleSection>
{/* Responsive visibility */}
<CollapsibleSection title="Visibility" defaultOpen={false}>
<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>
</> </>
); );
}; };
@@ -4,20 +4,69 @@ import {
FONT_FAMILIES, FONT_FAMILIES,
TEXT_SIZES, TEXT_SIZES,
FONT_WEIGHTS, FONT_WEIGHTS,
LINE_HEIGHTS,
LETTER_SPACINGS,
SHADOW_PRESETS,
} from '../../../constants/presets'; } from '../../../constants/presets';
import { import {
StylePanelProps, StylePanelProps,
SectionLabel, SectionLabel,
ColorSwatchGrid, ColorSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
NumericUnitInput,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
/* Text-transform is a small fixed enum with no natural home in the shared
foundation presets (constants/presets.ts is import-only for this
package), so it lives here as a package-local preset list. */
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
{ label: 'None', value: 'none' },
{ label: 'UPPER', value: 'uppercase' },
{ label: 'lower', value: 'lowercase' },
{ label: 'Capitalize', value: 'capitalize' },
];
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand. */
function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
/* ---------- TEXT ---------- */ /* ---------- TEXT ---------- */
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
return ( return (
<> <>
@@ -44,6 +93,15 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
activeValue={style.fontSize as string} activeValue={style.fontSize as string}
onSelect={(v) => setPropStyle('fontSize', v)} onSelect={(v) => setPropStyle('fontSize', v)}
/> />
<div style={{ marginTop: 6 }}>
<NumericUnitInput
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
onChange={(v) => setPropStyle('fontSize', v)}
units={['px', 'em', 'rem', '%']}
placeholder="custom"
testId="text-fontsize-custom"
/>
</div>
</div> </div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Font Weight</SectionLabel> <SectionLabel>Font Weight</SectionLabel>
@@ -53,6 +111,53 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
onSelect={(v) => setPropStyle('fontWeight', v)} onSelect={(v) => setPropStyle('fontWeight', v)}
/> />
</div> </div>
<div className="guided-section">
<SectionLabel>Style</SectionLabel>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className={`preset-btn ${isItalic ? 'active' : ''}`}
style={{ flex: 1, fontStyle: 'italic' }}
onClick={() => setPropStyle('fontStyle', isItalic ? 'normal' : 'italic')}
title="Italic"
>
<i className="fa fa-italic" /> Italic
</button>
<button
type="button"
className={`preset-btn ${isUnderline ? 'active' : ''}`}
style={{ flex: 1, textDecoration: 'underline' }}
onClick={() => setPropStyle('textDecoration', isUnderline ? 'none' : 'underline')}
title="Underline"
>
<i className="fa fa-underline" /> Underline
</button>
</div>
</div>
<div className="guided-section">
<SectionLabel>Text Transform</SectionLabel>
<PresetButtonGrid
presets={TEXT_TRANSFORMS}
activeValue={(style.textTransform as string) || 'none'}
onSelect={(v) => setPropStyle('textTransform', v === 'none' ? '' : v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Line Height</SectionLabel>
<PresetButtonGrid
presets={LINE_HEIGHTS}
activeValue={String(style.lineHeight || '')}
onSelect={(v) => setPropStyle('lineHeight', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Letter Spacing</SectionLabel>
<PresetButtonGrid
presets={LETTER_SPACINGS}
activeValue={String(style.letterSpacing || '')}
onSelect={(v) => setPropStyle('letterSpacing', v)}
/>
</div>
<div className="guided-section"> <div className="guided-section">
<SectionLabel>Alignment</SectionLabel> <SectionLabel>Alignment</SectionLabel>
<div className="preset-grid align-grid"> <div className="preset-grid align-grid">
@@ -68,6 +173,59 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
))} ))}
</div> </div>
</div> </div>
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop as string, right: style.paddingRight as string, bottom: style.paddingBottom as string, left: style.paddingLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyle(`padding${capitalize(side)}`, v)}
/>
</CollapsibleSection>
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border as string)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
</> </>
); );
}; };
@@ -0,0 +1,135 @@
import React from 'react';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
} from './shared';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
CONTAINERS package's single shared panel (ContainerStylePanel, used for
Container / Section / Columns). Kept local to this package (not in
shared.tsx, which is foundation/import-only) since it's just DRY-ing the
identical JSX block across those 3 components rather than a genuinely
cross-package reusable control. Mirrors the equivalent helper in the
media package (mediaBoxModel.tsx) -- same shape, independently duplicated
per-package by design (packages are developed and merged in parallel).
========================================================================== */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand --
* not arbitrary author-supplied CSS. */
export function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
</CollapsibleSection>
);
};
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
* integer for the range input / label. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
);
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
);
@@ -0,0 +1,133 @@
import React from 'react';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
} from './shared';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
MEDIA package's panels (ImageStylePanel + MediaStylePanel). Kept local to
this package (not in shared.tsx, which is foundation/import-only) since
it's just DRY-ing the identical JSX block across ImageBlock/VideoBlock/
Gallery/ContentSlider/MapEmbed rather than a genuinely cross-package
reusable control.
========================================================================== */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand --
* not arbitrary author-supplied CSS. */
export function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
</CollapsibleSection>
);
};
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
* integer for the range input / label. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
);
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
);
@@ -0,0 +1,85 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { HeadCodeModal } from './HeadCodeModal';
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- DOM test harness -- same react-dom/client + `act` pattern used
throughout src/ui/*.test.tsx (no @testing-library/react in this repo). ---------- */
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
document.body.style.overflow = '';
});
// Exposes the current headCode so assertions can read it back after a
// simulated edit -- CodeEditor writes through `updateDesign`, this just
// surfaces the result.
function Harness({ onReady }: { onReady: (headCode: string) => void }) {
const { design } = useSiteDesign();
onReady(design.headCode);
return null;
}
describe('HeadCodeModal', () => {
// HeadCodeModal portals its content to document.body (see the comment in
// HeadCodeModal.tsx), so the rendered DOM lives outside `container` --
// query document.body instead.
test('renders the CodeEditor (fallback textarea path) seeded with the current headCode', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
</SiteDesignProvider>,
);
// CodeMirror loads via async dynamic import (see CodeEditor.test.tsx);
// synchronously after mount the fallback textarea is what's live.
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
expect(textarea).not.toBeNull();
expect(textarea!.value).toBe('');
expect(textarea!.dataset.language).toBe('html');
});
test('typing in the editor writes through to SiteDesignContext.headCode', () => {
let latestHeadCode = '';
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
<Harness onReady={(v) => { latestHeadCode = v; }} />
</SiteDesignProvider>,
);
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]')!;
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
setter.call(textarea, '<meta name="x" content="y">');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(latestHeadCode).toBe('<meta name="x" content="y">');
});
test('does not render when closed', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open={false} onClose={vi.fn()} />
</SiteDesignProvider>,
);
expect(document.body.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
});
});
+10 -21
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal'; import { Modal } from '../../ui/Modal';
import { CodeEditor } from '../../ui/CodeEditor';
interface HeadCodeModalProps { interface HeadCodeModalProps {
open: boolean; open: boolean;
@@ -55,27 +56,15 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}>&lt;head&gt;</code> of every page on your site. Use it for analytics, custom fonts, or global CSS. Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}>&lt;head&gt;</code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
</div> </div>
<textarea <div style={{ flex: 1, minHeight: 300 }}>
value={design.headCode || ''} <CodeEditor
onChange={(e) => updateDesign({ headCode: e.target.value })} value={design.headCode || ''}
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"} onChange={(code) => updateDesign({ headCode: code })}
style={{ language="html"
flex: 1, height="100%"
minHeight: 300, placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
padding: 14, />
background: '#0d0d0f', </div>
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 8,
fontFamily: 'Source Code Pro, Consolas, monospace',
fontSize: 13,
lineHeight: 1.6,
resize: 'vertical',
outline: 'none',
tabSize: 2,
}}
spellCheck={false}
/>
</div> </div>
{/* Footer */} {/* Footer */}
@@ -0,0 +1,57 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { PublishWarnings } from './PublishWarnings';
/* ---------- DOM test harness (no @testing-library/react in this repo, see
src/ui/AssetPicker.test.tsx for the same pattern: react-dom/client +
react-dom/test-utils `act`, both transitive deps of react-dom already). ---------- */
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 click(el: Element | null) {
if (!el) throw new Error('element not found');
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
});
describe('PublishWarnings', () => {
test('renders nothing when there are no warnings', () => {
render(<PublishWarnings warnings={[]} onDismiss={() => {}} />);
expect(container.innerHTML).toBe('');
});
test('renders each warning', () => {
render(
<PublishWarnings
warnings={['First problem.', 'Second problem.']}
onDismiss={() => {}}
/>,
);
expect(container.textContent).toContain('First problem.');
expect(container.textContent).toContain('Second problem.');
});
test('dismiss fires the callback', () => {
const onDismiss = vi.fn();
render(<PublishWarnings warnings={['A problem.']} onDismiss={onDismiss} />);
click(container.querySelector('[data-testid="publish-warnings-dismiss"]'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,31 @@
import React from 'react';
export interface PublishWarningsProps {
warnings: string[];
onDismiss: () => void;
}
/** Non-blocking banner shown after a successful publish. The site IS live --
* these are things the customer should fix and re-publish, not failures. */
export const PublishWarnings: React.FC<PublishWarningsProps> = ({ warnings, onDismiss }) => {
if (!warnings.length) return null;
return (
<div className="publish-warnings" role="status" data-testid="publish-warnings">
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
<ul>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
data-testid="publish-warnings-dismiss"
>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
);
};
+191
View File
@@ -0,0 +1,191 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/**
* Closes the gap flagged by the whole-branch review: PublishWarnings.test.tsx
* covers the presentational banner in isolation, but nothing asserted that
* `result.warnings` from `publish()` actually reaches it through TopBar. That
* 3-line seam (handlePublish -> setPublishWarnings -> <PublishWarnings>) is
* exactly what regressed before this feature existed: the backend has always
* returned `warnings`, and TopBar discarded them by only checking
* `result.success` -- so the contact-form-relay warning was dead code for its
* entire life. This suite drives the real `<TopBar>` through a real button
* click and asserts the warnings show up, survive the 3s "Published" flash,
* don't taint the success/error status, and get cleared by a fresh publish.
*
* Mocks (same DOM-harness + `vi.mock('@craftjs/core', ...)` pattern as
* RenderNode.test.tsx / useWhpApi.load.test.tsx -- no @testing-library/react
* in this repo):
* - `@craftjs/core`'s `useEditor`: TopBar only needs inert undo/redo/query
* stubs, not a real Craft.js tree.
* - `useWhpApi`: this IS the seam under test -- `publish` is a controllable
* mock so each test can choose exactly what the "backend" returns.
* - TemplateModal / HeadCodeModal / SitesmithButton: sibling chrome
* unrelated to the warnings seam (portals, CodeMirror lazy-load,
* useSitesmith's own fetch calls) -- stubbed out so this suite stays
* focused, same as MobilePanelBar.test.tsx stubbing its sibling panels.
* `usePages`/`useSiteDesign`/`useMobileChrome` are left un-mocked and
* un-provided -- their default context values (defined in each context
* module) are harmless no-op stubs, and TopBar's desktop render path never
* needs more than that.
*/
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
const query = {
serialize: () => '{}',
history: { canUndo: () => false, canRedo: () => false },
};
const actions = { history: { undo: vi.fn(), redo: vi.fn() } };
const collected = collector ? collector({}, query) : {};
return { actions, query, ...collected };
},
}));
const publishMock = vi.fn();
vi.mock('../../hooks/useWhpApi', () => ({
useWhpApi: () => ({
save: vi.fn().mockResolvedValue({ success: true }),
publish: publishMock,
load: vi.fn().mockResolvedValue(null),
uploadAsset: vi.fn(),
isWHP: true,
}),
}));
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
import { TopBar } from './TopBar';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { WhpConfig } from '../../types';
const whpConfig: WhpConfig = {
user: 'testuser',
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 1,
siteDomain: 'example.com',
siteName: 'Example Site',
backUrl: '/panel/sites',
isRoot: false,
};
let container: HTMLDivElement;
let root: Root;
function render() {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<TopBar device="desktop" onDeviceChange={() => {}} showGuides={false} onToggleGuides={() => {}} />
</EditorConfigProvider>,
);
});
}
function publishButton(): HTMLButtonElement {
const btn = container.querySelector<HTMLButtonElement>('.topbar-btn.publish');
if (!btn) throw new Error('Publish button not found');
return btn;
}
/** handlePublish does exactly one `await publish()` before touching state;
* two microtask flushes inside the same act() batch is enough to carry that
* through to the resulting re-render. */
async function clickPublish() {
await act(async () => {
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
publishMock.mockReset();
vi.useRealTimers();
});
describe('TopBar publish-warnings wiring', () => {
test('warnings from publish() flow through into the rendered banner', async () => {
publishMock.mockResolvedValue({ success: true, warnings: ['Warning one.', 'Warning two.'] });
render();
await clickPublish();
expect(container.textContent).toContain('Warning one.');
expect(container.textContent).toContain('Warning two.');
});
test('a clean publish (no warnings key) renders no banner at all', async () => {
publishMock.mockResolvedValue({ success: true });
render();
await clickPublish();
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
});
test('a warning is non-blocking -- publish still reports success, not a failure', async () => {
publishMock.mockResolvedValue({
success: true,
warnings: ['Submissions will not be delivered until an administrator enables it.'],
});
render();
await clickPublish();
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
expect(container.querySelector('.save-indicator.error')).toBeNull();
expect(container.textContent).toContain('Submissions will not be delivered until an administrator enables it.');
});
test('warnings survive the 3-second "Published" flash timer', async () => {
vi.useFakeTimers();
publishMock.mockResolvedValue({ success: true, warnings: ['Sticks around after the flash.'] });
render();
await act(async () => {
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
// Sanity: both the flash and the warning are up before the timer fires.
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
expect(container.textContent).toContain('Sticks around after the flash.');
act(() => {
vi.advanceTimersByTime(3000);
});
// The 3s timer resets publishStatus -> the "Published" flash is gone...
expect(container.querySelector('.publish-badge.published')).toBeNull();
// ...but publishWarnings lives in its own state and must NOT have been
// cleared by that same timer.
expect(container.textContent).toContain('Sticks around after the flash.');
});
test('a new publish attempt clears warnings left over from the previous one', async () => {
publishMock.mockResolvedValueOnce({ success: true, warnings: ['Old warning.'] });
render();
await clickPublish();
expect(container.textContent).toContain('Old warning.');
publishMock.mockResolvedValueOnce({ success: true });
await clickPublish();
expect(container.textContent).not.toContain('Old warning.');
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
});
});
+42 -6
View File
@@ -10,6 +10,7 @@ import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal'; import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal'; import { HeadCodeModal } from './HeadCodeModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu'; import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { PublishWarnings } from './PublishWarnings';
import { SitesmithButton } from '../sitesmith/SitesmithButton'; import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -28,11 +29,12 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
canRedo: query.history.canRedo(), canRedo: query.history.canRedo(),
})); }));
const { save, publish, load } = useWhpApi(); const { save, publish, load } = useWhpApi();
const { headerPage, footerPage } = usePages(); const { headerPage, footerPage, pages, activePageId } = usePages();
const { design } = useSiteDesign(); const { design } = useSiteDesign();
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle'); const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
const [isDraft, setIsDraft] = useState(false); const [isDraft, setIsDraft] = useState(false);
// Mobile-A2: lifted from private useState into MobileChromeContext so // Mobile-A2: lifted from private useState into MobileChromeContext so
// opening a mobile sheet can close these modals (item 3) -- behavior is // opening a mobile sheet can close these modals (item 3) -- behavior is
@@ -100,11 +102,16 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const handlePublish = useCallback(async () => { const handlePublish = useCallback(async () => {
setPublishStatus('publishing'); setPublishStatus('publishing');
setPublishWarnings([]);
try { try {
const result = await publish(); const result = await publish();
if (result?.success) { if (result?.success) {
setPublishStatus('published'); setPublishStatus('published');
setIsDraft(false); setIsDraft(false);
// The site published; these are fixable problems, not failures. Held
// independently of publishStatus so the 3s "Published" flash doesn't
// take the warning down with it.
setPublishWarnings(Array.isArray(result.warnings) ? result.warnings : []);
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current); if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000); publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
} else { } else {
@@ -133,7 +140,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const handlePreview = useCallback(() => { const handlePreview = useCallback(() => {
try { try {
const serialized = query.serialize(); const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => { import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml, buildAnimationScript }) => {
// Get header HTML // Get header HTML
let headerHtml = ''; let headerHtml = '';
try { try {
@@ -154,12 +161,39 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
} }
} catch (e) { console.warn('Footer export failed:', e); } } catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer // Compose full page: header + body + footer. `handlePreview` below
const composedBody = headerHtml + bodyHtml + footerHtml; // replaces the ENTIRE wrapped-doc `<body>` inner (including the
// in-body reveal `<script>` wrapInDocument already emitted) with
// this composed string, so the script would otherwise be clobbered
// and animated elements would stay hidden forever
// ([data-animation]{opacity:0} with no IntersectionObserver to ever
// add `.animated`). Re-append the reveal script here -- built from
// the SAME composed content it will end up living alongside -- so
// it survives the replacement below and fires exactly once.
const composedBody =
headerHtml + bodyHtml + footerHtml +
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
// PKG-H: fold the active page's own SEO overrides + the site-wide
// design tokens/favicon into the Preview export so editor Preview
// === published output (contract §4/§7). `title` is the fully
// resolved TITLE (seo.metaTitle || page.name) per the contract --
// html-export's buildSeoMeta/og:title fallback both key off it.
const activePage = pages.find((p) => p.id === activePageId);
const seo = activePage?.seo;
const title = seo?.metaTitle || activePage?.name || whpConfig?.siteName || 'Preview';
const result = exportToHtml(serialized, { const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview', title,
includeFonts: true, includeFonts: true,
headCode: design.headCode, headCode: design.headCode,
description: seo?.metaDescription,
ogTitle: seo?.ogTitle,
ogImage: seo?.ogImage,
twitterCard: seo?.twitterCard,
noindex: seo?.noindex,
favicon: design.favicon,
design,
}); });
// Replace the body in the full document with our composed version. // Replace the body in the full document with our composed version.
@@ -183,7 +217,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
} catch (e) { } catch (e) {
console.error('Preview failed:', e); console.error('Preview failed:', e);
} }
}, [query, headerPage, footerPage, whpConfig, design]); }, [query, headerPage, footerPage, whpConfig, design, pages, activePageId]);
// Phase A: at ≤768px the topbar collapses to only the essentials -- // Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith, // back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
@@ -193,6 +227,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
if (isMobile) { if (isMobile) {
return ( return (
<nav className="topbar topbar-mobile"> <nav className="topbar topbar-mobile">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
<div className="topbar-left"> <div className="topbar-left">
{isWHP && ( {isWHP && (
<a <a
@@ -280,6 +315,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
return ( return (
<nav className="topbar"> <nav className="topbar">
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
<div className="topbar-left"> <div className="topbar-left">
{isWHP && ( {isWHP && (
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel"> <a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
@@ -0,0 +1,509 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { PageProvider, usePages, applyLandingInvariant } from './PageContext';
import { PageData } from '../types';
/**
* PKG-I: page duplicate / reorder / set-landing.
*
* The landing-page invariant is: `pages[0]` is the landing page, slug
* LOCKED to `'index'`; every other page gets a real, unique slug. This
* suite covers:
* - `applyLandingInvariant` as a pure function (unit tests, no provider).
* - `movePage`/`setLandingPage` re-establishing the invariant after
* reordering, via `PageProvider`.
* - `duplicatePage` inserting a copy right after the source with a copied
* craftState + seo and a unique slug, and switching the canvas to it.
*/
function makePage(overrides: Partial<PageData> & { id: string }): PageData {
return { name: overrides.id, slug: overrides.id, craftState: null, ...overrides };
}
describe('applyLandingInvariant (pure)', () => {
test('page at index 0 gets slug "index" even if it held a different slug', () => {
const pages = [
makePage({ id: 'a', name: 'About', slug: 'about' }),
makePage({ id: 'b', name: 'Home', slug: 'index' }),
];
const result = applyLandingInvariant(pages);
expect(result[0].slug).toBe('index');
expect(result[0].id).toBe('a');
});
test('demotes the old landing page (now at index > 0) to a unique real slug', () => {
const pages = [
makePage({ id: 'a', name: 'About', slug: 'about' }),
makePage({ id: 'b', name: 'Home', slug: 'index' }),
];
const result = applyLandingInvariant(pages);
const demoted = result.find((p) => p.id === 'b')!;
expect(demoted.slug).not.toBe('index');
expect(demoted.slug).toBe('home');
});
test('never produces two pages with slug "index"', () => {
const pages = [
makePage({ id: 'a', name: 'About', slug: 'about' }),
makePage({ id: 'b', name: 'Home', slug: 'index' }),
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
];
const result = applyLandingInvariant(pages);
const indexSlugs = result.filter((p) => p.slug === 'index');
expect(indexSlugs).toHaveLength(1);
expect(indexSlugs[0].id).toBe('a');
});
test('demoted page slug is deduped against a colliding existing slug elsewhere in the array', () => {
const pages = [
makePage({ id: 'a', name: 'About', slug: 'about' }),
makePage({ id: 'b', name: 'Home', slug: 'index' }), // demoted page, named "Home" -> slugifies to "home"
makePage({ id: 'c', name: 'HomePage', slug: 'home' }), // unrelated page already using slug "home"
];
const result = applyLandingInvariant(pages);
expect(result[0].slug).toBe('index');
const demoted = result.find((p) => p.id === 'b')!;
expect(demoted.slug).toBe('home-2');
const untouched = result.find((p) => p.id === 'c')!;
expect(untouched.slug).toBe('home');
const slugs = result.map((p) => p.slug);
expect(new Set(slugs).size).toBe(slugs.length);
});
test('non-landing pages that already have a real slug are left untouched', () => {
const pages = [
makePage({ id: 'a', name: 'Home', slug: 'index' }),
makePage({ id: 'b', name: 'About', slug: 'about' }),
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
];
const result = applyLandingInvariant(pages);
expect(result[1]).toEqual(pages[1]);
expect(result[2]).toEqual(pages[2]);
});
test('empty array is a no-op', () => {
expect(applyLandingInvariant([])).toEqual([]);
});
});
/* ---------- PageProvider-mounted coverage ---------- */
let serializeReturn = '{}';
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => serializeReturn },
actions: { deserialize: deserializeMock },
}),
}));
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();
}
async function flushTimers() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
beforeEach(() => {
serializeReturn = '{}';
deserializeMock.mockClear();
});
describe('PageContext.movePage', () => {
test('moves a page up, swapping with its neighbor', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
act(() => ctx!.addPage('Contact', 'contact'));
// pages: [Home(index0), About, Contact]
const contactId = ctx!.pages[2].id;
act(() => ctx!.movePage(contactId, 'up'));
expect(ctx!.pages.map((p) => p.name)).toEqual(['Home', 'Contact', 'About']);
// Landing invariant still holds -- Home untouched at index 0.
expect(ctx!.pages[0].slug).toBe('index');
unmount();
});
test('is a no-op at the top boundary', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
const homeId = ctx!.pages[0].id;
const before = ctx!.pages.map((p) => p.id);
act(() => ctx!.movePage(homeId, 'up'));
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
unmount();
});
test('is a no-op at the bottom boundary', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
const aboutId = ctx!.pages[1].id;
const before = ctx!.pages.map((p) => p.id);
act(() => ctx!.movePage(aboutId, 'down'));
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
unmount();
});
test('moving a non-landing page into index 0 promotes it and demotes the old landing page to a real slug', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
// pages: [Home(index0, slug index), About]
const aboutId = ctx!.pages[1].id;
const homeId = ctx!.pages[0].id;
act(() => ctx!.movePage(aboutId, 'up'));
// pages: [About, Home]
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
expect(ctx!.pages[0].slug).toBe('index'); // About is now the landing page
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
expect(demotedHome.slug).not.toBe('index');
expect(demotedHome.slug).toBe('home');
// Exactly one 'index' slug, always at index 0.
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
expect(indexPages).toHaveLength(1);
expect(ctx!.pages.indexOf(indexPages[0])).toBe(0);
// movePage does not touch the canvas.
expect(deserializeMock).not.toHaveBeenCalled();
unmount();
});
test('moving the current landing page down demotes it and promotes its neighbor', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
// pages: [Home(index0, slug index), About]
const homeId = ctx!.pages[0].id;
const aboutId = ctx!.pages[1].id;
act(() => ctx!.movePage(homeId, 'down'));
// pages: [About, Home]
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
// Order changed and the landing invariant re-established: index 0
// (now About) gets slug 'index'; the moved page (now at index 1, Home)
// gets a real, non-'index' unique slug.
expect(ctx!.pages[0].id).toBe(aboutId);
expect(ctx!.pages[0].slug).toBe('index');
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
expect(demotedHome.slug).not.toBe('index');
expect(demotedHome.slug).toBe('home');
// Exactly one 'index' slug.
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
expect(indexPages).toHaveLength(1);
unmount();
});
});
describe('PageContext.setLandingPage', () => {
test('promotes an arbitrary page to index 0 and demotes the old landing page', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
act(() => ctx!.addPage('Contact', 'contact'));
const homeId = ctx!.pages[0].id;
const contactId = ctx!.pages[2].id;
act(() => ctx!.setLandingPage(contactId));
expect(ctx!.pages[0].id).toBe(contactId);
expect(ctx!.pages[0].slug).toBe('index');
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
expect(demotedHome.slug).toBe('home');
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
expect(indexPages).toHaveLength(1);
// setLandingPage does not touch the canvas.
expect(deserializeMock).not.toHaveBeenCalled();
unmount();
});
test('is a no-op when the page is already the landing page', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
const homeId = ctx!.pages[0].id;
const before = ctx!.pages.map((p) => ({ ...p }));
act(() => ctx!.setLandingPage(homeId));
expect(ctx!.pages).toEqual(before);
unmount();
});
});
describe('PageContext.duplicatePage', () => {
test('inserts a copy immediately after the source with a copied craftState, seo, and a unique slug', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
await flushTimers();
act(() => ctx!.addPage('Contact', 'contact'));
await flushTimers();
// pages: [Home, About, Contact]; Contact is currently active.
const aboutId = ctx!.pages[1].id;
act(() => ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' }));
act(() => ctx!.duplicatePage(aboutId));
await flushTimers();
const names = ctx!.pages.map((p) => p.name);
expect(names).toEqual(['Home', 'About', 'About copy', 'Contact']);
const copy = ctx!.pages[2];
expect(copy.name).toBe('About copy');
expect(copy.slug).toBe('about-copy');
expect(copy.seo).toEqual({ metaTitle: 'About Us' });
// Every slug in the array is unique.
const slugs = ctx!.pages.map((p) => p.slug);
expect(new Set(slugs).size).toBe(slugs.length);
// Landing invariant untouched -- copy is never index 0.
expect(ctx!.pages[0].slug).toBe('index');
unmount();
});
test('duplicating the ACTIVE page saves the live canvas into the copy (and the original)', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
// Home is active by default. Simulate the user having made live edits.
serializeReturn = '{"ROOT":{"live":"edit"}}';
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
await flushTimers();
const copy = ctx!.pages[1];
expect(copy.name).toBe('Home copy');
expect(copy.craftState).toBe('{"ROOT":{"live":"edit"}}');
// Original page's stored state was also refreshed to the live canvas.
expect(ctx!.pages[0].craftState).toBe('{"ROOT":{"live":"edit"}}');
// The canvas switched to the new copy.
expect(ctx!.activePageId).toBe(copy.id);
unmount();
});
test('duplicating a non-active page copies its already-stored craftState (no live-canvas read)', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
await flushTimers();
// Home is now inactive, stored with whatever it serialized to on switch.
const homeId = ctx!.pages[0].id;
const homeCraftState = ctx!.pages[0].craftState;
// Switch the live serialize() return to something else, to prove
// duplicating an inactive page does NOT read the live canvas.
serializeReturn = '{"ROOT":{"unrelated":"currently-active-page-content"}}';
act(() => ctx!.duplicatePage(homeId));
await flushTimers();
const copy = ctx!.pages.find((p) => p.name === 'Home copy')!;
expect(copy.craftState).toBe(homeCraftState);
unmount();
});
test('duplicating a non-active page does NOT drop the outgoing active page\'s live unsaved edits (regression lock)', async () => {
// Regression test for the Critical bug: duplicatePage(pageId) used to
// call saveCurrentState() ONLY when pageId === the active page, yet
// ALWAYS ended by tearing down the canvas via loadState() + switching
// activePageId to the copy. If the duplicated page was NOT the active
// one, the active page's live canvas edits were never serialized into
// its slot before that teardown -- silently discarded. This asserts the
// outgoing active page ('About') keeps its live-serialized craftState
// after duplicating a DIFFERENT page ('Home').
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
await flushTimers();
// pages: [Home, About]; About is active (addPage switches to it).
const homeId = ctx!.pages[0].id;
const aboutId = ctx!.pages[1].id;
expect(ctx!.activePageId).toBe(aboutId);
// Simulate the user having made live, unsaved edits to About (the
// active page) that have not yet been serialized into pages[] state.
const liveAboutEdit = '{"ROOT":{"live":"about-edit-not-yet-saved"}}';
serializeReturn = liveAboutEdit;
// Duplicate a DIFFERENT page (Home), not the active one (About).
act(() => ctx!.duplicatePage(homeId));
await flushTimers();
// The outgoing active page's live edits must have been persisted into
// its own slot before the canvas was torn down and switched away.
const aboutAfter = ctx!.pages.find((p) => p.id === aboutId)!;
expect(aboutAfter.craftState).toBe(liveAboutEdit);
unmount();
});
test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
deserializeMock.mockClear();
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
await flushTimers();
expect(deserializeMock).toHaveBeenCalled();
unmount();
});
});
+191 -3
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'; import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { PageData } from '../types'; import { PageData, PageSeo } from '../types';
import { SerializedTreeNode } from '../types/sitesmith'; import { SerializedTreeNode } from '../types/sitesmith';
import { useSiteDesign, SiteDesign } from './SiteDesignContext'; import { useSiteDesign, SiteDesign } from './SiteDesignContext';
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree'; import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
@@ -18,9 +18,33 @@ interface PageContextValue {
addPage: (name: string, slug: string) => void; addPage: (name: string, slug: string) => void;
deletePage: (pageId: string) => void; deletePage: (pageId: string) => void;
renamePage: (pageId: string, name: string, slug: string) => void; renamePage: (pageId: string, name: string, slug: string) => void;
/**
* Duplicates `pageId`, inserting the copy immediately after the source in
* `pages` and switching the canvas to the new copy. If `pageId` is the
* active page, its current on-canvas state is saved first so the copy
* (and the original) both reflect what's actually on screen. The copy
* gets its own unique slug (never `'index'` -- it's never at index 0) and
* a name of `"<source name> copy"`; its `seo` is copied from the source.
*/
duplicatePage: (pageId: string) => void;
/**
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
* the adjacent page; no-op at either end). Does NOT touch the live
* canvas -- only list order changes. Re-applies the landing-page
* invariant afterward (see `applyLandingInvariant`) since a reorder can
* move a different page into/out of index 0.
*/
movePage: (pageId: string, direction: 'up' | 'down') => void;
/**
* Moves `pageId` to index 0 (making it the new landing page) and
* re-applies the landing-page invariant. Does NOT touch the live canvas.
*/
setLandingPage: (pageId: string) => void;
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
setHeaderCraftState: (craftState: string) => void; setHeaderCraftState: (craftState: string) => void;
setFooterCraftState: (craftState: string) => void; setFooterCraftState: (craftState: string) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void; setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
/** /**
* Bookkeeping-only: point `activePageId` at an already-loaded page without * Bookkeeping-only: point `activePageId` at an already-loaded page without
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's * re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
@@ -186,6 +210,10 @@ const PageContext = createContext<PageContextValue>({
addPage: () => {}, addPage: () => {},
deletePage: () => {}, deletePage: () => {},
renamePage: () => {}, renamePage: () => {},
duplicatePage: () => {},
movePage: () => {},
setLandingPage: () => {},
updatePageSeo: () => {},
setHeaderCraftState: () => {}, setHeaderCraftState: () => {},
setFooterCraftState: () => {}, setFooterCraftState: () => {},
setPagesCraftState: () => {}, setPagesCraftState: () => {},
@@ -226,6 +254,59 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
return `${base}-${i}`; return `${base}-${i}`;
} }
/**
* Re-establishes the landing-page invariant on a REORDERED pages array: the
* page now at index 0 is the landing page and its slug is locked to
* `'index'` (regardless of whatever slug it held before it was moved there);
* every other page keeps its slug UNLESS it's the page that previously held
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
* unique slug of its own (derived from its name) since a page can no longer
* publish to `index.html` from anywhere but index 0.
*
* Pure function of the array -- used by both `movePage` (swap two adjacent
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
* shared "fix the invariant up after reordering" step, and directly
* unit-testable without mounting `PageProvider`.
*
* Normally at most one page enters with slug `'index'` (true for any array
* that already satisfied the invariant before the reorder that produced this
* input) -- exactly the case both callers hand it. Defensively, though, a
* STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
* loaded data that predates this invariant) is also demoted rather than left
* as a duplicate -- see the running `usedSlugs` accumulation below.
*/
export function applyLandingInvariant(pages: PageData[]): PageData[] {
if (pages.length === 0) return pages;
// Slugs that must not be collided into: 'index' (reserved for whoever
// ends up at index 0) plus every non-landing page's existing slug except
// any demoted page's (it currently holds 'index' and is about to be given
// a new one). Computed upfront, over the WHOLE array, so a demoted page's
// new slug is checked against every other page regardless of array order
// -- checking only "slugs seen so far" while walking the array would miss
// a collision against a page that appears LATER in the list than the
// demoted one. Mutated (pushed to) as pages are demoted below so that two
// demoted pages in the same pass can't collide with EACH OTHER either.
const usedSlugs: string[] = ['index'];
for (let i = 1; i < pages.length; i++) {
if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
}
return pages.map((page, i) => {
if (i === 0) {
return page.slug === 'index' ? page : { ...page, slug: 'index' };
}
if (page.slug === 'index') {
// Demoted landing page (or a stray extra 'index' page -- see doc
// comment above) -- give it a real, unique slug of its own.
const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
usedSlugs.push(newSlug);
return { ...page, slug: newSlug };
}
return page;
});
}
const DEFAULT_PAGE: PageData = { const DEFAULT_PAGE: PageData = {
id: 'home', id: 'home',
name: 'Home', name: 'Home',
@@ -406,6 +487,101 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
[loadState], [loadState],
); );
/**
* Duplicates `pageId`: inserts a copy immediately after the source in
* `pages` and switches the live canvas to it. See the doc comment on
* `PageContextValue.duplicatePage`.
*/
const duplicatePage = useCallback(
(pageId: string) => {
// Always persist whatever is on the live canvas back into its page
// slot BEFORE any teardown below (same as addPage/switchPage/deletePage
// do unconditionally). Without this, duplicating a page OTHER than the
// active one would tear down and switch the canvas via loadState()
// further down without ever serializing the outgoing active page's
// live edits into its slot -- silently discarding them.
saveCurrentState();
const isActive = pageId === activePageIdRef.current;
const source = pagesRef.current.find((p) => p.id === pageId);
if (!source) return;
// If the source IS the active page, saveCurrentState() above just
// wrote the live canvas into `source.craftState`'s slot -- but
// `pagesRef.current` (captured above) may still be the pre-update
// snapshot depending on render timing, so ask Craft.js directly for
// the same value rather than re-reading the ref. If the source is a
// NON-active page, its stored craftState is untouched by saving the
// (different) active page above, so use it as-is.
const sourceCraftState = isActive ? query.serialize() : source.craftState;
const otherSlugs = pagesRef.current.map((p) => p.slug);
const copyId = nextPageId();
const copyName = `${source.name} copy`;
// The copy is always inserted AFTER the source (index >= 1), so it
// never needs the reserved 'index' slug -- a normal unique slug always
// applies here regardless of whether the source itself is the landing
// page.
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
const copy: PageData = {
id: copyId,
name: copyName,
slug: copySlug,
craftState: sourceCraftState,
seo: source.seo ? { ...source.seo } : undefined,
};
setPages((prev) => {
const idx = prev.findIndex((p) => p.id === pageId);
if (idx === -1) return prev;
const next = [...prev];
next.splice(idx + 1, 0, copy);
return next;
});
// Switch the canvas to the new copy so the user lands on it, same as
// addPage switching to the freshly created page.
loadState(copy.craftState, EMPTY_CANVAS);
setActivePageId(copyId);
activePageIdRef.current = copyId;
},
[query, saveCurrentState, loadState],
);
/**
* Reorders `pageId` one slot up or down (swap with the adjacent page).
* Pure list-order change -- does not touch the live canvas. See the doc
* comment on `PageContextValue.movePage`.
*/
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
setPages((prev) => {
const idx = prev.findIndex((p) => p.id === pageId);
if (idx === -1) return prev;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
const next = [...prev];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
return applyLandingInvariant(next);
});
}, []);
/**
* Moves `pageId` to index 0, making it the new landing page. Pure list-
* order change -- does not touch the live canvas. See the doc comment on
* `PageContextValue.setLandingPage`.
*/
const setLandingPage = useCallback((pageId: string) => {
setPages((prev) => {
const idx = prev.findIndex((p) => p.id === pageId);
if (idx <= 0) return prev; // already the landing page, or not found
const next = [...prev];
const [moved] = next.splice(idx, 1);
next.unshift(moved);
return applyLandingInvariant(next);
});
}, []);
const renamePage = useCallback((pageId: string, name: string, slug: string) => { const renamePage = useCallback((pageId: string, name: string, slug: string) => {
setPages((prev) => setPages((prev) =>
prev.map((p, i) => { prev.map((p, i) => {
@@ -421,6 +597,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
); );
}, []); }, []);
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
const updatePageSeo = useCallback((pageId: string, seo: Partial<PageSeo>) => {
setPages((prev) =>
prev.map((p) => (p.id === pageId ? { ...p, seo: { ...p.seo, ...seo } } : p)),
);
}, []);
/** Allow external code (e.g., load from API) to set the header craft state */ /** Allow external code (e.g., load from API) to set the header craft state */
const setHeaderCraftState = useCallback((craftState: string) => { const setHeaderCraftState = useCallback((craftState: string) => {
setHeaderPage((prev) => ({ ...prev, craftState })); setHeaderPage((prev) => ({ ...prev, craftState }));
@@ -442,7 +625,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []); }, []);
/** Allow external code (e.g., load from API) to restore pages with craft states */ /** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => { const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
setPages(pagesData.map((p, i) => ({ setPages(pagesData.map((p, i) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
@@ -451,6 +634,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// ALWAYS the landing page → slug 'index' → file index.html. // ALWAYS the landing page → slug 'index' → file index.html.
slug: i === 0 ? 'index' : p.slug, slug: i === 0 ? 'index' : p.slug,
craftState: p.craftState, craftState: p.craftState,
seo: p.seo,
}))); })));
}, []); }, []);
@@ -545,6 +729,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
addPage, addPage,
deletePage, deletePage,
renamePage, renamePage,
duplicatePage,
movePage,
setLandingPage,
updatePageSeo,
setHeaderCraftState, setHeaderCraftState,
setFooterCraftState, setFooterCraftState,
setPagesCraftState, setPagesCraftState,
@@ -0,0 +1,118 @@
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 { PageProvider, usePages } from './PageContext';
/**
* PKG-H: `updatePageSeo(pageId, seo)` merges `seo` fields onto the target
* page's existing `seo` (creating it if absent), leaving every other page
* and every other field on the target page untouched -- mirrors
* `renamePage`'s existing merge-by-id pattern.
*/
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}' },
actions: { deserialize: vi.fn() },
}),
}));
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();
}
describe('PageContext.updatePageSeo', () => {
test('sets seo on a page that previously had none', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
expect(ctx!.pages[0].seo).toBeUndefined();
act(() => {
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'Hello World', noindex: true });
});
expect(ctx!.pages[0].seo).toEqual({ metaTitle: 'Hello World', noindex: true });
unmount();
});
test('merges new fields onto existing seo without clobbering untouched fields', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => {
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'First', metaDescription: 'Desc' });
});
act(() => {
ctx!.updatePageSeo(ctx!.pages[0].id, { metaTitle: 'Second' });
});
expect(ctx!.pages[0].seo).toEqual({ metaTitle: 'Second', metaDescription: 'Desc' });
unmount();
});
test('only touches the targeted page, leaving other pages untouched', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => {
ctx!.addPage('About', 'about');
});
const homeId = ctx!.pages[0].id;
const aboutId = ctx!.pages[1].id;
act(() => {
ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' });
});
expect(ctx!.pages.find((p) => p.id === aboutId)?.seo).toEqual({ metaTitle: 'About Us' });
expect(ctx!.pages.find((p) => p.id === homeId)?.seo).toBeUndefined();
unmount();
});
});
+4
View File
@@ -24,6 +24,9 @@ export interface SiteDesign {
// Site-wide custom code // Site-wide custom code
headCode: string; headCode: string;
/** Site-wide favicon URL (one per site, not per-page). Empty string = none. */
favicon: string;
} }
export interface SiteDesignContextValue { export interface SiteDesignContextValue {
@@ -52,6 +55,7 @@ export const DEFAULT_SITE_DESIGN: SiteDesign = {
buttonRadius: '8px', buttonRadius: '8px',
navStyle: 'light', navStyle: 'light',
headCode: '', headCode: '',
favicon: '',
}; };
const SiteDesignContext = createContext<SiteDesignContextValue>({ const SiteDesignContext = createContext<SiteDesignContextValue>({
+35
View File
@@ -106,6 +106,11 @@ body {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
z-index: 100; z-index: 100;
gap: 12px; gap: 12px;
/* Positioned ancestor for .publish-warnings (position: absolute; top:
100%), which is rendered as this <nav>'s first child in both the
desktop and mobile branches -- without this, it would anchor to the
viewport instead of sitting directly under the topbar. */
position: relative;
} }
.topbar-left, .topbar-left,
@@ -1884,3 +1889,33 @@ body {
height: 44px !important; height: 44px !important;
} }
} }
/* --------------------------------------------------------------------------
Publish warnings banner -- non-blocking; the site DID publish. Anchored
to .topbar's `position: relative` (see above) so it drops down directly
beneath the bar in both the desktop and mobile branches.
-------------------------------------------------------------------------- */
.publish-warnings {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: #422006;
border-bottom: 1px solid #a16207;
color: #fde68a;
font-size: 12px;
}
.publish-warnings ul { margin: 0; padding-left: 16px; flex: 1; }
.publish-warnings li { margin: 2px 0; }
.publish-warnings button {
background: none;
border: none;
color: #fde68a;
cursor: pointer;
padding: 0 4px;
}
@@ -3,7 +3,7 @@ import React from 'react';
import { renderEditorHarness, EditorHarness } from '../editorHarness'; import { renderEditorHarness, EditorHarness } from '../editorHarness';
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions'; import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
/** /**
* Real-`@craftjs/core` integration coverage for duplicate/paste. * Real-`@craftjs/core` integration coverage for duplicate/paste.
@@ -67,10 +67,39 @@ const INITIAL_STATE = JSON.stringify({
}, },
}); });
// A second, independent "page" state -- distinct node ids from INITIAL_STATE,
// simulating what PageContext.switchPage does: `query.serialize()` the
// current page, then `actions.deserialize()` the target page's stored
// state, replacing the ENTIRE node map. `social-1` (copied from page A)
// does not exist anywhere in this state.
const PAGE_B_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: ['page-b-heading-1'],
linkedNodes: {},
},
'page-b-heading-1': {
type: { resolvedName: 'Heading' },
isCanvas: false,
props: { text: 'Page B Heading', level: 'h2' },
displayName: 'Heading',
custom: {},
hidden: false,
parent: 'ROOT',
nodes: [],
linkedNodes: {},
},
});
let harness: EditorHarness | null = null; let harness: EditorHarness | null = null;
afterEach(() => { afterEach(() => {
setClipboardNodeId(null); setClipboardTree(null);
if (harness) { if (harness) {
harness.unmount(); harness.unmount();
harness = null; harness = null;
@@ -158,7 +187,7 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }), new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
); );
}); });
expect(getClipboardNodeId()).toBe('social-1'); expect(getClipboardTree()!.rootNodeId).toBe('social-1');
// Select heading-1 (a sibling), then paste -- should land as a sibling // Select heading-1 (a sibling), then paste -- should land as a sibling
// of heading-1's parent (ROOT), with brand-new ids. // of heading-1's parent (ROOT), with brand-new ids.
@@ -190,4 +219,76 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
expect(pastedProps.links).not.toBe(originalProps.links); expect(pastedProps.links).not.toBe(originalProps.links);
expect(pastedProps).toEqual(originalProps); expect(pastedProps).toEqual(originalProps);
}); });
test('CROSS-PAGE copy/paste: copy on page A, switch to page B, paste -- the node appears on page B', () => {
harness = renderEditorHarness({ initialState: INITIAL_STATE });
const Consumer: React.FC = () => {
useKeyboardShortcuts();
return null;
};
harness.mountChild(<Consumer />);
// --- Page A: select + copy social-1. ---
harness.act(() => {
harness!.actions.selectNode('social-1');
});
harness.act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
);
});
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
// --- Switch to page B: exactly what PageContext.switchPage does --
// serialize (discarded here, a real page switch would stash it) then
// deserialize the target page's state, replacing the ENTIRE node map.
// `social-1` no longer exists anywhere in `query` after this. ---
harness.act(() => {
harness!.actions.deserialize(PAGE_B_STATE);
});
expect(harness.query.getNodes()['social-1']).toBeUndefined();
// Select page B's only node, then paste.
harness.act(() => {
harness!.actions.selectNode('page-b-heading-1');
});
const beforePaste = Object.keys(harness.query.getNodes());
expect(() => {
harness!.act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }),
);
});
}).not.toThrow();
const afterPaste = Object.keys(harness.query.getNodes());
// The regression this guards against: with the old id-based clipboard,
// `query.node('social-1').get()` returns undefined once page B is
// loaded, so the paste handler's guard silently no-ops -- NOTHING gets
// added. With the tree-snapshot clipboard, the copied subtree is
// detached from any live query and pastes onto page B regardless.
expect(afterPaste.length).toBe(beforePaste.length + 1);
const pastedId = afterPaste.find((id) => !beforePaste.includes(id))!;
expect(pastedId).toBeDefined();
const pastedNode = harness.query.node(pastedId).get();
expect(pastedNode.data.displayName).toBe('Social Links');
expect(pastedNode.data.props.links).toEqual([
{ platform: 'facebook', url: 'https://facebook.com/original' },
]);
// Actually landed on page B's tree, as a sibling of the selected node.
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
expect(rootChildren).toContain(pastedId);
// Real DOM assertion: the pasted SocialLinks component is actually
// rendered on the (now page B) canvas.
expect(
harness.container.querySelectorAll('a[href="https://facebook.com/original"]'),
).toHaveLength(1);
});
}); });
+23
View File
@@ -11,11 +11,34 @@ export interface WhpConfig {
isRoot: boolean; isRoot: boolean;
} }
/**
* Per-page SEO/meta overrides (PKG-H contract §1). All fields optional --
* an absent `seo` (or absent individual field) means "no override, fall
* back to the page name / no tag emitted", preserving back-compat for
* every project saved before this field existed.
*/
export interface PageSeo {
/** Overrides <title>; when empty, <title> falls back to page.name. */
metaTitle?: string;
/** <meta name="description"> + og:description. */
metaDescription?: string;
/** og:title; when empty falls back to metaTitle || page.name. */
ogTitle?: string;
/** Absolute or site-relative image URL (via AssetPicker mediaType=image). */
ogImage?: string;
/** Default 'summary_large_image' when ogImage set, else 'summary'. */
twitterCard?: 'summary' | 'summary_large_image';
/** Emits <meta name="robots" content="noindex, nofollow">. */
noindex?: boolean;
}
export interface PageData { export interface PageData {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
craftState: string | null; craftState: string | null;
/** NEW -- optional; absent === no SEO overrides (back-compat). */
seo?: PageSeo;
} }
export interface AssetData { export interface AssetData {
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { CodeEditor } from './CodeEditor';
/* ---------- DOM test harness (no @testing-library/react in this repo; see
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
react-dom/client + react-dom/test-utils `act` pattern). ----------
CodeMirror is loaded via dynamic import() (see CodeEditor.tsx), which is
always async -- even for an already-resolved/cached module, `import()`
only settles on a later microtask. That means immediately after the
initial synchronous `act(() => root.render(...))` below, the component is
still in its 'loading' state and renders the <textarea> fallback. These
tests deliberately assert against that first-tick DOM (never awaiting
the CodeMirror promise), so they exercise exactly the fallback path a
real headless/offline environment would fall back to, deterministically
and without needing to mock @codemirror/*. */
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
});
function fallbackTextarea(): HTMLTextAreaElement {
const el = container.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
if (!el) throw new Error('fallback textarea not found');
return el;
}
describe('CodeEditor', () => {
test('shows the textarea fallback immediately (CodeMirror loads async)', () => {
render(<CodeEditor value="<p>hi</p>" onChange={vi.fn()} />);
const textarea = fallbackTextarea();
expect(textarea.value).toBe('<p>hi</p>');
});
test('the CodeMirror mount root is hidden while the fallback is showing', () => {
render(<CodeEditor value="" onChange={vi.fn()} />);
const cmRoot = container.querySelector<HTMLDivElement>('[data-testid="code-editor-cm-root"]');
expect(cmRoot?.style.display).toBe('none');
});
test('onChange fires with the new value when typing in the fallback textarea', () => {
const onChange = vi.fn();
render(<CodeEditor value="<p>hi</p>" onChange={onChange} />);
const textarea = fallbackTextarea();
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
setter.call(textarea, '<p>updated</p>');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(onChange).toHaveBeenCalledWith('<p>updated</p>');
});
test('accepts a language prop without throwing (html/css/javascript/auto)', () => {
for (const language of ['html', 'css', 'javascript', 'auto'] as const) {
expect(() => render(<CodeEditor value="" onChange={vi.fn()} language={language} />)).not.toThrow();
const textarea = fallbackTextarea();
expect(textarea.dataset.language).toBe(language);
act(() => { root.unmount(); });
container.remove();
}
});
test('defaults to html language when none is passed', () => {
render(<CodeEditor value="" onChange={vi.fn()} />);
expect(fallbackTextarea().dataset.language).toBe('html');
});
test('respects a custom height', () => {
render(<CodeEditor value="" onChange={vi.fn()} height={480} />);
expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
});
});
+257
View File
@@ -0,0 +1,257 @@
import React, { useEffect, useRef, useState } from 'react';
import type { EditorView as EditorViewType } from '@codemirror/view';
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
export interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
/** 'auto' behaves like 'html' -- the HTML language mode already highlights
* embedded <script>/<style> blocks, which covers the common "auto" case
* of mixed markup. */
language?: CodeEditorLanguage;
height?: number | string;
placeholder?: string;
}
/* ----------------------------------------------------------------
Lazy-loaded CodeMirror 6.
All @codemirror/* packages are pulled in via dynamic import() so they
land in their own chunk(s) (see vite.config.ts chunkFileNames) instead of
bloating the main editor.js bundle -- most sessions never open a code
editor. The module set is fetched once (module-level promise, shared
across every CodeEditor instance on the page) and cached forever.
While the import is in flight -- or if it ever fails (offline, CDN
hiccup, an environment where CodeMirror can't mount e.g. some headless
test runners) -- callers get a plain <textarea> so editing never breaks,
just loses syntax highlighting/autocomplete.
---------------------------------------------------------------- */
interface CmModules {
EditorState: typeof import('@codemirror/state').EditorState;
EditorView: typeof import('@codemirror/view').EditorView;
keymap: typeof import('@codemirror/view').keymap;
lineNumbers: typeof import('@codemirror/view').lineNumbers;
highlightActiveLine: typeof import('@codemirror/view').highlightActiveLine;
highlightActiveLineGutter: typeof import('@codemirror/view').highlightActiveLineGutter;
drawSelection: typeof import('@codemirror/view').drawSelection;
defaultKeymap: typeof import('@codemirror/commands').defaultKeymap;
history: typeof import('@codemirror/commands').history;
historyKeymap: typeof import('@codemirror/commands').historyKeymap;
indentWithTab: typeof import('@codemirror/commands').indentWithTab;
syntaxHighlighting: typeof import('@codemirror/language').syntaxHighlighting;
defaultHighlightStyle: typeof import('@codemirror/language').defaultHighlightStyle;
bracketMatching: typeof import('@codemirror/language').bracketMatching;
indentOnInput: typeof import('@codemirror/language').indentOnInput;
foldGutter: typeof import('@codemirror/language').foldGutter;
autocompletion: typeof import('@codemirror/autocomplete').autocompletion;
completionKeymap: typeof import('@codemirror/autocomplete').completionKeymap;
closeBrackets: typeof import('@codemirror/autocomplete').closeBrackets;
closeBracketsKeymap: typeof import('@codemirror/autocomplete').closeBracketsKeymap;
html: typeof import('@codemirror/lang-html').html;
css: typeof import('@codemirror/lang-css').css;
javascript: typeof import('@codemirror/lang-javascript').javascript;
oneDark: typeof import('@codemirror/theme-one-dark').oneDark;
}
let cmModulesPromise: Promise<CmModules> | null = null;
function loadCodeMirror(): Promise<CmModules> {
if (!cmModulesPromise) {
cmModulesPromise = Promise.all([
import('@codemirror/state'),
import('@codemirror/view'),
import('@codemirror/commands'),
import('@codemirror/language'),
import('@codemirror/autocomplete'),
import('@codemirror/lang-html'),
import('@codemirror/lang-css'),
import('@codemirror/lang-javascript'),
import('@codemirror/theme-one-dark'),
]).then(([state, view, commands, language, autocomplete, langHtml, langCss, langJs, theme]) => ({
EditorState: state.EditorState,
EditorView: view.EditorView,
keymap: view.keymap,
lineNumbers: view.lineNumbers,
highlightActiveLine: view.highlightActiveLine,
highlightActiveLineGutter: view.highlightActiveLineGutter,
drawSelection: view.drawSelection,
defaultKeymap: commands.defaultKeymap,
history: commands.history,
historyKeymap: commands.historyKeymap,
indentWithTab: commands.indentWithTab,
syntaxHighlighting: language.syntaxHighlighting,
defaultHighlightStyle: language.defaultHighlightStyle,
bracketMatching: language.bracketMatching,
indentOnInput: language.indentOnInput,
foldGutter: language.foldGutter,
autocompletion: autocomplete.autocompletion,
completionKeymap: autocomplete.completionKeymap,
closeBrackets: autocomplete.closeBrackets,
closeBracketsKeymap: autocomplete.closeBracketsKeymap,
html: langHtml.html,
css: langCss.css,
javascript: langJs.javascript,
oneDark: theme.oneDark,
}));
}
return cmModulesPromise;
}
function languageExtension(mods: CmModules, language: CodeEditorLanguage) {
switch (language) {
case 'css':
return mods.css();
case 'javascript':
return mods.javascript();
case 'html':
case 'auto':
default:
// lang-html already highlights + completes embedded <script>/<style>
// blocks as JS/CSS, which is exactly what "auto" wants for mixed
// HTML snippets (head code, HTML blocks).
return mods.html({ autoCloseTags: true });
}
}
const fallbackStyle: React.CSSProperties = {
width: '100%',
height: '100%',
padding: 14,
background: '#0d0d0f',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 8,
fontFamily: 'Source Code Pro, Consolas, monospace',
fontSize: 13,
lineHeight: 1.6,
resize: 'none',
outline: 'none',
tabSize: 2,
boxSizing: 'border-box',
};
export const CodeEditor: React.FC<CodeEditorProps> = ({
value,
onChange,
language = 'html',
height = 320,
placeholder,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorViewType | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Tracks the last value this component itself emitted, so the
// value-sync effect below doesn't stomp on in-progress typing when the
// parent re-renders with the exact same string it was just handed.
const lastEmittedRef = useRef(value);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
useEffect(() => {
let cancelled = false;
setStatus('loading');
loadCodeMirror()
.then((mods) => {
if (cancelled || !containerRef.current) return;
const updateListener = mods.EditorView.updateListener.of((update) => {
if (update.docChanged) {
const next = update.state.doc.toString();
lastEmittedRef.current = next;
onChangeRef.current(next);
}
});
const state = mods.EditorState.create({
doc: value,
extensions: [
mods.lineNumbers(),
mods.highlightActiveLineGutter(),
mods.highlightActiveLine(),
mods.history(),
mods.foldGutter(),
mods.drawSelection(),
mods.indentOnInput(),
mods.syntaxHighlighting(mods.defaultHighlightStyle, { fallback: true }),
mods.bracketMatching(),
mods.closeBrackets(),
mods.autocompletion(),
mods.keymap.of([
...mods.closeBracketsKeymap,
...mods.historyKeymap,
...mods.completionKeymap,
...mods.defaultKeymap,
mods.indentWithTab,
]),
languageExtension(mods, language),
mods.oneDark,
mods.EditorView.lineWrapping,
updateListener,
],
});
const view = new mods.EditorView({ state, parent: containerRef.current });
viewRef.current = view;
setStatus('ready');
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('CodeEditor: CodeMirror failed to load, falling back to a plain textarea', err);
if (!cancelled) setStatus('error');
});
return () => {
cancelled = true;
viewRef.current?.destroy();
viewRef.current = null;
};
// Re-mount on language change (language is fixed for HTML/CSS/JS
// targets in this app -- it never flips on an already-open editor --
// but re-creating cleanly if it ever does is simpler/safer than trying
// to reconfigure the LanguageSupport extension in place).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [language]);
// Keep the live CodeMirror doc in sync if `value` changes from outside
// (e.g. the modal is reused for a different field) without clobbering
// the cursor position/selection on every keystroke-driven re-render.
useEffect(() => {
const view = viewRef.current;
if (!view || status !== 'ready') return;
if (value === lastEmittedRef.current) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
lastEmittedRef.current = value;
}, [value, status]);
const showFallback = status !== 'ready';
return (
<div style={{ position: 'relative', height, minHeight: height }}>
<div
ref={containerRef}
data-testid="code-editor-cm-root"
style={{
height: '100%',
overflow: 'auto',
borderRadius: 8,
border: '1px solid #3f3f46',
display: showFallback ? 'none' : 'block',
}}
/>
{showFallback && (
<textarea
data-testid="code-editor-fallback"
data-language={language}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={status === 'loading' ? (placeholder || 'Loading editor…') : placeholder}
spellCheck={false}
style={fallbackStyle}
/>
)}
</div>
);
};
+447 -1
View File
@@ -1,5 +1,6 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { exportBodyHtml } from './html-export'; import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
/** /**
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated * C2: buildDataAttrs (internal to html-export.ts) previously interpolated
@@ -97,3 +98,448 @@ describe('renderNode div-fallback allowlists props.tag', () => {
expect(html).toContain('</section>'); expect(html).toContain('</section>');
}); });
}); });
/**
* PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
* variables emitted into `wrapInDocument`'s <head> (exercised via
* `exportToHtml`, since `exportBodyHtml` intentionally stays body-only --
* contract §7). These assert the exact "only emit when set" / sanitization
* / ordering rules the backend implementer's `generateCompiledHTML` must
* mirror byte-for-byte for editor Preview === published output.
*/
describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
const EMPTY_ROOT_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
function exportWith(options: ExportOptions): string {
return exportToHtml(EMPTY_ROOT_STATE, options).html;
}
describe('back-compat: no seo, no favicon, no design passed', () => {
const html = exportWith({ title: 'Legacy Page' });
test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => {
expect(html).toContain('<title>Legacy Page</title>');
expect(html).not.toContain('name="robots"');
expect(html).not.toContain('name="description"');
expect(html).not.toContain('property="og:description"');
expect(html).not.toContain('property="og:image"');
expect(html).not.toContain('rel="icon"');
// og:title still always emits (falls back to title) -- og:type is a
// fixed tag too -- neither depends on seo being set.
expect(html).toContain('<meta property="og:title" content="Legacy Page">');
expect(html).toContain('<meta property="og:type" content="website">');
// twitter:card only emits when ogImage or description is set -- neither
// is here, so it must be absent.
expect(html).not.toContain('name="twitter:card"');
});
test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => {
expect(html).toContain(':root{--wsb-primary:');
expect(html).toContain('--wsb-button-radius:');
expect(html).toContain('body{font-family:var(--wsb-body-font)');
expect(html).toContain('a:focus-visible');
expect(html).toContain('prefers-reduced-motion:reduce');
});
});
describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => {
test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => {
const html = exportWith({ title: 'Page', description: 'A great page.' });
expect(html).toContain('<meta name="description" content="A great page.">');
expect(html).toContain('<meta property="og:description" content="A great page.">');
expect(html).toContain('<meta name="twitter:card" content="summary">');
});
test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => {
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' });
expect(html).toContain('<meta property="og:image" content="/uploads/hero.jpg">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).not.toContain('name="description"');
});
test('explicit twitterCard overrides the derived default', () => {
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' });
expect(html).toContain('<meta name="twitter:card" content="summary">');
});
test('ogTitle set -> og:title uses it instead of the page title', () => {
const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' });
expect(html).toContain('<meta property="og:title" content="Custom Share Title">');
expect(html).not.toContain('<meta property="og:title" content="Page Title">');
});
test('noindex true -> robots meta appears; false/absent -> it does not', () => {
expect(exportWith({ title: 'Page', noindex: true })).toContain('<meta name="robots" content="noindex, nofollow">');
expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"');
expect(exportWith({ title: 'Page' })).not.toContain('name="robots"');
});
test('favicon set -> icon link appears with the exact URL', () => {
const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' });
expect(html).toContain('<link rel="icon" href="/uploads/favicon.png">');
});
test('favicon absent -> no icon link', () => {
expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"');
});
});
describe('sanitization neutralizes malicious values', () => {
test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => {
const html = exportWith({
title: 'Page',
ogImage: 'javascript:alert(1)',
favicon: 'javascript:alert(1)',
description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty
});
expect(html).not.toContain('javascript:alert');
expect(html).not.toContain('property="og:image"');
expect(html).not.toContain('rel="icon"');
// og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'.
expect(html).toContain('<meta name="twitter:card" content="summary">');
});
test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => {
const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' });
expect(html).toContain('<meta property="og:image" content="data:image/png;base64,AAAA">');
});
test('an attribute-breakout metaDescription is escaped, not left raw', () => {
const payload = '"><script>alert(1)</script>';
const html = exportWith({ title: 'Page', description: payload });
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;');
});
test('a malicious design-token value cannot break out of the :root{} block', () => {
const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '</style><script>alert(1)</script>' };
const html = exportWith({ title: 'Page', design });
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('</style><script>');
});
});
describe(':root token block', () => {
test('emits all 16 contract-named CSS variables with sanitized values, in order', () => {
const design: SiteDesign = {
...DEFAULT_SITE_DESIGN,
primaryColor: '#111111',
secondaryColor: '#222222',
accentColor: '#333333',
linkColor: '#444444',
successColor: '#555555',
warningColor: '#666666',
errorColor: '#777777',
backgroundColor: '#888888',
textColor: '#999999',
mutedTextColor: '#aaaaaa',
borderColor: '#bbbbbb',
borderRadius: '12px',
headingFont: 'Georgia, serif',
bodyFont: 'Verdana, sans-serif',
buttonFont: 'Tahoma, sans-serif',
buttonRadius: '4px',
};
const html = exportWith({ title: 'Page', design });
const rootMatch = html.match(/:root\{([^}]*)\}/);
expect(rootMatch).not.toBeNull();
const rootBlock = rootMatch![1];
const expectedOrder = [
['--wsb-primary', '#111111'],
['--wsb-secondary', '#222222'],
['--wsb-accent', '#333333'],
['--wsb-link', '#444444'],
['--wsb-success', '#555555'],
['--wsb-warning', '#666666'],
['--wsb-error', '#777777'],
['--wsb-bg', '#888888'],
['--wsb-text', '#999999'],
['--wsb-muted', '#aaaaaa'],
['--wsb-border', '#bbbbbb'],
['--wsb-radius', '12px'],
['--wsb-heading-font', 'Georgia, serif'],
['--wsb-body-font', 'Verdana, sans-serif'],
['--wsb-button-font', 'Tahoma, sans-serif'],
['--wsb-button-radius', '4px'],
];
let lastIndex = -1;
for (const [varName, value] of expectedOrder) {
const decl = `${varName}:${value}`;
expect(rootBlock).toContain(decl);
const idx = rootBlock.indexOf(decl);
expect(idx).toBeGreaterThan(lastIndex);
lastIndex = idx;
}
});
});
describe('a11y/perf CSS', () => {
test('focus-visible outline rule is present', () => {
const html = exportWith({ title: 'Page' });
expect(html).toContain('a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}');
});
test('prefers-reduced-motion rule is present', () => {
const html = exportWith({ title: 'Page' });
expect(html).toContain('@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}');
});
});
describe('Google Fonts link', () => {
test('always includes display=swap, with no design passed (fallback full link)', () => {
const html = exportWith({ title: 'Page', includeFonts: true });
expect(html).toContain('display=swap');
expect(html).toContain('fonts.googleapis.com');
});
test('subsets to only the used families when all 3 design fonts are known presets', () => {
const design: SiteDesign = {
...DEFAULT_SITE_DESIGN,
headingFont: 'Playfair Display, serif',
bodyFont: 'Inter, sans-serif',
buttonFont: 'Inter, sans-serif',
};
const html = exportWith({ title: 'Page', includeFonts: true, design });
expect(html).toContain('display=swap');
expect(html).toContain('family=Playfair+Display');
expect(html).toContain('family=Inter');
// Not one of the 3 used fonts -- subset link must not pull it in.
expect(html).not.toContain('family=Merriweather');
});
test('falls back to the full multi-font link when a design font is not a known preset', () => {
const design: SiteDesign = {
...DEFAULT_SITE_DESIGN,
headingFont: 'Comic Sans MS, cursive',
};
const html = exportWith({ title: 'Page', includeFonts: true, design });
expect(html).toContain('display=swap');
// Full fallback link carries every preset family, including ones the
// design object doesn't reference -- a component using ANY preset
// never silently loses its font.
expect(html).toContain('family=Merriweather');
});
});
});
/**
* FIX: entrance-animation broken in Preview. Root causes (see
* .superpowers/sdd/fix-animation-contract.md):
* 1. injectAttrs inserted data-attrs before the tag's first `>`, so a void
* tag (`<img ... />`) became malformed (`<img ... / data-animation="...">`,
* attrs landing AFTER the self-close slash, outside the tag).
* 2. wrapInDocument's in-body reveal <script> was destroyed by TopBar's
* handlePreview, which replaces the whole <body> inner with a
* recomposed header+body+footer string that never carried the script.
*/
describe('injectAttrs well-formed void-tag attrs (animation fix)', () => {
// ImageBlock.toHtml renders `<img src="..." ... />` -- a real void-tag
// producer that goes through injectAttrs via renderNode.
const imageState = (props: Record<string, unknown>) =>
JSON.stringify({
ROOT: {
type: { resolvedName: 'ImageBlock' },
isCanvas: false,
props: { src: '/uploads/photo.jpg', style: {}, ...props },
displayName: 'ImageBlock',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
test('void <img/> gets attrs INSIDE the tag, no " / " sequence before the final >', () => {
const { html } = exportBodyHtml(imageState({ animation: 'bounce' }));
expect(html).toContain('data-animation="bounce"');
// Well-formed: the attribute sits before the self-close slash.
expect(html).toMatch(/data-animation="bounce"\s*\/>/);
// Malformed shape from the bug: attrs landing after the slash.
expect(html).not.toMatch(/\/\s*data-animation="bounce"/);
expect(html).not.toContain('/ data-animation');
});
test('non-void tag (Container div) is unaffected -- attrs still inserted before its only >', () => {
const state = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div', style: {}, animation: 'fade-in' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
const { html } = exportBodyHtml(state);
expect(html).toMatch(/^<div[^>]*data-animation="fade-in"[^>]*>/);
expect(html).not.toContain('/>');
});
});
describe('buildAnimationScript (animation fix)', () => {
test('returns the IntersectionObserver reveal script when body contains data-animation', () => {
const body = '<div data-animation="fade-in">Hi</div>';
const script = buildAnimationScript(body);
expect(script).toContain('<script>');
expect(script).toContain('IntersectionObserver');
expect(script).toContain("querySelectorAll('[data-animation]')");
});
test('returns empty string when body has no data-animation', () => {
expect(buildAnimationScript('<div>Hi</div>')).toBe('');
});
test('reveal script coerces a bare-number delay to a valid CSS time (e.g. "2" -> "2s")', () => {
// animationDelay is stored as a plain seconds string ("2"); assigning that raw
// to el.style.animationDelay is invalid CSS and no-ops. The script must suffix a
// unit onto bare numbers while leaving unit-bearing values ("2s"/"200ms") alone.
const script = buildAnimationScript('<div data-animation="fade-in" data-animation-delay="2">Hi</div>');
expect(script).toContain("/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay");
// guard against regressing to the raw (invalid) assignment
expect(script).not.toContain('animationDelay = delay;');
// sanity-check the coercion logic itself against representative inputs
const coerce = (delay: string) => (/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay);
expect(coerce('2')).toBe('2s');
expect(coerce('0.5')).toBe('0.5s');
expect(coerce('2s')).toBe('2s');
expect(coerce('200ms')).toBe('200ms');
});
});
describe('Preview body-replacement keeps exactly one reveal script (animation fix)', () => {
const animatedState = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div', style: {}, animation: 'fade-in' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
test('wrapped doc alone already contains exactly one script + the CSS + the noscript fallback', () => {
const { html } = exportToHtml(animatedState, { title: 'Page' });
const scriptCount = (html.match(/IntersectionObserver/g) || []).length;
expect(scriptCount).toBe(1);
expect(html).toContain('[data-animation]{opacity:0}');
expect(html).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
});
test('simulated handlePreview body-replacement: composed body built WITH buildAnimationScript still yields exactly one reveal script and the head CSS survives', () => {
// Mirror TopBar.tsx handlePreview: exportToHtml gives the wrapped doc
// (head CSS/noscript + its own in-body script); a "composedBody" of
// header+body+footer (no script of its own) is what actually replaces
// the <body> inner. Without appending buildAnimationScript to
// composedBody, the wrapped doc's script would be clobbered and the
// element would never reveal.
const { html: wrapped } = exportToHtml(animatedState, { title: 'Page' });
const headerHtml = '';
const { html: bodyHtml } = exportBodyHtml(animatedState);
const footerHtml = '';
const composedBody =
headerHtml + bodyHtml + footerHtml +
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
const bodyMatch = wrapped.match(/<body[^>]*>([\s\S]*)<\/body>/i);
expect(bodyMatch).toBeTruthy();
const finalHtml = wrapped.replace(bodyMatch![1], () => composedBody);
const scriptCount = (finalHtml.match(/IntersectionObserver/g) || []).length;
expect(scriptCount).toBe(1);
expect(finalHtml).toContain('[data-animation]{opacity:0}');
expect(finalHtml).toContain('data-animation="fade-in"');
// No malformed void-tag artifact should leak into the final assembly.
expect(finalHtml).not.toContain('/ data-animation');
});
test('non-animated body: no animation CSS, no noscript, no reveal script anywhere', () => {
const plainState = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div', style: {} },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
const { html } = exportToHtml(plainState, { title: 'Page' });
expect(html).not.toContain('[data-animation]');
expect(html).not.toContain('<noscript>');
expect(html).not.toContain('IntersectionObserver');
expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
});
});
/**
* FIX: bounce entrance-animation disappears after finishing + reads like a
* fade (see .superpowers/sdd/fix-anim-image-contract.md, section A). Root
* cause: the old `@keyframes bounce` set opacity at 0% and 60% but NOT at
* 100% -- with `animation-fill-mode: both`, on finish the element reverted
* to the base `[data-animation]{opacity:0}` rule and vanished. The fix is a
* springier keyframe that ends at `opacity:1`.
*/
describe('bounce keyframe ends at opacity:1 (fix-anim-image A)', () => {
const animatedState = (animation: string) => JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div', style: {}, animation },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
const NEW_BOUNCE_MINIFIED = '@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}';
const OLD_BOUNCE_TAIL = '100%{transform:translateY(0)}}';
test('minified export contains the new springier bounce substring, byte-identical to the shared contract', () => {
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
expect(html).toContain(NEW_BOUNCE_MINIFIED);
});
test('minified export does NOT contain the old bounce tail (100% with no opacity)', () => {
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
expect(html).not.toContain(OLD_BOUNCE_TAIL);
// Every keyframe's 100% frame in this doc must carry opacity:1 now.
expect(html).toContain('100%{opacity:1;transform:translateY(0)}}');
});
test('pretty (non-minified) export ends the bounce keyframe at 100% { opacity: 1; transform: translateY(0); }', () => {
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page', minifyCss: false });
const NEW_BOUNCE_PRETTY = '@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }';
expect(html).toContain(NEW_BOUNCE_PRETTY);
expect(html).not.toContain('100% { transform: translateY(0); } }');
});
test('other keyframes (fadeIn/slideUp/zoomIn) are unchanged', () => {
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
expect(html).toContain('@keyframes fadeIn{from{opacity:0}to{opacity:1}}');
expect(html).toContain('@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}');
expect(html).toContain('@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}');
});
});
+214 -9
View File
@@ -1,13 +1,30 @@
import { componentResolver } from '../components/resolver'; import { componentResolver } from '../components/resolver';
import { cssPropsToString } from './style-helpers'; import { cssPropsToString } from './style-helpers';
import { escapeHtml, escapeAttr } from './escape'; import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
import { sanitizeContainerTag } from '../components/layout/Container'; import { sanitizeContainerTag } from '../components/layout/Container';
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
export interface ExportOptions { export interface ExportOptions {
title?: string; title?: string;
includeFonts?: boolean; includeFonts?: boolean;
minifyCss?: boolean; minifyCss?: boolean;
headCode?: string; headCode?: string;
/** PageSeo.metaDescription -- also feeds og:description when set. */
description?: string;
/** PageSeo.ogTitle -- falls back to `title` (which itself already folds in metaTitle) when empty. */
ogTitle?: string;
/** PageSeo.ogImage -- absolute or site-relative image URL. */
ogImage?: string;
/** PageSeo.twitterCard -- default 'summary_large_image' when ogImage set, else 'summary'. */
twitterCard?: 'summary' | 'summary_large_image';
/** PageSeo.noindex -- emits <meta name="robots" content="noindex, nofollow">. */
noindex?: boolean;
/** SiteDesign.favicon -- site-wide, one per site. */
favicon?: string;
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
design?: SiteDesign;
} }
interface ResolverMap { interface ResolverMap {
@@ -35,12 +52,29 @@ function buildDataAttrs(props: Record<string, any>): string {
/** /**
* Inject data attributes into the first HTML opening tag of a rendered string. * Inject data attributes into the first HTML opening tag of a rendered string.
*
* For a void/self-closing tag (e.g. `<img src="x" />`) the first `>` is
* preceded by a `/` -- naively inserting before the `>` produces the
* malformed `<img ... / data-animation="...">` (attrs land AFTER the
* self-close slash, outside the tag). Detect that trailing `/` and insert
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
* Non-void tags (no trailing `/`) are unaffected.
*/ */
function injectAttrs(html: string, attrs: string): string { function injectAttrs(html: string, attrs: string): string {
if (!attrs) return html; if (!attrs) return html;
// Find the first > of the opening tag and inject before it // Find the first > of the opening tag and inject before it
const idx = html.indexOf('>'); const idx = html.indexOf('>');
if (idx === -1) return html; if (idx === -1) return html;
if (idx > 0 && html[idx - 1] === '/') {
// Void/self-closing tag (`<img ... />`): inserting before `>` would land
// the attrs after the `/`, outside the tag (`<img ... / data-x="y">`).
// Insert before the `/` instead -- also trim any whitespace directly
// preceding it so we don't end up with a double space, since `attrs`
// already carries its own leading space(s).
let contentEnd = idx - 1;
while (contentEnd > 0 && /\s/.test(html[contentEnd - 1])) contentEnd--;
return html.slice(0, contentEnd) + attrs + html.slice(idx - 1);
}
return html.slice(0, idx) + attrs + html.slice(idx); return html.slice(0, idx) + attrs + html.slice(idx);
} }
@@ -138,6 +172,149 @@ const GOOGLE_FONTS_LINK = `<link rel="preconnect" href="https://fonts.googleapis
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`; <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">`;
// Google Fonts family= params for each of the 8 presets in constants/presets.ts
// FONT_FAMILIES, keyed by the CSS font-family name (the part before the first
// comma in e.g. 'Playfair Display, serif'). Used to build a best-effort
// SUBSET fonts link (perf: §3) when every design font resolves to a known
// preset; otherwise buildFontsLink() falls back to the full GOOGLE_FONTS_LINK
// above (which already covers all 8 and already has &display=swap) so a
// component using a preset outside the design's 3 font fields never loses
// its font.
const GOOGLE_FONT_PARAMS: Record<string, string> = {
'Inter': 'family=Inter:wght@300;400;500;600;700',
'Roboto': 'family=Roboto:wght@300;400;500;700',
'Open Sans': 'family=Open+Sans:wght@300;400;600;700',
'Poppins': 'family=Poppins:wght@300;400;500;600;700',
'Montserrat': 'family=Montserrat:wght@300;400;500;600;700',
'Playfair Display': 'family=Playfair+Display:wght@400;600;700',
'Merriweather': 'family=Merriweather:wght@300;400;700',
'Source Code Pro': 'family=Source+Code+Pro:wght@400;500;600',
};
/** Extracts the CSS font-family name from a value like 'Inter, sans-serif' -> 'Inter'. */
function extractFontName(value: string | undefined): string {
return (value || '').split(',')[0].trim();
}
/**
* Best-effort Google Fonts link (contract §3): tries to build a SUBSET link
* containing only the design's heading/body/button fonts (+ display=swap).
* Falls back to the full multi-font GOOGLE_FONTS_LINK -- which still has
* display=swap -- whenever any of those three fonts isn't one of the 8 known
* presets, so a font a component actually uses is never silently dropped.
*/
export function buildFontsLink(design?: SiteDesign): string {
if (!design) return GOOGLE_FONTS_LINK;
const families = [design.headingFont, design.bodyFont, design.buttonFont]
.map(extractFontName)
.filter((f, i, arr) => f !== '' && arr.indexOf(f) === i);
if (families.length === 0) return GOOGLE_FONTS_LINK;
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]).filter((p): p is string => !!p);
if (params.length !== families.length) return GOOGLE_FONTS_LINK;
return `<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?${params.join('&')}&display=swap" rel="stylesheet">`;
}
// ---------- Design-token CSS-variable block (contract §2) ----------
// Ordered [cssVarName, SiteDesign key] pairs -- exact order/names from the
// PKG-H shared contract §2. Both the frontend (here) and the backend
// (site-builder.php) emit this same 16-line block for the same data so
// editor Preview === published output.
const TOKEN_VARS: Array<[string, keyof SiteDesign]> = [
['--wsb-primary', 'primaryColor'],
['--wsb-secondary', 'secondaryColor'],
['--wsb-accent', 'accentColor'],
['--wsb-link', 'linkColor'],
['--wsb-success', 'successColor'],
['--wsb-warning', 'warningColor'],
['--wsb-error', 'errorColor'],
['--wsb-bg', 'backgroundColor'],
['--wsb-text', 'textColor'],
['--wsb-muted', 'mutedTextColor'],
['--wsb-border', 'borderColor'],
['--wsb-radius', 'borderRadius'],
['--wsb-heading-font', 'headingFont'],
['--wsb-body-font', 'bodyFont'],
['--wsb-button-font', 'buttonFont'],
['--wsb-button-radius', 'buttonRadius'],
];
// Fixed token-base CSS (contract §2) -- makes the tokens actually take effect
// globally; component inline styles still override via specificity.
const TOKEN_BASE_CSS = `body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}`;
// Fixed a11y/perf CSS (contract §3), emitted for every published/preview page.
const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}`;
/**
* Builds the `:root{...}` design-token block + the fixed token-base CSS +
* the fixed a11y/perf CSS (contract §2/§3). Every token value is sanitized
* through `cssValue()` before interpolation -- it sits raw inside a
* `:root{ }` CSS-element context (not a string-quoted context), so a
* malicious value could otherwise break out of the block / the `<style>`
* element itself.
*/
export function buildTokenCss(design: SiteDesign): string {
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
}
// ---------- <head> SEO/meta block (contract §4) ----------
/**
* Builds the ordered SEO/meta tag block from `<meta name="robots">` (only
* when noindex) through the favicon `<link>` -- everything between `<title>`
* and the Google Fonts link in the contract §4 order. `title` must already
* be the FULLY RESOLVED title (i.e. `seo.metaTitle || page.name`) -- the
* og:title fallback chain in the contract (`ogTitle || metaTitle || TITLE`)
* collapses to `ogTitle || title` once `title` itself already folds in the
* metaTitle fallback, so no separate metaTitle parameter is needed here.
* Each optional tag is emitted ONLY when its source value is non-empty --
* no empty `content=""` tags ever ship.
*/
export function buildSeoMeta(options: ExportOptions, title: string): string {
const lines: string[] = [];
if (options.noindex) {
lines.push('<meta name="robots" content="noindex, nofollow">');
}
lines.push(`<title>${escapeHtml(title)}</title>`);
const description = options.description || '';
if (description) {
lines.push(`<meta name="description" content="${escapeAttr(description)}">`);
}
const ogTitle = options.ogTitle || title;
lines.push(`<meta property="og:title" content="${escapeAttr(ogTitle)}">`);
if (description) {
lines.push(`<meta property="og:description" content="${escapeAttr(description)}">`);
}
const ogImage = options.ogImage ? safeImageUrl(options.ogImage) : '';
if (ogImage) {
lines.push(`<meta property="og:image" content="${escapeAttr(ogImage)}">`);
}
lines.push('<meta property="og:type" content="website">');
if (ogImage || description) {
const card = options.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
lines.push(`<meta name="twitter:card" content="${escapeAttr(card)}">`);
}
const favicon = options.favicon ? safeImageUrl(options.favicon) : '';
if (favicon) {
lines.push(`<link rel="icon" href="${escapeAttr(favicon)}">`);
}
return lines.join('\n ');
}
const RESPONSIVE_CSS = ` const RESPONSIVE_CSS = `
@media (max-width: 768px) { @media (max-width: 768px) {
[style*="display: flex"][style*="flex-direction: row"], [style*="display: flex"][style*="flex-direction: row"],
@@ -161,7 +338,7 @@ const ANIMATION_CSS = `
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } } @keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } } @keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } @keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } } @keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }
[data-animation] { opacity: 0; } [data-animation] { opacity: 0; }
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; } [data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
@@ -172,18 +349,34 @@ const ANIMATION_CSS = `
[data-animation="zoom-in"].animated { animation-name: zoomIn; } [data-animation="zoom-in"].animated { animation-name: zoomIn; }
[data-animation="bounce"].animated { animation-name: bounce; }`; [data-animation="bounce"].animated { animation-name: bounce; }`;
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`; const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
const ANIMATION_SCRIPT = `<script> const ANIMATION_SCRIPT = `<script>
document.querySelectorAll('[data-animation]').forEach(function(el) { document.querySelectorAll('[data-animation]').forEach(function(el) {
var delay = el.getAttribute('data-animation-delay'); var delay = el.getAttribute('data-animation-delay');
if (delay) el.style.animationDelay = delay; if (delay) el.style.animationDelay = /^-?[0-9.]+$/.test(delay) ? delay + 's' : delay;
new IntersectionObserver(function(entries) { new IntersectionObserver(function(entries) {
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } }); entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
}, { threshold: 0.1 }).observe(el); }, { threshold: 0.1 }).observe(el);
}); });
</script>`; </script>`;
// No-JS safety net (contract "No-JS safety"): un-hides animated elements
// when JS is disabled, so `[data-animation]{opacity:0}` never permanently
// hides content that the reveal script would otherwise never run for.
const ANIMATION_NOSCRIPT = `<noscript><style>[data-animation]{opacity:1}</style></noscript>`;
/**
* Returns the reveal `<script>` (byte-identical to the shared contract, and
* to the backend's `generateCompiledHTML` emission) when `bodyHtml` contains
* an animated element, else `''`. Single source of the script string so
* every caller (wrapInDocument's in-body emission, and TopBar's Preview
* body-replacement) stays in sync.
*/
export function buildAnimationScript(bodyHtml: string): string {
return bodyHtml.includes('data-animation') ? ANIMATION_SCRIPT : '';
}
function wrapInDocument(bodyHtml: string, options: ExportOptions): string { function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
const title = options.title || 'Untitled Page'; const title = options.title || 'Untitled Page';
const minify = options.minifyCss !== false; const minify = options.minifyCss !== false;
@@ -191,21 +384,33 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS; const responsive = minify ? RESPONSIVE_CSS_MINIFIED : RESPONSIVE_CSS;
const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS; const visibility = minify ? VISIBILITY_CSS_MINIFIED : VISIBILITY_CSS;
const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS; const animation = minify ? ANIMATION_CSS_MINIFIED : ANIMATION_CSS;
const fonts = options.includeFonts !== false ? `\n ${GOOGLE_FONTS_LINK}` : ''; // §7 back-compat: even a caller that doesn't pass `design` (legacy call
// sites, e.g. tests not yet updated) still gets the token/base/a11y CSS --
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
// correct stand-in rather than skipping the block entirely.
const design = options.design || DEFAULT_SITE_DESIGN;
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design)}` : '';
const headCode = options.headCode ? `\n ${options.headCode}` : ''; const headCode = options.headCode ? `\n ${options.headCode}` : '';
const seoMeta = buildSeoMeta(options, title);
const tokenCss = buildTokenCss(design);
// Only include animation CSS + script if body contains data-animation // Only include animation CSS + noscript fallback + script if body contains
// data-animation (contract gate). `buildAnimationScript` is the single
// source of the reveal-script string -- TopBar's Preview body-replacement
// uses the same helper so the two emissions never drift apart.
const hasAnimations = bodyHtml.includes('data-animation'); const hasAnimations = bodyHtml.includes('data-animation');
const animationBlock = hasAnimations ? animation : ''; const animationBlock = hasAnimations ? animation : '';
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : ''; const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
const revealScript = buildAnimationScript(bodyHtml);
const animationScript = revealScript ? `\n${revealScript}` : '';
return `<!DOCTYPE html> return `<!DOCTYPE html>
<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">
<title>${escapeHtml(title)}</title>${fonts} ${seoMeta}${fonts}
<style>${reset}${responsive}${visibility}${animationBlock}</style>${headCode} <style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
</head> </head>
<body> <body>
${bodyHtml}${animationScript} ${bodyHtml}${animationScript}