feat(ui): add mobile preview device framing and design QA tools

This commit is contained in:
2026-05-15 11:01:49 -07:00
parent 2993f19a25
commit 8a7897a891
4 changed files with 312 additions and 33 deletions

View File

@@ -32,6 +32,8 @@ export default function PreviewTab() {
const bridge = usePreviewBridge();
const origin = typeof window !== "undefined" ? window.location.origin : "";
const [deviceMode, setDeviceMode] = useState<"desktop" | "mobile">("desktop");
// Auto-select first preview on load
useEffect(() => {
if (!selectedUrl && options.length > 0) {
@@ -50,8 +52,8 @@ export default function PreviewTab() {
return (
<div style={canvas}>
{options.length > 1 && (
<div style={toolbar}>
<div style={toolbar}>
{options.length > 1 && (
<select
value={selectedUrl ?? ""}
onChange={(e) => setSelectedUrl(e.target.value)}
@@ -64,36 +66,96 @@ export default function PreviewTab() {
</option>
))}
</select>
</div>
)}
<div style={previewFrame}>
{loading && !iframeSrc ? (
<div style={loaderWrap}>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
</div>
) : iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={iframeStyle}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>
<p style={emptyText}>No preview available</p>
</div>
)}
<div
style={{
display: "flex",
gap: 4,
background: "rgba(255,255,255,0.85)",
padding: 4,
borderRadius: 8,
border: "1px solid rgba(26,26,26,0.12)",
}}
>
<button
onClick={() => setDeviceMode("desktop")}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: "0.8rem",
background:
deviceMode === "desktop" ? "rgba(0,0,0,0.06)" : "transparent",
color: deviceMode === "desktop" ? "#000" : "#666",
}}
>
Desktop
</button>
<button
onClick={() => setDeviceMode("mobile")}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: "0.8rem",
background:
deviceMode === "mobile" ? "rgba(0,0,0,0.06)" : "transparent",
color: deviceMode === "mobile" ? "#000" : "#666",
}}
>
Mobile
</button>
</div>
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
...(deviceMode === "desktop" ? desktopFrame : mobileFrame),
transition: "all 0.3s ease",
}}
>
{deviceMode === "mobile" && <MobileChrome />}
{loading && !iframeSrc ? (
<div style={loaderWrap}>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
</div>
) : iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={{
...iframeStyle,
borderRadius: deviceMode === "mobile" ? 44 : 0,
}}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>
<p style={emptyText}>No preview available</p>
</div>
)}
{deviceMode === "mobile" && <div style={homeIndicator} aria-hidden />}
</div>
</div>
</div>
);
@@ -130,9 +192,10 @@ const select: React.CSSProperties = {
color: "#1a1a1a",
};
const previewFrame: React.CSSProperties = {
const desktopFrame: React.CSSProperties = {
flex: 1,
minHeight: 0,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 14,
@@ -143,6 +206,121 @@ const previewFrame: React.CSSProperties = {
"0 1px 2px rgba(26, 26, 26, 0.04), 0 12px 40px rgba(26, 26, 26, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.85)",
};
const mobileFrame: React.CSSProperties = {
position: "relative",
width: 390,
height: 844,
flexShrink: 0,
borderRadius: 56,
padding: 12,
background: "linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%)",
boxShadow:
"0 0 0 1px rgba(255,255,255,0.04) inset, 0 0 0 2px #000 inset, 0 28px 60px -12px rgba(0,0,0,0.45), 0 8px 20px -8px rgba(0,0,0,0.35)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
};
const homeIndicator: React.CSSProperties = {
position: "absolute",
left: "50%",
bottom: 20,
transform: "translateX(-50%)",
width: 134,
height: 5,
background: "#1a1916",
borderRadius: 999,
opacity: 0.85,
pointerEvents: "none",
zIndex: 10,
};
function MobileChrome() {
return (
<>
<div
style={{
position: "absolute",
top: 22,
left: "50%",
transform: "translateX(-50%)",
width: 124,
height: 36,
background: "#000",
borderRadius: 999,
zIndex: 15,
}}
/>
<div
style={{
position: "absolute",
top: 12,
left: 12,
right: 12,
height: 47,
padding: "18px 26px 0",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
color: "#1a1916",
pointerEvents: "none",
zIndex: 10,
}}
>
<span>9:41</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect x="0" y="7" width="3" height="4" rx="0.6" />
<rect x="4" y="5" width="3" height="6" rx="0.6" />
<rect x="8" y="3" width="3" height="8" rx="0.6" />
<rect x="12" y="0" width="3" height="11" rx="0.6" />
</svg>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<path d="M8.5 1.5C5.5 1.5 2.7 2.6 0.5 4.6L2 6.1C3.8 4.5 6.1 3.6 8.5 3.6c2.4 0 4.7 0.9 6.5 2.5l1.5-1.5c-2.2-2-5-3.1-8-3.1zM3.5 7.6L5 9.1c1-0.9 2.2-1.4 3.5-1.4 1.3 0 2.5 0.5 3.5 1.4l1.5-1.5c-1.4-1.3-3.1-2-5-2-1.9 0-3.6 0.7-5 2zM6.5 10.6l2 2 2-2c-0.5-0.5-1.2-0.8-2-0.8s-1.5 0.3-2 0.8z" />
</svg>
<svg
viewBox="0 0 25 11"
style={{ width: 25, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect
x="0.5"
y="0.5"
width="21"
height="10"
rx="2.5"
fill="none"
stroke="currentColor"
strokeOpacity="0.45"
/>
<rect
x="22"
y="3.5"
width="1.5"
height="4"
rx="0.4"
fill="currentColor"
fillOpacity="0.45"
/>
<rect x="2" y="2" width="18" height="7" rx="1.4" />
</svg>
</span>
</div>
</>
);
}
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",