From 2ddc08cd3aacec4cd17a53daca8c725612e95994 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 21 Feb 2026 08:56:48 -0700 Subject: [PATCH] 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. --- backend/src/spicebook/models/notebook.py | 2 + backend/src/spicebook/storage/filesystem.py | 41 ++++ docker-compose.dev.yml | 33 ++- frontend/src/components/NotebookCard.tsx | 17 +- .../src/components/ResistorColorGuide.astro | 199 ++++++++++++++++++ frontend/src/lib/types.ts | 2 + frontend/src/pages/index.astro | 6 + 7 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ResistorColorGuide.astro diff --git a/backend/src/spicebook/models/notebook.py b/backend/src/spicebook/models/notebook.py index 066e843..0dbbda8 100644 --- a/backend/src/spicebook/models/notebook.py +++ b/backend/src/spicebook/models/notebook.py @@ -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): diff --git a/backend/src/spicebook/storage/filesystem.py b/backend/src/spicebook/storage/filesystem.py index 0c264d0..df1e102 100644 --- a/backend/src/spicebook/storage/filesystem.py +++ b/backend/src/spicebook/storage/filesystem.py @@ -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) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 67ce6aa..3111439 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/frontend/src/components/NotebookCard.tsx b/frontend/src/components/NotebookCard.tsx index 329499b..40bbb7d 100644 --- a/frontend/src/components/NotebookCard.tsx +++ b/frontend/src/components/NotebookCard.tsx @@ -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 ( -
+ {schematic_svg ? ( +
+ ) : ( +
+ )}

@@ -43,6 +50,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {

+ {description && ( +

+ {description} +

+ )} +
diff --git a/frontend/src/components/ResistorColorGuide.astro b/frontend/src/components/ResistorColorGuide.astro new file mode 100644 index 0000000..c4ad0b4 --- /dev/null +++ b/frontend/src/components/ResistorColorGuide.astro @@ -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 = { + 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%' }, +]; +--- + +
+

Reference

+

Reading Resistor Color Codes

+

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

+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1st digit + Brown = 1 + + + + 2nd digit + Black = 0 + + + + Multiplier + Orange = ×1kΩ + + + + Tolerance + Gold = ±5% + + + + 10 kΩ ±5% + +
+ + +
+ + + + + + + + + + {digitBands.map((band) => ( + + + + + + ))} + +
ColorDigitMultiplier
+ + + {band.color} + + {band.digit}{band.multiplier}
+ + +
+

Tolerance

+
+ {toleranceBands.map((band) => ( + + + {band.color} + {band.value} + + ))} +
+
+
+ +
+
+ + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5596152..3ee6b15 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -34,6 +34,8 @@ export interface NotebookSummary { tags: string[]; cell_count: number; modified: string; + description?: string; + schematic_svg?: string; } export interface WaveformVariable { diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 010d090..772a649 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -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 { + +
+ +
+