Support for Google Fonts in Preview script and Error Feedback (#202)

* feat: Add support for loading Google Fonts in the embed script.

* feat: Add error state and UI feedback
This commit is contained in:
Luis Llanes
2025-08-30 06:00:27 -07:00
committed by Sahaj Jain
parent 954db92f4a
commit f029535213
4 changed files with 293 additions and 99 deletions
+124 -38
View File
@@ -8,8 +8,11 @@ import {
} from "@/components/block-viewer";
import { CopyButton } from "@/components/copy-button";
import { LoadingLogo } from "@/components/editor/ai/loading-logo";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { SocialLink } from "@/components/social-link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import { useIframeThemeInjector } from "@/hooks/use-iframe-theme-injector";
import { useWebsitePreview } from "@/hooks/use-website-preview";
@@ -21,6 +24,7 @@ import {
ExternalLink,
Globe,
GlobeLock,
Info,
Loader,
RefreshCw,
X,
@@ -101,7 +105,7 @@ function DynamicWebsitePreviewProvider({
}) {
const websitePreviewState = useWebsitePreview({ allowCrossOrigin });
const { status, retryValidation } = useIframeThemeInjector({
const { status, retryValidation, themeInjectionError } = useIframeThemeInjector({
allowCrossOrigin: allowCrossOrigin && !!websitePreviewState.currentUrl,
iframeRef: websitePreviewState.iframeRef,
});
@@ -110,6 +114,7 @@ function DynamicWebsitePreviewProvider({
...websitePreviewState,
status,
retryValidation,
themeInjectionError,
};
return (
@@ -124,7 +129,7 @@ function DynamicToolbarControls() {
inputUrl,
setInputUrl,
currentUrl,
isLoading,
isLoading: previewIsLoading,
loadUrl,
refreshIframe,
openInNewTab,
@@ -157,18 +162,18 @@ function DynamicToolbarControls() {
<Button
onClick={loadUrl}
disabled={isLoading || !inputUrl}
disabled={previewIsLoading || !inputUrl}
size="sm"
className="h-8 w-16 shadow-none"
>
{isLoading ? <Loader className="size-3 animate-spin" /> : "Load"}
{previewIsLoading ? <Loader className="size-3 animate-spin" /> : "Load"}
</Button>
<Button
variant="outline"
size="icon"
onClick={refreshIframe}
disabled={isLoading || !currentUrl}
disabled={previewIsLoading || !currentUrl}
className="size-8 shadow-none"
>
<RefreshCw className={cn("size-3")} />
@@ -194,17 +199,18 @@ function DynamicToolbarControls() {
function DynamicIframeContent() {
const {
currentUrl,
isLoading,
error,
isLoading: previewIsLoading,
error: previewError,
status,
retryValidation,
allowCrossOrigin,
iframeRef,
handleIframeLoad,
handleIframeError,
themeInjectionError,
} = useDynamicWebsitePreview();
if (!currentUrl && !error) {
if (!currentUrl && !previewError) {
return (
<div className="relative size-full overflow-hidden p-4">
<div className="text-muted-foreground mx-auto flex h-full max-w-lg flex-col items-center justify-center space-y-6">
@@ -222,10 +228,15 @@ function DynamicIframeContent() {
<p className="text-foreground text-lg font-medium">Preview External Websites</p>
<p className="text-muted-foreground text-sm text-pretty">
Enter a URL to preview websites built with{" "}
<span className="font-medium">shadcn/ui</span> components.
{allowCrossOrigin
? "External sites can integrate with tweakcn by including our script for live theme previews."
: "Same-origin websites support direct theme injection without requiring external scripts."}
<span className="font-medium">shadcn/ui</span> components.{" "}
{allowCrossOrigin ? (
<span>
External sites can integrate with tweakcn by including our script for{" "}
<span className="font-medium">live theme previews</span>.
</span>
) : (
"Same-origin websites support direct theme injection without requiring external scripts."
)}
</p>
</div>
@@ -246,12 +257,12 @@ function DynamicIframeContent() {
);
}
if (error) {
if (previewError) {
return (
<div className="relative size-full overflow-hidden p-4">
<div className="flex h-full flex-col items-center justify-center space-y-2 p-4 text-center">
<p className="text-destructive text-sm font-medium">Error Loading Website</p>
<span className="text-muted-foreground max-w-md text-xs">{error}</span>
<span className="text-muted-foreground max-w-md text-xs">{previewError}</span>
</div>
</div>
);
@@ -259,7 +270,7 @@ function DynamicIframeContent() {
return (
<div className="relative size-full overflow-hidden">
{isLoading && (
{previewIsLoading && (
<div className="bg-background/80 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-sm">
<div className="flex items-center space-x-2">
<div className="relative size-6">
@@ -285,18 +296,19 @@ function DynamicIframeContent() {
onError={handleIframeError}
sandbox={
allowCrossOrigin
? "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
? "allow-scripts allow-same-origin allow-forms allow-popups"
: "allow-scripts allow-same-origin"
}
loading="lazy"
/>
{!isLoading && !!status && allowCrossOrigin && (
<div className="bg-background/60 outline-border/50 absolute bottom-2 left-2 z-10 rounded-md px-2 py-1 outline-2 backdrop-blur-lg">
{!previewIsLoading && !!status && allowCrossOrigin && (
<div className="absolute bottom-2 left-2 z-10">
<ConnectionStatus
status={status}
retryValidation={retryValidation}
isLoading={isLoading}
isLoading={previewIsLoading}
errorMsg={themeInjectionError}
/>
</div>
)}
@@ -308,45 +320,119 @@ function ConnectionStatus({
status,
retryValidation,
isLoading,
errorMsg,
}: {
status: IframeStatus;
retryValidation: () => void;
isLoading: boolean;
errorMsg?: string | null;
}) {
if (isLoading || status === "unknown") return null;
return (
<div className="flex h-8 items-center gap-2">
{ICONS[status]}
<span className="text-muted-foreground text-sm font-medium">{TEXTS[status]}</span>
{(status === "missing" || status === "unsupported") && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 py-1 text-xs"
onClick={retryValidation}
>
Retry
</Button>
<div className="bg-popover/90 outline-border/50 flex h-8 items-center gap-2 rounded-lg px-2 shadow-sm outline backdrop-blur-lg">
<div className="flex items-center gap-1">
<span className="text-foreground/90">
{errorMsg ? (
<HoverCard>
<HoverCardTrigger>{ICONS[status]}</HoverCardTrigger>
<HoverCardContent
align="start"
side="top"
className="size-fit max-w-[280px] min-w-[140px] p-2"
>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Info className="size-3" />
<p className="text-xs font-medium">Error details:</p>
</div>
<p className="text-muted-foreground text-xs text-pretty">{errorMsg}</p>
{status === "missing" && (
<code className="text-foreground bg-muted group/script relative isolate block overflow-hidden rounded-md border">
<HorizontalScrollArea className="relative isolate">
<span className="p-2 font-mono text-xs text-nowrap">
{TWEAKCN_EMBED_SCRIPT_TAG}
</span>
</HorizontalScrollArea>
<CopyButton
variant="default"
size="icon"
textToCopy={TWEAKCN_EMBED_SCRIPT_TAG}
className="absolute top-1 right-1 z-10 hidden group-hover/script:inline-flex [&>svg]:size-3"
/>
</code>
)}
{status === "unsupported" && (
<div className="space-y-1">
<p className="border-primary! border-l-2 pl-2 text-xs font-medium text-pretty">
Consult the shadcn/ui docs for more information:
</p>
<div className="flex flex-col gap-0.5">
<SocialLink
showIcon
href="https://ui.shadcn.com/docs/installation"
className="text-primary text-xs"
>
Installation docs
</SocialLink>
<SocialLink
showIcon
href="https://ui.shadcn.com/docs/theming"
className="text-primary text-xs"
>
Theming docs
</SocialLink>
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
) : (
ICONS[status]
)}
</span>
<span className="text-foreground/90 flex items-center gap-1 text-sm font-medium">
{TEXTS[status]}
</span>
</div>
{(status === "missing" || status === "unsupported" || status === "error") && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs shadow-none"
onClick={retryValidation}
>
Retry
</Button>
</div>
)}
</div>
);
}
const ICONS: Record<IframeStatus, React.ReactNode> = {
checking: <Loader className="text-foreground size-4 animate-spin" />,
connected: <CheckCircle className="text-foreground size-4" />,
supported: <CheckCircle className="text-foreground size-4" />,
unsupported: <AlertCircle className="text-foreground size-4" />,
missing: <XCircle className="text-destructive size-4" />,
unknown: null,
checking: <Loader className="size-4 animate-spin" />,
connected: <CheckCircle className="size-4" />,
supported: <CheckCircle className="size-4" />,
unsupported: <AlertCircle className="size-4" />,
missing: <XCircle className="text-destructive size-4" />,
error: <XCircle className="text-destructive size-4" />,
};
const TEXTS: Record<IframeStatus, string> = {
checking: "Checking connection...",
unknown: "",
checking: "Checking connection",
connected: "Connected",
supported: "Live preview enabled",
unsupported: "Unsupported site",
missing: "Script not found",
unknown: "",
error: "An error occurred",
};
+33 -5
View File
@@ -3,7 +3,7 @@ import { EmbedMessage, IframeStatus, MESSAGE } from "@/types/live-preview-embed"
import { applyThemeToElement } from "@/utils/apply-theme";
import { useCallback, useEffect, useRef, useState } from "react";
const THEME_UPDATE_DEBOUNCE_MS = 150; // Throttle theme updates to ~6-7 fps
const THEME_UPDATE_DEBOUNCE_MS = 50;
export interface UseIframeThemeInjectorProps {
allowCrossOrigin?: boolean; // default false - must explicitly opt-in for external sites
@@ -24,6 +24,7 @@ export const useIframeThemeInjector = ({
const { themeState } = useEditorStore();
const [status, setStatus] = useState<IframeStatus>("unknown");
const [themeInjectionError, setThemeInjectionError] = useState<string | null>(null);
const validationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const themeUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -31,9 +32,9 @@ export const useIframeThemeInjector = ({
if (allowCrossOrigin) return; // Only for same-origin mode
const iframe = ref.current;
if (!iframe?.contentDocument?.documentElement) return;
const iframeRoot = iframe?.contentDocument?.documentElement;
if (!iframeRoot) return;
const iframeRoot = iframe.contentDocument.documentElement;
applyThemeToElement(themeState, iframeRoot);
}, [allowCrossOrigin, ref, themeState]);
@@ -45,6 +46,7 @@ export const useIframeThemeInjector = ({
iframe.contentWindow.postMessage(msg, "*");
} catch (error) {
console.warn("Failed to send message to iframe:", error);
setThemeInjectionError("Failed to establish the connection.");
}
}
},
@@ -58,7 +60,10 @@ export const useIframeThemeInjector = ({
clearTimeout(validationTimeoutRef.current!);
validationTimeoutRef.current = setTimeout(() => {
setStatus("missing");
}, 2000);
setThemeInjectionError(
"The tweakcn's live theme preview script could not be found. Please make sure the script is included in the website's source code and try again."
);
}, 3000);
}, [postMessage]);
// Listen for iframe load
@@ -77,6 +82,7 @@ export const useIframeThemeInjector = ({
// Same-origin: just apply theme directly
applySameOriginTheme();
setStatus("supported"); // Always supported for same-origin
setThemeInjectionError(null);
}
};
@@ -100,13 +106,34 @@ export const useIframeThemeInjector = ({
switch (message.type) {
case MESSAGE.EMBED_LOADED:
console.log("Tweakcn Embed: Embed loaded");
setThemeInjectionError(null);
break;
case MESSAGE.PONG:
setStatus("connected");
setThemeInjectionError(null);
postMessage({ type: MESSAGE.CHECK_SHADCN });
break;
case MESSAGE.SHADCN_STATUS:
setStatus(message.payload.supported ? "supported" : "unsupported");
const { supported } = message.payload;
if (supported) {
setStatus("supported");
setThemeInjectionError(null);
} else {
setStatus("unsupported");
setThemeInjectionError(
"Live theme preview requires shadcn/ui setup. Please make sure that the basic shadcn/ui variables are configured correctly."
);
}
break;
case MESSAGE.EMBED_ERROR:
const { error } = message.payload;
console.error("Tweakcn Embed: Error from iframe:", error);
setStatus("error");
setThemeInjectionError(error);
break;
}
};
@@ -143,5 +170,6 @@ export const useIframeThemeInjector = ({
ref,
status: allowCrossOrigin ? status : "supported", // Same-origin is always "supported"
retryValidation: allowCrossOrigin ? startCrossOriginValidation : applySameOriginTheme,
themeInjectionError,
};
};
+131 -54
View File
@@ -1,51 +1,27 @@
// Tweakcn Live Preview Embed Script
// Enables live theme updates for external websites using shadcn/ui
const TWEAKCN_MESSAGE = {
PING: "TWEAKCN_PING",
PONG: "TWEAKCN_PONG",
CHECK_SHADCN: "TWEAKCN_CHECK_SHADCN",
SHADCN_STATUS: "TWEAKCN_SHADCN_STATUS",
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
};
// Required shadcn/ui CSS variables for detection
// ----- SHADCN SUPPORT -----
const REQUIRED_SHADCN_VARS = [
"--background",
"--foreground",
"--primary",
"--card",
"--radius",
];
"--background",
"--foreground",
"--card",
"--card-foreground",
"--popover",
"--popover-foreground",
"--primary",
"--primary-foreground",
"--secondary",
"--secondary-foreground",
"--muted",
"--muted-foreground",
"--accent",
"--accent-foreground",
"--destructive",
"--border",
"--input",
"--ring",
]
const updateThemeClass = (root, mode) => {
root.classList.toggle("dark", mode === "dark");
};
const applyStyleProperty = (root, key, value) => {
if (typeof value === "string" && value.trim()) {
root.style.setProperty(`--${key}`, value);
}
};
const applyThemeStyles = (root, themeStyles, mode) => {
// Apply light theme styles first (base styles)
const lightStyles = themeStyles.light || {};
for (const [key, value] of Object.entries(lightStyles)) {
applyStyleProperty(root, key, value);
}
// Apply dark mode overrides if in dark mode
if (mode === "dark" && themeStyles.dark) {
for (const [key, value] of Object.entries(themeStyles.dark)) {
applyStyleProperty(root, key, value);
}
}
};
const checkShadcnSupport = () => {
function checkShadcnSupport() {
const rootStyles = getComputedStyle(document.documentElement);
const hasSupport = REQUIRED_SHADCN_VARS.every(
(v) => rootStyles.getPropertyValue(v).trim() !== ""
@@ -53,21 +29,103 @@ const checkShadcnSupport = () => {
return { supported: hasSupport };
};
const applyTheme = (themeState) => {
// ----- FONT LOADING UTILITIES -----
const DEFAULT_FONT_WEIGHTS = ["400", "500", "600", "700"];
function extractFontFamily(fontFamilyValue) {
if (!fontFamilyValue) return null;
const firstFont = fontFamilyValue.split(",")[0].trim();
const cleanFont = firstFont.replace(/['"]/g, "");
const systemFonts = [
"ui-sans-serif", "ui-serif", "ui-monospace", "system-ui",
"sans-serif", "serif", "monospace", "cursive", "fantasy"
];
if (systemFonts.includes(cleanFont.toLowerCase())) return null;
return cleanFont;
}
function buildFontCssUrl(family, weights) {
weights = weights || DEFAULT_FONT_WEIGHTS;
const encodedFamily = encodeURIComponent(family);
const weightsParam = weights.join(";");
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@${weightsParam}&display=swap`;
}
function loadGoogleFont(family, weights) {
weights = weights || DEFAULT_FONT_WEIGHTS;
const href = buildFontCssUrl(family, weights);
const existing = document.querySelector(`link[href="${href}"]`);
if (existing) return;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
function loadThemeFonts(themeStyles) {
try {
const currentFonts = {
sans: themeStyles["font-sans"],
serif: themeStyles["font-serif"],
mono: themeStyles["font-mono"],
};
Object.entries(currentFonts).forEach(([_type, fontValue]) => {
const fontFamily = extractFontFamily(fontValue);
if (fontFamily) {
loadGoogleFont(fontFamily, DEFAULT_FONT_WEIGHTS);
}
});
} catch (error) {
console.warn("Tweakcn Embed: Failed to load fonts:", error);
}
}
// ----- THEME STYLES APPLICATION -----
function applyStyleProperty(root, key, value) {
if (typeof value === "string" && value.trim()) {
root.style.setProperty(`--${key}`, value);
}
};
function updateThemeModeClass(root, mode) {
root.classList.toggle("dark", mode === "dark");
};
function applyThemeStyles(root, themeStyles, mode) {
updateThemeModeClass(root, mode);
// Apply light theme styles first (base styles)
const lightStyles = themeStyles.light || {};
for (const [key, value] of Object.entries(lightStyles)) {
applyStyleProperty(root, key, value);
}
// Apply dark mode overrides
const darkStyles = themeStyles.dark;
if (mode === "dark" && darkStyles) {
for (const [key, value] of Object.entries(darkStyles)) {
applyStyleProperty(root, key, value);
}
}
loadThemeFonts(lightStyles);
};
function applyTheme(themeState) {
const root = document.documentElement;
if (!root || !themeState || !themeState.styles) {
console.warn("Tweakcn Embed: Missing root element or theme styles.");
return;
}
const { currentMode: mode, styles: themeStyles } = themeState;
// Follow the same pattern as utils/apply-theme.ts
updateThemeClass(root, mode);
const { currentMode: mode, styles: themeStyles } = themeState;
applyThemeStyles(root, themeStyles, mode);
};
const sendMessageToParent = (message) => {
// ----- MESSAGE SENDING -----
function sendMessageToParent(message) {
if (window.parent && window.parent !== window) {
try {
window.parent.postMessage(message, "*");
@@ -77,17 +135,36 @@ const sendMessageToParent = (message) => {
}
};
const TWEAKCN_MESSAGE = {
PING: "TWEAKCN_PING",
PONG: "TWEAKCN_PONG",
CHECK_SHADCN: "TWEAKCN_CHECK_SHADCN",
SHADCN_STATUS: "TWEAKCN_SHADCN_STATUS",
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
EMBED_ERROR: "TWEAKCN_EMBED_ERROR",
};
// ----- MAIN SCRIPT -----
(() => {
"use strict";
// Prevent multiple initialization
if (window.TweakcnEmbed) return;
if (window.tweakcnEmbed) return;
const handleMessage = (event) => {
// Verify the message is from the parent window
if (event.source !== window.parent) return;
// Verify the message has the expected structure
if (!event.data || typeof event.data.type !== "string") return;
// TODO: Remove localhost once this is live
const ALLOWED_ORIGINS = ['https://tweakcn.com', 'http://localhost:3000'];
if (!ALLOWED_ORIGINS.includes(event.origin)){
sendMessageToParent({ type: TWEAKCN_MESSAGE.EMBED_ERROR, payload: { error: "Origin not allowed. Preview failed to establish the connection with tweakcn." } });
return;
} ;
const { type, payload } = event.data;
@@ -119,12 +196,12 @@ const sendMessageToParent = (message) => {
window.addEventListener("message", handleMessage);
window.TweakcnEmbed = {
window.tweakcnEmbed = {
initialized: true,
version: "1.0.0",
destroy: () => {
window.removeEventListener("message", handleMessage);
delete window.TweakcnEmbed;
delete window.tweakcnEmbed;
},
};
+5 -2
View File
@@ -9,6 +9,7 @@ export const MESSAGE = {
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
EMBED_ERROR: "TWEAKCN_EMBED_ERROR",
} as const;
export type MessageType = (typeof MESSAGE)[keyof typeof MESSAGE];
@@ -28,7 +29,8 @@ export type EmbedMessage =
| { type: typeof MESSAGE.SHADCN_STATUS; payload: ShadcnStatusPayload }
| { type: typeof MESSAGE.THEME_UPDATE; payload: ThemeUpdatePayload }
| { type: typeof MESSAGE.THEME_APPLIED }
| { type: typeof MESSAGE.EMBED_LOADED };
| { type: typeof MESSAGE.EMBED_LOADED }
| { type: typeof MESSAGE.EMBED_ERROR; payload: { error: string } };
export type IframeStatus =
| "unknown"
@@ -36,4 +38,5 @@ export type IframeStatus =
| "connected"
| "supported"
| "unsupported"
| "missing";
| "missing"
| "error";