Add schematic thumbnails, descriptions to notebook cards and resistor color guide

Notebook cards now show schematic SVG thumbnails (on Mims green background)
and description snippets extracted from the first markdown cell. The backend
extracts both from already-loaded notebook data at zero additional I/O cost.

New ResistorColorGuide section between the pipeline strip and featured
notebooks explains the 4-band color code with an annotated zigzag diagram
and reference table using the same BAND_HEX colors from the schematic renderer.

Also switches docker-compose.dev.yml from direct port mapping to Caddy labels,
matching the production routing pattern and avoiding port conflicts.
This commit is contained in:
Ryan Malloy 2026-02-21 08:56:48 -07:00
parent fe6d20afbd
commit 2ddc08cd3a
7 changed files with 294 additions and 6 deletions

View File

@ -47,6 +47,8 @@ class NotebookSummary(BaseModel):
tags: list[str]
cell_count: int
modified: str
description: str | None = None
schematic_svg: str | None = None
class CreateNotebookRequest(BaseModel):

View File

@ -51,6 +51,44 @@ def _notebook_id_from_path(path: Path) -> str:
return path.stem
def _extract_card_data(nb: Notebook) -> tuple[str | None, str | None]:
"""Extract a description snippet and schematic SVG from notebook cells.
- description: text from the first markdown cell, with the title header
stripped and truncated to ~200 characters.
- schematic_svg: SVG string from the first successful schematic output.
"""
description: str | None = None
schematic_svg: str | None = None
for cell in nb.cells:
if description is None and cell.type == CellType.MARKDOWN and cell.source.strip():
lines = cell.source.strip().splitlines()
# Strip leading "# Title" header line
if lines and lines[0].lstrip().startswith("#"):
lines = lines[1:]
text = " ".join(line.strip() for line in lines if line.strip())
# Strip basic markdown syntax
text = re.sub(r"[*_`~\[\]#>]", "", text).strip()
if text:
description = text[:200].rstrip()
if schematic_svg is None:
for output in cell.outputs:
if (
output.output_type == "schematic"
and output.data.get("success")
and output.data.get("svg")
):
schematic_svg = output.data["svg"]
break
if description is not None and schematic_svg is not None:
break
return description, schematic_svg
def list_notebooks(directory: Path) -> list[NotebookSummary]:
"""List all notebooks in the directory and its examples/ subdirectory."""
summaries: list[NotebookSummary] = []
@ -66,6 +104,7 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
try:
nb = _read_notebook_file(path)
description, schematic_svg = _extract_card_data(nb)
summaries.append(NotebookSummary(
id=nb_id,
title=nb.metadata.title,
@ -73,6 +112,8 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
tags=nb.metadata.tags,
cell_count=len(nb.cells),
modified=nb.metadata.modified,
description=description,
schematic_svg=schematic_svg,
))
except Exception:
logger.warning("Skipping corrupt notebook: %s", path)

View File

