fix(preview): gate head animation CSS/noscript on composed header+body+footer

Preview (TopBar handlePreview + history snapshot preview) gated the
animation CSS and no-JS fallback on the page body only, while the published
head (generateCompiledHTML) gates on the composed header+body+footer. Add
exportComposedPreviewHtml (head scanned over composed content for fonts and
animation; body replaced with composed HTML + one reveal script) and use it
from both Preview paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:40:54 -07:00
1 parent a2d18b7f4b
commit faf129c509
5 files changed
+86 -37

No files matched your search

+7 -26
View File
@@ -274,7 +274,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
const win = window.open('', '_blank');
try {
const serialized = query.serialize();
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportToHtml, exportBodyHtml, buildAnimationScript }, { openSandboxedPreview }]) => {
Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportComposedPreviewHtml, exportBodyHtml }, { openSandboxedPreview }]) => {
// Get header HTML
let headerHtml = '';
try {
@@ -295,18 +295,11 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer. `handlePreview` below
// 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);
// Compose full page: header + body + footer. exportComposedPreviewHtml
// builds the head over this composed content (fonts + animation gate,
// like the published head) and swaps it in as the body with ONE reveal
// script, so animated elements in header/footer still reveal.
const composedHtml = 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
@@ -317,7 +310,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
const seo = activePage?.seo;
const title = seo?.metaTitle?.trim() || activePage?.name || whpConfig?.siteName || 'Preview';
const result = exportToHtml(serialized, {
let html = exportComposedPreviewHtml(serialized, composedHtml, {
title,
includeFonts: true,
headCode: design.headCode,
@@ -332,20 +325,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}`
: undefined,
design,
// Fonts: scan header+body+footer like the published head does (head parity).
fontScanHtml: composedBody,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they resolve from the sandboxed (opaque-origin) frame.
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
+14
View File
@@ -125,6 +125,20 @@ describe('buildSnapshotPreviewHtml', () => {
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
expect(html).toContain('<title>Page A</title>');
});
test('head parity: animation CSS + noscript gate on header+body+footer (animated footer only)', () => {
const footer = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { animation: 'fade-in' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null },
});
const html = buildSnapshotPreviewHtml(
{ pages_craft_state: [{ id: 'a', name: 'Page A', slug: 'index', craftState: state('Body') }], footer_craft_state: footer },
0,
'https://panel.example',
);
const head = html.slice(0, html.indexOf('</head>'));
expect(head).toContain('[data-animation]{opacity:0}');
expect(head).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
expect(html.split("document.querySelectorAll('[data-animation]')").length - 1).toBe(1);
});
});
/**
+5 -10
View File
@@ -1,6 +1,6 @@
import type { PageData, PageNav, PageSeo } from '../types';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml, exportToHtml, buildAnimationScript } from './html-export';
import { exportBodyHtml, exportComposedPreviewHtml } from './html-export';
/**
* Pure helpers for version history (#51). A snapshot is the gunzipped previous
@@ -150,12 +150,13 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
const headerHtml = zoneHtml(snapshot.header_craft_state, 'header');
const footerHtml = zoneHtml(snapshot.footer_craft_state, 'footer');
const composed = headerHtml + bodyHtml + footerHtml;
const composedBody = composed + buildAnimationScript(composed);
const design = snapshotDesign(snapshot);
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
const seo = page.seo;
let html = exportToHtml(state, {
// Head (fonts + animation gate) is scanned over header+body+footer, like the
// published head (head parity); the body is the composed content.
let html = exportComposedPreviewHtml(state, composed, {
title: seo?.metaTitle?.trim() || page.name || 'Preview',
includeFonts: true,
headCode: fullDesign.headCode,
@@ -166,13 +167,7 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
noindex: seo?.noindex,
favicon: fullDesign.favicon,
design: fullDesign,
// Fonts: scan header+body+footer like the published head does (head parity).
fontScanHtml: composedBody,
}).html;
// Function replacer: user content may contain `$&` / `$1`.
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (m) html = html.replace(m[1], () => composedBody);
});
html = html.replace(/src="\/api\//g, `src="${apiOrigin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${apiOrigin}/api/`);
@@ -112,3 +112,36 @@ describe('buildFontsLink per-element presets (item 14/15)', () => {
expect(link).not.toContain('undefined');
});
});
/**
* Minor finding: Preview gated the animation CSS + noscript on the PAGE BODY
* only, while generateCompiledHTML gates on the composed header+body+footer
* ($hasAnim = strpos($html,'data-animation') over $composedBody). An animated
* element only in the header/footer therefore had no keyframes/noscript in
* Preview but did once published.
*/
describe('composed preview: animation head gate covers header+body+footer', () => {
const ANIM_CSS_MARK = '[data-animation]{opacity:0}';
const NOSCRIPT = '<noscript><style>[data-animation]{opacity:1}</style></noscript>';
const plainBody = root({});
const animatedHeader = '<header data-animation="fade-in">H</header>';
test('animation only in the header -> head has the animation CSS + noscript, one reveal script', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const bodyHtml = exportBodyHtml(plainBody).html;
const html = exportComposedPreviewHtml(plainBody, animatedHeader + bodyHtml, { title: 'T' });
const head = html.slice(0, html.indexOf('</head>'));
expect(head).toContain(ANIM_CSS_MARK);
expect(head).toContain(NOSCRIPT);
expect(html.split("document.querySelectorAll('[data-animation]')").length - 1).toBe(1);
expect(html).toContain(animatedHeader);
});
test('no animation anywhere -> no animation CSS/noscript/script', async () => {
const { exportComposedPreviewHtml } = await import('./html-export');
const html = exportComposedPreviewHtml(plainBody, '<header>H</header>' + exportBodyHtml(plainBody).html, { title: 'T' });
expect(html).not.toContain(ANIM_CSS_MARK);
expect(html).not.toContain(NOSCRIPT);
expect(html).not.toContain("querySelectorAll('[data-animation]')");
});
});
+27 -1
View File
@@ -32,6 +32,10 @@ export interface ExportOptions {
* the body. Preview passes the composed header+body+footer so it matches the
* published head, which scans all three (head parity). */
fontScanHtml?: string;
/** HTML scanned for `data-animation` to gate the head animation CSS + noscript;
* defaults to the body. Preview passes the composed header+body+footer, matching
* generateCompiledHTML's $hasAnim over the composed body (head parity). */
animationScanHtml?: 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. */
@@ -474,7 +478,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
// 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 = (options.animationScanHtml ?? bodyHtml).includes('data-animation');
const animationBlock = hasAnimations ? animation : '';
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
const revealScript = buildAnimationScript(bodyHtml);
@@ -536,6 +540,28 @@ export function exportToHtml(
}
}
/**
* Preview document for a page composed with its header/footer: the head is built
* over the composed content (fonts + animation gate, like generateCompiledHTML)
* and the body is replaced by the composed HTML plus ONE reveal script built from
* it. Function replacer: user content may contain `$&` / `$1`.
*/
export function exportComposedPreviewHtml(
serializedState: string,
composedHtml: string,
options: ExportOptions = {},
): string {
const composedBody = composedHtml + buildAnimationScript(composedHtml);
let html = exportToHtml(serializedState, {
...options,
fontScanHtml: composedBody,
animationScanHtml: composedBody,
}).html;
const m = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (m) html = html.replace(m[1], () => composedBody);
return html;
}
/**
* Export just the body HTML + CSS (for WHP API save -- PHP wraps in document).
*/