From 9e5a826ca1d83afff961c97eb42d988054485a36 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 09:13:07 -0700 Subject: [PATCH 1/2] Add SpiceBook simulation embeds for interactive circuit exploration SimulationEmbed React island with click-to-activate iframes, theme sync via postMessage/MutationObserver, and graceful fallback when SpiceBook is unavailable. 12 simulations across 6 books (555 timer, op-amp, semiconductor, formulas, communications, sensor). Books without simulations render no extra markup. client:visible hydration defers JS cost until scrolled into view. --- site/.env.example | 3 + site/Dockerfile | 2 + site/docker-compose.yml | 1 + site/src/components/SimulationEmbed.tsx | 250 ++++++++++++++++++ .../content/books/mims/555-timer-circuits.md | 9 + .../mims/basic-semiconductor-circuits.md | 7 + .../books/mims/communications-projects.md | 8 + .../mims/formulas-tables-basic-circuits.md | 7 + .../content/books/mims/op-amp-ic-circuits.md | 9 + .../src/content/books/mims/sensor-projects.md | 7 + site/src/content/config.ts | 9 + site/src/lib/types.ts | 9 + site/src/pages/mims/[slug].astro | 12 +- site/src/styles/global.css | 21 ++ 14 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 site/src/components/SimulationEmbed.tsx diff --git a/site/.env.example b/site/.env.example index 61bbe80..f2fcf41 100644 --- a/site/.env.example +++ b/site/.env.example @@ -13,3 +13,6 @@ CADDY_HOST=mims.localhost # Site URL for sitemap generation and OG meta tags # SITE_URL=https://forrest.warehack.ing + +# SpiceBook URL for interactive circuit simulations +# PUBLIC_SPICEBOOK_URL=https://spicebook.warehack.ing diff --git a/site/Dockerfile b/site/Dockerfile index e1540df..1d72787 100644 --- a/site/Dockerfile +++ b/site/Dockerfile @@ -21,7 +21,9 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] FROM base AS builder WORKDIR /app ARG SITE_URL=https://forrest.warehack.ing +ARG PUBLIC_SPICEBOOK_URL=https://spicebook.warehack.ing ENV SITE_URL=${SITE_URL} +ENV PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL} COPY package*.json ./ RUN npm ci COPY . . diff --git a/site/docker-compose.yml b/site/docker-compose.yml index 895e29a..07f9795 100644 --- a/site/docker-compose.yml +++ b/site/docker-compose.yml @@ -6,6 +6,7 @@ services: target: ${MODE:-production} args: - SITE_URL=${SITE_URL:-https://forrest.warehack.ing} + - PUBLIC_SPICEBOOK_URL=${PUBLIC_SPICEBOOK_URL:-https://spicebook.warehack.ing} container_name: mims-library restart: unless-stopped environment: diff --git a/site/src/components/SimulationEmbed.tsx b/site/src/components/SimulationEmbed.tsx new file mode 100644 index 0000000..7112c55 --- /dev/null +++ b/site/src/components/SimulationEmbed.tsx @@ -0,0 +1,250 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Cpu, Play, ExternalLink, AlertCircle } from 'lucide-react'; +import type { SimulationRef } from '@/lib/types'; + +interface SimulationEmbedProps { + simulations: SimulationRef[]; + spicebookUrl: string; +} + +interface SimCardProps { + sim: SimulationRef; + spicebookUrl: string; +} + +function SimCard({ sim, spicebookUrl }: SimCardProps) { + const [active, setActive] = useState(false); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + const iframeRef = useRef(null); + const timeoutRef = useRef>(); + const loadedRef = useRef(false); + + const embedUrl = `${spicebookUrl}/embed/${sim.notebookId}`; + + // Detect current theme from class + const getTheme = useCallback(() => { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + }, []); + + // Send theme to iframe via postMessage + const sendTheme = useCallback((theme: string) => { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage( + { type: 'spicebook-theme', theme }, + spicebookUrl + ); + } + }, [spicebookUrl]); + + // Watch for theme changes on element + useEffect(() => { + if (!active) return; + + const observer = new MutationObserver(() => { + sendTheme(getTheme()); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, [active, getTheme, sendTheme]); + + // Keep loadedRef in sync so the timeout can check current state + // without capturing a stale closure value + useEffect(() => { + loadedRef.current = loaded; + }, [loaded]); + + // Set a load timeout — if the iframe doesn't fire onload within 8s, + // assume SpiceBook is unreachable + useEffect(() => { + if (!active) return; + + timeoutRef.current = setTimeout(() => { + if (!loadedRef.current) setError(true); + }, 8000); + + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [active]); + + // Stop in-flight network requests when unmounting the iframe + useEffect(() => { + return () => { + if (iframeRef.current) { + iframeRef.current.src = 'about:blank'; + } + }; + }, []); + + const handleLoad = () => { + setLoaded(true); + setError(false); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + sendTheme(getTheme()); + }; + + const handleActivate = () => { + setActive(true); + setError(false); + setLoaded(false); + }; + + if (!active) { + // Preview card — click to activate + return ( +
+
+
+
+

+ {sim.title} +

+

+ {sim.description} +

+
+ {sim.pageRef && ( + + {sim.pageRef} + + )} +
+ + +
+ + {/* Decorative circuit trace along bottom */} +
+
+ ); + } + + // Active state — iframe loaded (or loading/error) + return ( +
+ {/* Header bar */} +
+
+
+ + {sim.title} + + {sim.pageRef && ( + + ({sim.pageRef}) + + )} +
+
+ + + + +
+
+ + {/* Iframe container with 16:10 aspect ratio */} +
+ {/* Loading state */} + {!loaded && !error && ( +
+
+ + Loading simulation... +
+
+ )} + + {/* Error/fallback state */} + {error && ( +
+
+ +
+

Simulation coming soon

+

+ This notebook is being prepared.{' '} + + Try SpiceBook directly + +

+
+ +
+
+ )} + + {/* SECURITY: allow-same-origin is required so SpiceBook can access its + own storage/cookies. Safe because the iframe src is a different origin. + The parent MUST validate origin on any future postMessage listener. + NOTE: iframe onError does NOT fire for HTTP 4xx/5xx responses — + the 8s timeout is the primary error detection mechanism. A postMessage + handshake with SpiceBook would provide reliable HTTP error detection. */} +