@ -4,23 +4,48 @@ services:
context: ./backend
dockerfile: Dockerfile
target: dev
ports:
- "${BACKEND_PORT:-8099}:8000"
expose:
- "8000"
volumes:
- ./backend/src:/app/src
- ./notebooks:/app/notebooks
# Mount mcltspice for live development
- ../mcp-ltspice/src/mcltspice:/app/mcltspice-lib:ro
command: ["uv", "run", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
ports:
- "${FRONTEND_PORT:-4321}:4321"
expose:
- "4321"
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.reverse_proxy_1: "{{upstreams 4321}}"
caddy.reverse_proxy_1.flush_interval: "-1"
caddy.reverse_proxy_1.transport: "http"
caddy.reverse_proxy_1.transport.read_timeout: "0"
caddy.reverse_proxy_1.transport.write_timeout: "0"
caddy.reverse_proxy_1.transport.keepalive: "5m"
caddy.reverse_proxy_1.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy_1.stream_timeout: "24h"
caddy.reverse_proxy_1.stream_close_delay: "5s"
networks:
caddy:
external: true

View File

@ -25,14 +25,21 @@ interface NotebookCardProps {
}
export function NotebookCard({ notebook }: NotebookCardProps) {
const { id, title, engine, tags, cell_count, modified } = notebook;
const { id, title, engine, tags, cell_count, modified, description, schematic_svg } = notebook;
return (
<a
href={`/notebook/${encodeURIComponent(id)}`}
className="group block rounded-lg border border-slate-800 bg-slate-900/50 overflow-hidden hover:border-blue-500/40 hover:bg-slate-800/70 transition-all"
>
<div className="notebook-card-strip" data-engine={engine} />
{schematic_svg ? (
<div
className="w-full h-[120px] bg-[#f8faf6] border-b border-slate-800/60 overflow-hidden flex items-center justify-center [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
dangerouslySetInnerHTML={{ __html: schematic_svg }}
/>
) : (
<div className="notebook-card-strip" data-engine={engine} />
)}
<div className="p-5">
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
@ -43,6 +50,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
</Badge>
</div>
{description && (
<p className="text-sm text-slate-400 leading-relaxed mb-2 line-clamp-2">
{description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-slate-500 mb-3">
<span className="inline-flex items-center gap-1">
<LayoutGrid className="w-3 h-3" />

View File

@ -0,0 +1,199 @@
---
/**
* Resistor Color Code Guide — educational reference section.
*
* Left column: annotated SVG showing a 10k resistor zigzag with labeled bands.
* Right column: compact reference table mapping colors to digits and multipliers.
*
* Colors match BAND_HEX from resistor_colors.py so dots match schematic rendering.
*/
// BAND_HEX values from backend/src/spicebook/engine/resistor_colors.py
const bandHex: Record<string, string> = {
black: '#1a1a1a',
brown: '#8B4513',
red: '#CC0000',
orange: '#E67300',
yellow: '#B8960B',
green: '#006400',
blue: '#0000CC',
violet: '#7B00B3',
gray: '#666666',
white: '#D0D0D0',
gold: '#B8860B',
silver: '#808080',
};
const digitBands = [
{ color: 'Black', hex: bandHex.black, digit: 0, multiplier: '×1 \u03A9' },
{ color: 'Brown', hex: bandHex.brown, digit: 1, multiplier: '×10 \u03A9' },
{ color: 'Red', hex: bandHex.red, digit: 2, multiplier: '×100 \u03A9' },
{ color: 'Orange', hex: bandHex.orange, digit: 3, multiplier: '×1 k\u03A9' },
{ color: 'Yellow', hex: bandHex.yellow, digit: 4, multiplier: '×10 k\u03A9' },
{ color: 'Green', hex: bandHex.green, digit: 5, multiplier: '×100 k\u03A9' },
{ color: 'Blue', hex: bandHex.blue, digit: 6, multiplier: '×1 M\u03A9' },
{ color: 'Violet', hex: bandHex.violet, digit: 7, multiplier: '×10 M\u03A9' },
{ color: 'Gray', hex: bandHex.gray, digit: 8, multiplier: '\u2014' },
{ color: 'White', hex: bandHex.white, digit: 9, multiplier: '\u2014' },
];
const toleranceBands = [
{ color: 'Gold', hex: bandHex.gold, value: '\u00B15%' },
{ color: 'Silver', hex: bandHex.silver, value: '\u00B110%' },
];
---
<section class="max-w-6xl mx-auto px-6 py-16">
<p class="text-sm font-semibold tracking-widest text-blue-400 uppercase mb-2">Reference</p>
<h2 class="text-2xl font-bold text-slate-100 mb-3">Reading Resistor Color Codes</h2>
<p class="text-sm text-slate-400 mb-8 max-w-2xl leading-relaxed">
Resistors in SpiceBook schematics use color-coded bands — the same EIA standard
printed on physical components. Each colored segment of the zigzag symbol encodes
a digit, multiplier, or tolerance value.
</p>
<div class="grid gap-8 lg:grid-cols-[1fr_1fr] items-start">
<!-- Left: Annotated resistor diagram -->
<div class="rcg-diagram-card">
<svg viewBox="0 0 460 200" xmlns="http://www.w3.org/2000/svg" class="w-full h-auto" aria-label="Annotated 10k ohm resistor showing 4 color bands">
<!-- Mims graph paper background -->
<defs>
<pattern id="rcg-minor" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M 10 0 V 10 H 0" fill="none" stroke="#d4e4d4" stroke-width="0.3"/>
</pattern>
<pattern id="rcg-major" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 V 50 H 0" fill="none" stroke="#b0ccb0" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="460" height="200" fill="#f8faf6" rx="4"/>
<rect width="460" height="200" fill="url(#rcg-minor)" rx="4"/>
<rect width="460" height="200" fill="url(#rcg-major)" rx="4"/>
<!-- Lead wires -->
<line x1="40" y1="100" x2="100" y2="100" stroke="#555" stroke-width="2"/>
<line x1="360" y1="100" x2="420" y2="100" stroke="#555" stroke-width="2"/>
<!-- Zigzag body — 7 segments like backend mapping -->
<!-- seg 0: entry half-rise (wire) -->
<polyline points="100,100 120,72" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 1: Band 1 - Brown (1st digit = 1) -->
<polyline points="120,72 160,128" fill="none" stroke={bandHex.brown} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 2: Band 2 - Black (2nd digit = 0) -->
<polyline points="160,128 200,72" fill="none" stroke={bandHex.black} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 3: Band 3 - Orange (multiplier = ×1kΩ) -->
<polyline points="200,72 240,128" fill="none" stroke={bandHex.orange} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 4: gap (wire) -->
<polyline points="240,128 280,72" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 5: Band 4 - Gold (tolerance = ±5%) -->
<polyline points="280,72 320,128" fill="none" stroke={bandHex.gold} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 6: exit half-rise (wire) -->
<polyline points="320,128 340,100" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Lead wire continuation -->
<line x1="340" y1="100" x2="360" y2="100" stroke="#555" stroke-width="2"/>
<!-- Callout labels with lines -->
<!-- Band 1: Brown -->
<line x1="140" y1="68" x2="140" y2="36" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="140" y="30" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">1st digit</text>
<text x="140" y="18" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.brown}>Brown = 1</text>
<!-- Band 2: Black -->
<line x1="180" y1="132" x2="180" y2="164" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="180" y="175" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">2nd digit</text>
<text x="180" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill="#444">Black = 0</text>
<!-- Band 3: Orange -->
<line x1="220" y1="68" x2="220" y2="36" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="220" y="30" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">Multiplier</text>
<text x="220" y="18" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.orange}>Orange = ×1k&#937;</text>
<!-- Band 4: Gold -->
<line x1="300" y1="132" x2="300" y2="164" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="300" y="175" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">Tolerance</text>
<text x="300" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.gold}>Gold = ±5%</text>
<!-- Result value label -->
<rect x="360" y="58" width="90" height="24" rx="3" fill="#f0f2ee" stroke="#b0ccb0" stroke-width="0.8"/>
<text x="405" y="74" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" fill="#333" font-weight="700">10 k&#937; ±5%</text>
</svg>
</div>
<!-- Right: Color code reference table -->
<div class="rcg-table-card">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700/60">
<th class="rcg-th text-left py-2.5 pr-2">Color</th>
<th class="rcg-th text-center py-2.5 px-2">Digit</th>
<th class="rcg-th text-left py-2.5 pl-2">Multiplier</th>
</tr>
</thead>
<tbody>
{digitBands.map((band) => (
<tr class="border-b border-slate-800/40">
<td class="py-1.5 pr-2">
<span class="inline-flex items-center gap-2">
<span class="rcg-dot" style={`background: ${band.hex};`}></span>
<span class="text-slate-300 text-xs">{band.color}</span>
</span>
</td>
<td class="text-center py-1.5 px-2 text-slate-300 font-mono text-xs">{band.digit}</td>
<td class="py-1.5 pl-2 text-slate-400 font-mono text-xs">{band.multiplier}</td>
</tr>
))}
</tbody>
</table>
<!-- Tolerance row -->
<div class="mt-4 pt-3 border-t border-slate-700/60">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Tolerance</p>
<div class="flex gap-6">
{toleranceBands.map((band) => (
<span class="inline-flex items-center gap-2">
<span class="rcg-dot" style={`background: ${band.hex};`}></span>
<span class="text-slate-300 text-xs">{band.color}</span>
<span class="text-slate-400 font-mono text-xs">{band.value}</span>
</span>
))}
</div>
</div>
</div>
</div>
</section>
<style>
.rcg-diagram-card {
border-radius: 0.5rem;
border: 1px solid #1e293b;
overflow: hidden;
background: #f8faf6;
}
.rcg-table-card {
border-radius: 0.5rem;
border: 1px solid #1e293b;
background: rgba(15, 23, 42, 0.5);
padding: 1.25rem;
}
.rcg-th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
.rcg-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(148, 163, 184, 0.2);
}
</style>

View File

@ -34,6 +34,8 @@ export interface NotebookSummary {
tags: string[];
cell_count: number;
modified: string;
description?: string;
schematic_svg?: string;
}
export interface WaveformVariable {

View File

@ -3,6 +3,7 @@ import { Icon } from 'astro-icon/components';
import NotebookLayout from '../layouts/NotebookLayout.astro';
import NotebookGallery from '../components/NotebookGallery';
import PipelineStrip from '../components/PipelineStrip.astro';
import ResistorColorGuide from '../components/ResistorColorGuide.astro';
import FeaturedNotebooks from '../components/FeaturedNotebooks.astro';
import OscilloscopeDisplay from '../components/OscilloscopeDisplay.astro';
import { fetchNotebookList } from '../lib/server-api';
@ -72,6 +73,11 @@ try {
<PipelineStrip />
</section>
<!-- Resistor Color Code Guide -->
<section class="border-b border-slate-800/60">
<ResistorColorGuide />
</section>
<!-- Featured Notebooks -->
<section class="border-b border-slate-800/60">
<FeaturedNotebooks />