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