tfrere's picture
tfrere HF Staff
fix(ui,publisher): kill theme/color flickers and translation prompts
08e4c7a
// ---------------------------------------------------------------------------
// Shared embed document builder
//
// Single source of truth for the srcdoc injected into every embed iframe,
// used by both the editor (live preview) and the publisher (static HTML).
//
// Exports:
// - buildEmbedSrcdoc(html, opts) – assemble the full HTML doc
// - EMBED_MESSAGE_TYPES – { resize, setTheme, updatePrimaryColor }
// - DEFAULT_EMBED_HEIGHT – fallback pixel height when none stored
//
// Why one shared builder?
// Before, the editor and publisher each had their own COLOR_PALETTES / height
// reporter / theme listener, with subtle divergences (e.g. only the publisher
// supported setTheme hot-swap). Keeping them in sync by hand was a footgun.
// ---------------------------------------------------------------------------
export const DEFAULT_EMBED_HEIGHT = 400;
export const EMBED_MESSAGE_TYPES = {
resize: "embedResize",
setTheme: "setTheme",
updatePrimaryColor: "updatePrimaryColor",
ready: "embedReady",
} as const;
/** ColorPalettes polyfill – mirrors the research-article-template API. */
const COLOR_PALETTES_POLYFILL = `
(function(){
var palettes = {
categorical: ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ac'],
sequential: ['#084594','#2171b5','#4292c6','#6baed6','#9ecae1','#c6dbef','#deebf7','#f7fbff'],
diverging: ['#d73027','#f46d43','#fdae61','#fee090','#ffffbf','#e0f3f8','#abd9e9','#74add1','#4575b4']
};
window.ColorPalettes = {
getColors: function(type, n) {
var p = palettes[type] || palettes.categorical;
return p.slice(0, n != null ? n : p.length);
},
getPrimary: function() {
return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7';
},
refresh: function() {},
getTextStyleForBackground: function() { return { fill: '#ffffff' }; }
};
})();
`.trim();
/**
* Height reporter:
* - Observes documentElement + body (ResizeObserver) to catch all reflows
* - Observes body's subtree (MutationObserver) to catch async chart mounts
* - Throttles via requestAnimationFrame and only posts when delta >= 1px
* - Safety retries at load, +300ms, +1000ms, +3000ms for slow async charts
* - Exposes window.__embedReady(optionalHeight) so charts can opt-in to a
* deterministic "I'm done" signal (useful for CSV fetches > 3s)
*/
const HEIGHT_REPORTER = `
(function(){
var lastH = 0;
var scheduled = false;
// IMPORTANT: only measure <body>. document.documentElement.scrollHeight is
// clamped to at least the iframe's viewport height, so it would never allow
// the iframe to shrink (it would always report >= current iframe height).
// body.scrollHeight reflects the actual content height plus body padding.
function measure(){
var b = document.body;
if (!b) return 0;
// getBoundingClientRect().bottom accounts for padding/margin precisely;
// fall back to scrollHeight if the rect reports 0 (detached layout).
var rectBottom = Math.ceil(b.getBoundingClientRect().bottom);
var scroll = Math.ceil(b.scrollHeight);
// body margin may push the rect down; scrollHeight ignores it. Take the
// max so we never under-report.
return Math.max(rectBottom, scroll);
}
function post(h){
try {
window.parent.postMessage({ type: 'embedResize', height: h }, '*');
} catch(e){}
}
function schedule(){
if (scheduled) return;
scheduled = true;
requestAnimationFrame(function(){
scheduled = false;
var h = measure();
if (h > 0 && Math.abs(h - lastH) >= 1) {
lastH = h;
post(h);
}
});
}
if (typeof ResizeObserver !== 'undefined') {
var ro = new ResizeObserver(schedule);
ro.observe(document.documentElement);
if (document.body) ro.observe(document.body);
}
if (typeof MutationObserver !== 'undefined' && document.body) {
var mo = new MutationObserver(schedule);
mo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true });
}
window.addEventListener('load', function(){
schedule();
setTimeout(schedule, 300);
setTimeout(schedule, 1000);
setTimeout(schedule, 3000);
});
// Opt-in explicit ready signal for async charts
window.__embedReady = function(){
schedule();
try { window.parent.postMessage({ type: 'embedReady' }, '*'); } catch(e){}
};
})();
`.trim();
/**
* Synchronous pre-paint theme bootstrap.
*
* Runs BEFORE the iframe's own <style> applies, so the first paint already
* uses the correct theme + primary color and the banner/chart never flashes
* from the default (light / #4e79a7) to the user-selected theme.
*
* Strategy (tried in order, first hit wins):
* 1. parent.documentElement[data-theme] + parent's computed --primary-color
* → works in published articles, the editor, and any embed sharing
* origin with its host (we rely on `allow-same-origin` being set).
* 2. localStorage.theme (same origin as parent).
* 3. matchMedia('(prefers-color-scheme: dark)').
*
* All accesses are wrapped in try/catch: if the iframe is cross-origin or
* sandboxed, we silently fall back to the server-provided defaults baked
* into the srcdoc (`data-theme`, `--primary-color`) and the postMessage
* setTheme path still works as a safety net.
*/
const THEME_BOOTSTRAP = `
(function(){
try {
var theme = null;
var primary = null;
try {
var parentDoc = window.parent && window.parent.document;
if (parentDoc) {
theme = parentDoc.documentElement.getAttribute('data-theme') || null;
var pc = getComputedStyle(parentDoc.documentElement).getPropertyValue('--primary-color');
if (pc) primary = pc.trim();
}
} catch(e) {}
if (!theme) {
try { theme = window.localStorage && window.localStorage.getItem('theme'); } catch(e) {}
}
if (!theme) {
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme === 'dark' ? 'dark' : 'light';
if (primary) document.documentElement.style.setProperty('--primary-color', primary);
} catch(e) {}
})();
`.trim();
/**
* Theme listener: swap CSS variables via [data-theme] without reloading.
* Also supports live primary-color updates and invokes window.__chartRerender
* if the embed defines one (same convention as research-article-template).
*/
const THEME_LISTENER = `
(function(){
window.addEventListener('message', function(e){
if (!e.data) return;
if (e.data.type === 'setTheme') {
document.documentElement.setAttribute('data-theme', e.data.theme);
document.documentElement.style.colorScheme = e.data.theme === 'dark' ? 'dark' : 'light';
if (e.data.primaryColor) {
document.documentElement.style.setProperty('--primary-color', e.data.primaryColor);
}
if (window.__chartRerender) try { window.__chartRerender(); } catch(err){}
} else if (e.data.type === 'updatePrimaryColor') {
document.documentElement.style.setProperty('--primary-color', e.data.color);
if (window.__chartRerender) try { window.__chartRerender(); } catch(err){}
}
});
})();
`.trim();
const BASE_STYLES = `
:root, [data-theme='light'] {
color-scheme: light;
--text-color: rgba(0,0,0,.85);
--surface-bg: #f9f9f9;
--page-bg: #fff;
--border-color: rgba(0,0,0,.1);
--muted-color: rgba(0,0,0,.6);
--axis-color: rgba(0,0,0,.6);
--tick-color: rgba(0,0,0,.85);
--grid-color: rgba(0,0,0,.08);
}
[data-theme='dark'] {
color-scheme: dark;
--text-color: rgba(255,255,255,.9);
--surface-bg: #07080a;
--page-bg: #0f1115;
--border-color: rgba(255,255,255,.15);
--muted-color: rgba(255,255,255,.7);
--axis-color: rgba(255,255,255,.7);
--tick-color: rgba(255,255,255,.7);
--grid-color: rgba(255,255,255,.10);
}
*, *::before, *::after { box-sizing: border-box; }
/* IMPORTANT: do NOT set min-height:100% on <body>. Doing so creates a
lower bound on scrollHeight equal to the iframe's rendered height, so
the reporter can never signal a smaller height and the iframe can only
ever grow (never shrink when the chart re-renders narrower on resize). */
html, body { margin: 0; padding: 0; background: transparent; }
body {
color: var(--text-color);
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
font-size: 13px;
padding: 16px;
/* overflow: clip clips runaway horizontal content without creating a
scroll container that would trap scroll events or inflate scrollHeight. */
overflow: clip;
}
`.trim();
/**
* Escape `</script>` inside the embed HTML fragment so the outer parser does
* not terminate its own <script> block prematurely. Keeps the final closing
* tag intact in case the embed itself ends with `</script>`.
*/
function escapeScriptTags(html: string): string {
const escaped = html.replace(/<\/script>/gi, "<\\/script>");
const lastIdx = escaped.lastIndexOf("<\\/script>");
if (lastIdx === -1) return html;
return escaped.slice(0, lastIdx) + "</script>" + escaped.slice(lastIdx + 10);
}
export interface BuildEmbedSrcdocOptions {
/** Initial theme, before any setTheme postMessage is received. */
isDark?: boolean;
/** Initial primary color (CSS color), defaults to #4e79a7. */
primaryColor?: string;
/**
* Edge-to-edge mode (used for article banners):
* - html/body fill 100% of the iframe viewport
* - no body padding (chart bleeds to the edges)
* - height reporter is skipped (iframe is size-driven by its parent's
* aspect-ratio, not by the chart's scrollHeight)
*/
fullBleed?: boolean;
}
/**
* Stylesheet override applied when `fullBleed` is true. Replaces the
* default body padding and establishes a proper 100%-height chain so
* charts declaring `height: 100%` actually get a measurable height.
*/
const FULL_BLEED_STYLES = `
html, body { height: 100%; width: 100%; }
body { padding: 0 !important; overflow: hidden; }
body > :first-child { width: 100%; height: 100%; }
`.trim();
/**
* Build a self-contained HTML document suitable for iframe `srcdoc=`.
* Theme changes after load should be driven via postMessage (`setTheme`)
* rather than rebuilding the srcdoc – otherwise the iframe reloads and loses
* chart state.
*/
export function buildEmbedSrcdoc(htmlFragment: string, opts: BuildEmbedSrcdocOptions = {}): string {
const isDark = !!opts.isDark;
const primaryColor = opts.primaryColor || "#4e79a7";
const themeAttr = isDark ? "dark" : "light";
const inlinePrimary = `:root{--primary-color:${primaryColor};}`;
const fullBleed = !!opts.fullBleed;
const extraStyles = fullBleed ? FULL_BLEED_STYLES : "";
const parts: string[] = [
"<!DOCTYPE html>",
`<html data-theme="${themeAttr}">`,
"<head>",
'<meta charset="UTF-8">',
// Pre-paint theme bootstrap MUST come before the <style> block so the
// correct [data-theme] attribute is in place when the default tokens
// cascade applies. Otherwise the iframe flashes from server-default to
// client theme once the postMessage listener kicks in post-load.
`<script>${THEME_BOOTSTRAP}<\/script>`,
`<style>${BASE_STYLES}${inlinePrimary}${extraStyles}</style>`,
`<script>${COLOR_PALETTES_POLYFILL}<\/script>`,
"</head>",
"<body>",
escapeScriptTags(htmlFragment),
];
// Skip the height reporter in fullBleed mode: the iframe is size-driven
// by its parent's aspect-ratio (e.g. the banner 5:2 box) and must not
// try to resize itself based on chart scrollHeight.
if (!fullBleed) {
parts.push(`<script>${HEIGHT_REPORTER}<\/script>`);
}
parts.push(`<script>${THEME_LISTENER}<\/script>`);
parts.push("</body></html>");
return parts.join("\n");
}