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>
This commit is contained in:
@@ -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);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{/* Image source */}
|
||||
@@ -54,18 +67,24 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
value={(style.maxWidth as string) || ''}
|
||||
onChange={(v) => setPropStyle('maxWidth', v)}
|
||||
/>
|
||||
<SizeControl
|
||||
label="Height"
|
||||
value={(style.height as string) || ''}
|
||||
onChange={(v) => setPropStyle('height', 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={(v) => setPropStyle('aspectRatio', v)}
|
||||
onChange={applyAspectRatio}
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Object Fit</SectionLabel>
|
||||
|
||||
@@ -37,6 +37,18 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
|
||||
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 (
|
||||
<>
|
||||
{/* 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) || ''}
|
||||
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={(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('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) {
|
||||
|
||||
@@ -491,3 +491,55 @@ describe('Preview body-replacement keeps exactly one reveal script (animation fi
|
||||
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)}}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,7 +338,7 @@ const ANIMATION_CSS = `
|
||||
@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(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].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
||||
@@ -349,7 +349,7 @@ const ANIMATION_CSS = `
|
||||
[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(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>
|
||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||
|
||||
Reference in New Issue
Block a user