Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c44dd545c | ||
|
|
2ac62c4e9e | ||
|
|
25dfcbb725 | ||
|
|
9bf78fd72d | ||
|
|
2dcc2b4d21 | ||
|
|
4e0fc78a30 |
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,19 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
|
// Applying an aspect-ratio crop should FILL the frame by default (object-fit:
|
||||||
|
// cover) rather than leave letterboxed empty bands, and width + ratio + cover
|
||||||
|
// fully determine the box -- so a stale `height` can't linger and conflict
|
||||||
|
// (kills the %-height no-op that made resize look like it wasn't working).
|
||||||
|
// 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 (
|
||||||
<>
|
<>
|
||||||
{/* Image source */}
|
{/* Image source */}
|
||||||
@@ -54,18 +67,24 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.maxWidth as string) || ''}
|
value={(style.maxWidth as string) || ''}
|
||||||
onChange={(v) => setPropStyle('maxWidth', v)}
|
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
|
<SizeControl
|
||||||
label="Height"
|
label="Height"
|
||||||
value={(style.height as string) || ''}
|
value={(style.height as string) || ''}
|
||||||
onChange={(v) => setPropStyle('height', v)}
|
onChange={(v) => setPropStyle('height', v)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
||||||
<img> itself is a CSS framing crop (no server-side image processing
|
<img> itself is a CSS framing crop (no server-side image processing
|
||||||
needed). */}
|
needed). */}
|
||||||
<AspectRatioControl
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Object Fit</SectionLabel>
|
<SectionLabel>Object Fit</SectionLabel>
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ 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 source -- upload/browse/paste-URL (paste-URL still handles
|
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
||||||
@@ -63,9 +75,14 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.width as string) || ''}
|
value={(style.width as string) || ''}
|
||||||
onChange={(v) => setPropStyle('width', v)}
|
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
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,6 +121,58 @@ describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated o
|
|||||||
expect(q('size-control')).toBeNull();
|
expect(q('size-control')).toBeNull();
|
||||||
expect(q('aspect-ratio-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) {
|
function openCollapsibleByTitle(title: string) {
|
||||||
|
|||||||
@@ -133,7 +133,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,8 +154,18 @@ 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
|
// PKG-H: fold the active page's own SEO overrides + the site-wide
|
||||||
// design tokens/favicon into the Preview export so editor Preview
|
// design tokens/favicon into the Preview export so editor Preview
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { exportBodyHtml, exportToHtml, ExportOptions } from './html-export';
|
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
|
||||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -335,3 +335,211 @@ describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -52,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,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; }
|
||||||
@@ -332,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;
|
||||||
@@ -361,10 +394,15 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
|||||||
const seoMeta = buildSeoMeta(options, title);
|
const seoMeta = buildSeoMeta(options, title);
|
||||||
const tokenCss = buildTokenCss(design);
|
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">
|
||||||
@@ -372,7 +410,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
|||||||
<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">
|
||||||
${seoMeta}${fonts}
|
${seoMeta}${fonts}
|
||||||
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${headCode}
|
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${bodyHtml}${animationScript}
|
${bodyHtml}${animationScript}
|
||||||
|
|||||||
Reference in New Issue
Block a user