feat(threejs): add visibility optimization, host styling, transparent background

- Add visibility-based animation pause (IntersectionObserver + wrapped rAF)
- Zero CPU usage when widget scrolls out of view
- Apply host style variables via useHostStyles
- Support transparent WebGL background (alpha: true)
- Use light-dark() for theme-aware fallback colors
- Tool no longer echoes code back in result
- Document performance features in README
This commit is contained in:
Anton Pidkuiko MacBook 2026-01-17 23:19:34 +00:00
commit d60224f363
No known key found for this signature in database
3 changed files with 142 additions and 56 deletions

View file

@ -1,13 +1,17 @@
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html, body {
font-family: system-ui, -apple-system, sans-serif;
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
font-size: 1rem;
margin: 0;
padding: 0;
background: #1a1a2e;
background: transparent;
}
.loading {
@ -15,13 +19,13 @@ html, body {
align-items: center;
justify-content: center;
height: 400px;
color: #888;
color: var(--color-text-secondary, #888);
font-size: 14px;
}
.error {
padding: 20px;
color: #ff6b6b;
color: var(--color-text-danger, #ff6b6b);
}
.threejs-container {
@ -35,9 +39,9 @@ html, body {
display: flex;
align-items: center;
justify-content: center;
color: #ff6b6b;
background: rgba(26, 26, 46, 0.9);
border-radius: 8px;
font-family: system-ui;
color: var(--color-text-danger, #ff6b6b);
background: var(--color-background-secondary, rgba(26, 26, 46, 0.9));
border-radius: var(--border-radius-md, 8px);
font-family: var(--font-sans, system-ui);
padding: 20px;
}

View file

@ -5,7 +5,7 @@
* props to the actual widget component.
*/
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { useApp } from "@modelcontextprotocol/ext-apps/react";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { StrictMode, useState, useCallback, useEffect } from "react";
import { createRoot } from "react-dom/client";
@ -78,6 +78,9 @@ function McpAppWrapper() {
},
});
// Apply host styling (theme, CSS variables, fonts)
useHostStyles(app);
// Get initial host context after connection
useEffect(() => {
if (app) {

View file

@ -30,9 +30,10 @@ type ThreeJSAppProps = WidgetProps<ThreeJSToolInput>;
// Default demo code shown when no code is provided
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setClearColor(0x1a1a2e);
// Transparent background - scene composites over host UI
renderer.setClearColor(0x000000, 0);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
@ -66,13 +67,6 @@ animate();`;
// Streaming Preview
// =============================================================================
const SHIMMER_STYLE = `
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
function LoadingShimmer({ height, code }: { height: number; code?: string }) {
const preRef = useRef<HTMLPreElement>(null);
@ -85,28 +79,25 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
style={{
width: "100%",
height,
borderRadius: 8,
borderRadius: "var(--border-radius-lg, 8px)",
padding: 16,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
overflow: "hidden",
background:
"linear-gradient(90deg, #1a1a2e 25%, #2d2d44 50%, #1a1a2e 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s ease-in-out infinite",
"linear-gradient(135deg, var(--color-background-secondary, light-dark(#f0f0f5, #2a2a3c)) 0%, var(--color-background-tertiary, light-dark(#e5e5ed, #1e1e2e)) 100%)",
}}
>
<style>{SHIMMER_STYLE}</style>
<div
style={{
color: "#888",
fontFamily: "system-ui",
color: "var(--color-text-tertiary, light-dark(#666, #888))",
fontFamily: "var(--font-sans, system-ui)",
fontSize: 12,
marginBottom: 8,
}}
>
🎮 Three.js
Three.js
</div>
{code && (
<pre
@ -116,10 +107,10 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
padding: 0,
flex: 1,
overflow: "auto",
color: "#aaa",
fontFamily: "monospace",
fontSize: 11,
lineHeight: 1.4,
color: "var(--color-text-ghost, light-dark(#777, #aaa))",
fontFamily: "var(--font-mono, monospace)",
fontSize: "var(--font-text-xs-size, 11px)",
lineHeight: "var(--font-text-xs-line-height, 1.4)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
@ -135,6 +126,43 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) {
// Three.js Execution
// =============================================================================
// Visibility-aware animation controller
function createAnimationController() {
let isVisible = true;
let pendingCallbacks: FrameRequestCallback[] = [];
let rafIds: number[] = [];
const visibilityAwareRAF = (callback: FrameRequestCallback): number => {
if (isVisible) {
const id = requestAnimationFrame(callback);
rafIds.push(id);
return id;
} else {
// Queue callback for when visible again
pendingCallbacks.push(callback);
return -1;
}
};
const setVisible = (visible: boolean) => {
isVisible = visible;
if (visible && pendingCallbacks.length > 0) {
// Resume queued animations
const callbacks = pendingCallbacks;
pendingCallbacks = [];
callbacks.forEach((cb) => visibilityAwareRAF(cb));
}
};
const cleanup = () => {
rafIds.forEach((id) => cancelAnimationFrame(id));
rafIds = [];
pendingCallbacks = [];
};
return { visibilityAwareRAF, setVisible, cleanup };
}
const threeContext = {
THREE,
OrbitControls,
@ -148,16 +176,18 @@ async function executeThreeCode(
canvas: HTMLCanvasElement,
width: number,
height: number,
visibilityAwareRAF: (callback: FrameRequestCallback) => number,
): Promise<void> {
const fn = new Function(
"ctx",
"canvas",
"width",
"height",
"requestAnimationFrame",
`const { THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass } = ctx;
return (async () => { ${code} })();`,
);
await fn(threeContext, canvas, width, height);
await fn(threeContext, canvas, width, height, visibilityAwareRAF);
}
// =============================================================================
@ -177,6 +207,7 @@ export default function ThreeJSApp({
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animControllerRef = useRef<ReturnType<typeof createAnimationController> | null>(null);
const height = toolInputs?.height ?? toolInputsPartial?.height ?? 400;
const code = toolInputs?.code || DEFAULT_THREEJS_CODE;
@ -191,14 +222,38 @@ export default function ThreeJSApp({
paddingLeft: safeAreaInsets?.left,
};
// Visibility-based pause/play
useEffect(() => {
if (!containerRef.current) return;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
animControllerRef.current?.setVisible(entry.isIntersecting);
});
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!code || !canvasRef.current || !containerRef.current) return;
// Cleanup previous animation
animControllerRef.current?.cleanup();
animControllerRef.current = createAnimationController();
setError(null);
const width = containerRef.current.offsetWidth || 800;
executeThreeCode(code, canvasRef.current, width, height).catch((e) =>
setError(e instanceof Error ? e.message : "Unknown error"),
);
executeThreeCode(
code,
canvasRef.current,
width,
height,
animControllerRef.current.visibilityAwareRAF,
).catch((e) => setError(e instanceof Error ? e.message : "Unknown error"));
return () => animControllerRef.current?.cleanup();
}, [code, height]);
if (isStreaming || !code) {
@ -221,9 +276,8 @@ export default function ThreeJSApp({
style={{
width: "100%",
height,
borderRadius: 8,
borderRadius: "var(--border-radius-lg, 8px)",
display: "block",
background: "#1a1a2e",
}}
/>
{error && <div className="error-overlay">Error: {error}</div>}