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:
2026-07-14 16:04:50 -07:00
co-authored by Claude Opus 4.8
parent 2ac62c4e9e
commit 5c44dd545c
6 changed files with 282 additions and 9 deletions
+52
View File
@@ -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)}}');
});
});
+2 -2
View File
@@ -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) {