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.
This commit is contained in:
Ryan Malloy 2026-02-13 09:13:07 -07:00
parent 278946fb10
commit 9e5a826ca1
14 changed files with 353 additions and 1 deletions

View File

@ -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

View File

@ -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 . .

View File

@ -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:

View File

@ -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<HTMLIFrameElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const loadedRef = useRef(false);
const embedUrl = `${spicebookUrl}/embed/${sim.notebookId}`;
// Detect current theme from <html> 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 <html> 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 (
<div className="group relative rounded-xl border border-border bg-card overflow-hidden transition-all hover:shadow-md hover:border-primary/30">
<div className="p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 flex-1">
<h4 className="font-semibold text-card-foreground text-sm leading-tight">
{sim.title}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
{sim.description}
</p>
</div>
{sim.pageRef && (
<span className="shrink-0 text-[10px] font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
{sim.pageRef}
</span>
)}
</div>
<button
onClick={handleActivate}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium transition-colors"
>
<Play size={14} />
Run Simulation
</button>
</div>
{/* Decorative circuit trace along bottom */}
<div className="h-0.5 bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
</div>
);
}
// Active state — iframe loaded (or loading/error)
return (
<div className="rounded-xl border border-border bg-card overflow-hidden">
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${loaded ? 'bg-emerald-500' : error ? 'bg-red-400' : 'bg-amber-400 animate-pulse'}`} />
<span className="text-xs font-medium text-card-foreground truncate">
{sim.title}
</span>
{sim.pageRef && (
<span className="text-[10px] font-mono text-muted-foreground">
({sim.pageRef})
</span>
)}
</div>
<div className="flex items-center gap-1">
<a
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Open in SpiceBook"
>
<ExternalLink size={13} />
</a>
<button
onClick={() => setActive(false)}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors text-xs font-medium"
title="Close simulation"
>
&times;
</button>
</div>
</div>
{/* Iframe container with 16:10 aspect ratio */}
<div className="relative" style={{ aspectRatio: '16/10' }}>
{/* Loading state */}
{!loaded && !error && (
<div className="absolute inset-0 flex items-center justify-center sim-loading">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Cpu size={24} className="animate-spin motion-reduce:animate-none" style={{ animationDuration: '3s' }} />
<span className="text-xs">Loading simulation...</span>
</div>
</div>
)}
{/* Error/fallback state */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="flex flex-col items-center gap-3 text-center px-6">
<AlertCircle size={28} className="text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-card-foreground">Simulation coming soon</p>
<p className="text-xs text-muted-foreground">
This notebook is being prepared.{' '}
<a
href={`${spicebookUrl}/notebook/${sim.notebookId}`}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Try SpiceBook directly
</a>
</p>
</div>
<button
onClick={() => setActive(false)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to preview
</button>
</div>
</div>
)}
{/* 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. */}
<iframe
ref={iframeRef}
src={`${embedUrl}?theme=${getTheme()}`}
className={`w-full h-full border-0 ${loaded ? 'opacity-100' : 'opacity-0'}`}
style={{ transition: 'opacity 0.3s ease' }}
title={`${sim.title} — SpiceBook Simulation`}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
loading="lazy"
onLoad={handleLoad}
onError={() => setError(true)}
/>
</div>
</div>
);
}
export default function SimulationEmbed({ simulations, spicebookUrl }: SimulationEmbedProps) {
return (
<section className="space-y-4">
<div className="flex items-center gap-2.5">
<Cpu size={20} className="text-emerald-600 dark:text-emerald-400" />
<h3 className="text-lg font-semibold text-foreground">Interactive Simulations</h3>
</div>
<p className="text-sm text-muted-foreground">
Run live SPICE simulations of circuits from this notebook. Adjust component values and see waveforms update in real time.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{simulations.map((sim, idx) => (
<SimCard key={`${sim.notebookId}-${idx}`} sim={sim} spicebookUrl={spicebookUrl} />
))}
</div>
</section>
);
}

View File

@ -15,6 +15,15 @@ formats:
filename: "03_555_Timer_Circuits.pdf"
- type: "DjVu"
filename: "electronics - Forrest Mims-engineer's mini-notebook 555 timer circuits (radio shack electronics).djvu"
simulations:
- notebookId: "555-astable-blinker"
title: "555 Astable LED Blinker"
description: "Classic astable multivibrator — adjust R1, R2, C1 to set blink rate"
pageRef: "p. 8"
- notebookId: "555-monostable-pulse"
title: "555 Monostable Pulse Generator"
description: "One-shot timer producing precise pulse widths"
pageRef: "p. 4"
---
The 555 timer is one of the most versatile ICs ever made. This notebook shows you dozens of ways to use it, from simple LED blinkers to complex timing circuits.

View File

@ -17,6 +17,13 @@ formats:
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics).djvu"
- type: "Text"
filename: "electronics - Forrest Mims-engineer's mini-notebook basic semiconductor circuits (radio shack electronics)_djvu.txt"
simulations:
- notebookId: "common-emitter-amplifier"
title: "Common Emitter Amplifier"
description: "NPN amplifier with bias network — AC gain and DC operating point"
- notebookId: "voltage-divider"
title: "Resistive Voltage Divider"
description: "The most fundamental circuit — verify the ratio under load"
---
The foundational notebook covering transistor basics, diode applications, and simple semiconductor circuits. Perfect for beginners learning the fundamentals of solid-state electronics.

View File

@ -15,6 +15,14 @@ formats:
filename: "05_Communications_Projects.pdf"
- type: "DjVu"
filename: "Forrest Mims-Engineer's Mini-Notebook - Communications Projects (Radio Shack Electronics).djvu"
simulations:
- notebookId: "am-radio-receiver"
title: "AM Envelope Detector"
description: "Diode detector extracts audio from amplitude-modulated carrier"
pageRef: "p. 10"
- notebookId: "colpitts-oscillator"
title: "Colpitts RF Oscillator"
description: "LC oscillator startup and steady-state at ~1 MHz"
---
Build your own radio circuits! From simple crystal radios to FM transmitters and receivers, this notebook covers the fundamentals of wireless communication.

View File

@ -15,6 +15,13 @@ formats:
filename: "04_Formulas_Tables_Basic_Circuits.pdf"
- type: "DjVu"
filename: "Forrest Mims-Engineer's Mini-Notebook Formulas Tables Basic Circuits (Radio Shack Electronics).djvu"
simulations:
- notebookId: "rc-lowpass-filter"
title: "RC Lowpass Filter"
description: "First-order filter with Bode plot and step response"
- notebookId: "voltage-divider"
title: "Voltage Divider Under Load"
description: "What happens to the divider ratio when you connect a real load"
---
The ultimate pocket reference. Ohm's Law, resistor color codes, capacitor values, schematic symbols, and dozens of basic circuits - all in Mims' clear, hand-drawn style.

View File

@ -15,6 +15,15 @@ formats:
filename: "02_Op_Amp_IC_Circuits.pdf"
- type: "DjVu"
filename: "Forrest Mims-Engineer's Mini-Notebook Op Amp Ic Circuits (Radio Shack Electronics)(1).djvu"
simulations:
- notebookId: "inverting-op-amp"
title: "Inverting Amplifier"
description: "See gain, phase inversion, and bandwidth in one AC sweep"
pageRef: "p. 6"
- notebookId: "op-amp-comparator"
title: "Op-Amp Voltage Comparator"
description: "Threshold detection with hysteresis via positive feedback"
pageRef: "p. 14"
---
Master the versatile operational amplifier with circuits ranging from simple gain stages to precision instrumentation amplifiers.

View File

@ -15,6 +15,13 @@ formats:
filename: "08_Sensor_Projects.pdf"
- type: "DjVu"
filename: "Forrest Mims-engineer's mini-notebook sensor projects (radio shack electronics).djvu"
simulations:
- notebookId: "thermistor-bridge"
title: "Thermistor Bridge Circuit"
description: "Wheatstone bridge with NTC thermistor — voltage vs temperature"
- notebookId: "photodiode-amplifier"
title: "Photodiode Transimpedance Amplifier"
description: "Convert light intensity to voltage with op-amp current-to-voltage converter"
---
Sensors let your circuits interact with the real world. This notebook covers thermistors, photocells, microphones, and more - essential for any maker project.

View File

@ -19,6 +19,15 @@ const booksCollection = defineCollection({
type: z.string(),
filename: z.string(),
url: z.string().optional()
})).optional(),
simulations: z.array(z.object({
notebookId: z.string().min(1).max(100).regex(
/^[a-z0-9][a-z0-9-]*[a-z0-9]$/,
'notebookId must be lowercase alphanumeric with hyphens, no leading/trailing hyphens'
),
title: z.string(),
description: z.string(),
pageRef: z.string().optional(),
})).optional()
})
});

View File

@ -1,5 +1,12 @@
import type { CollectionEntry } from 'astro:content';
export interface SimulationRef {
notebookId: string;
title: string;
description: string;
pageRef?: string;
}
export interface BookData {
slug: string;
collection: 'mims' | 'uglys' | 'other';
@ -11,6 +18,7 @@ export interface BookData {
coverImage?: string;
year?: number;
sortOrder: number;
simulations?: SimulationRef[];
}
export function serializeBook(entry: CollectionEntry<'books'>): BookData {
@ -25,5 +33,6 @@ export function serializeBook(entry: CollectionEntry<'books'>): BookData {
coverImage: entry.data.coverImage,
year: entry.data.year,
sortOrder: entry.data.sortOrder,
simulations: entry.data.simulations,
};
}

View File

@ -2,6 +2,7 @@
import Layout from '@/layouts/Layout.astro';
import { Badge } from '@/components/ui/badge';
import EBookReader from '@/components/EBookReader';
import SimulationEmbed from '@/components/SimulationEmbed';
import { getCollection, type CollectionEntry } from 'astro:content';
import { getRelatedBooks } from '@/lib/related-books';
import RelatedBooks from '@/components/RelatedBooks.astro';
@ -22,7 +23,7 @@ interface Props {
}
const { book } = Astro.props;
const { title, shortTitle, description, topics, localPdf, coverImage, year, archiveOrgUrl, formats } = book.data;
const { title, shortTitle, description, topics, localPdf, coverImage, year, archiveOrgUrl, formats, simulations } = book.data;
// Get all mims books for navigation
const allBooks = await getCollection('books');
@ -151,6 +152,15 @@ const breadcrumbJsonLd: WithContext<BreadcrumbList> = {
/>
</div>
<!-- Interactive Simulations -->
{simulations && simulations.length > 0 && (
<SimulationEmbed
simulations={simulations}
spicebookUrl={import.meta.env.PUBLIC_SPICEBOOK_URL || 'https://spicebook.warehack.ing'}
client:visible
/>
)}
<!-- Related Books -->
<RelatedBooks relatedBooks={relatedBooks} currentCollection="mims" />

View File

@ -224,6 +224,27 @@
background: oklch(0.65 0.15 145);
}
/* Simulation loading animation — diagonal circuit-board-green stripes */
.sim-loading {
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
oklch(0.65 0.15 160 / 0.08) 10px,
oklch(0.65 0.15 160 / 0.08) 20px
);
animation: sim-loading-shift 1s linear infinite;
}
@keyframes sim-loading-shift {
to { background-position: 28px 0; }
}
@media (prefers-reduced-motion: reduce) {
.sim-loading {
animation: none;
}
}
/* Smooth theme transition */
html {
transition: background-color 0.15s ease, color 0.15s ease;