diff --git a/pathview/data/registry.json b/pathview/data/registry.json
index 11555c3d..7d4ee5fa 100644
--- a/pathview/data/registry.json
+++ b/pathview/data/registry.json
@@ -579,148 +579,6 @@
"blockClass": "Interface",
"importPath": "pathsim.blocks",
"params": []
- },
- "Process": {
- "blockClass": "Process",
- "importPath": "pathsim_chem",
- "params": [
- "tau",
- "initial_value",
- "source_term"
- ]
- },
- "ResidenceTime": {
- "blockClass": "ResidenceTime",
- "importPath": "pathsim_chem",
- "params": [
- "tau",
- "betas",
- "gammas",
- "initial_value",
- "source_term"
- ]
- },
- "Splitter": {
- "blockClass": "Splitter",
- "importPath": "pathsim_chem",
- "params": [
- "fractions"
- ]
- },
- "Bubbler4": {
- "blockClass": "Bubbler4",
- "importPath": "pathsim_chem",
- "params": [
- "conversion_efficiency",
- "vial_efficiency",
- "replacement_times"
- ]
- },
- "GLC": {
- "blockClass": "GLC",
- "importPath": "pathsim_chem",
- "params": [
- "P_in",
- "L",
- "D",
- "T",
- "BCs",
- "g",
- "initial_nb_of_elements"
- ]
- },
- "CSTR": {
- "blockClass": "CSTR",
- "importPath": "pathsim_chem",
- "params": [
- "V",
- "F",
- "k0",
- "Ea",
- "n",
- "dH_rxn",
- "rho",
- "Cp",
- "UA",
- "C_A0",
- "T0"
- ]
- },
- "PFR": {
- "blockClass": "PFR",
- "importPath": "pathsim_chem",
- "params": [
- "N_cells",
- "V",
- "F",
- "k0",
- "Ea",
- "n",
- "dH_rxn",
- "rho",
- "Cp",
- "C0",
- "T0"
- ]
- },
- "HeatExchanger": {
- "blockClass": "HeatExchanger",
- "importPath": "pathsim_chem",
- "params": [
- "N_cells",
- "F_h",
- "F_c",
- "V_h",
- "V_c",
- "UA",
- "rho_h",
- "Cp_h",
- "rho_c",
- "Cp_c",
- "T_h0",
- "T_c0"
- ]
- },
- "FlashDrum": {
- "blockClass": "FlashDrum",
- "importPath": "pathsim_chem",
- "params": [
- "holdup",
- "antoine_A",
- "antoine_B",
- "antoine_C",
- "N0"
- ]
- },
- "Mixer": {
- "blockClass": "Mixer",
- "importPath": "pathsim_chem",
- "params": []
- },
- "Valve": {
- "blockClass": "Valve",
- "importPath": "pathsim_chem",
- "params": [
- "Cv"
- ]
- },
- "Heater": {
- "blockClass": "Heater",
- "importPath": "pathsim_chem",
- "params": [
- "rho",
- "Cp"
- ]
- },
- "PointKinetics": {
- "blockClass": "PointKinetics",
- "importPath": "pathsim_chem",
- "params": [
- "n0",
- "Lambda",
- "beta",
- "lam"
- ]
}
},
"events": {
diff --git a/pyproject.toml b/pyproject.toml
index 7a0b1461..836c18e9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,8 +20,7 @@ dependencies = [
"flask-cors>=4.0",
"numpy",
"waitress>=3.0",
- "pathsim",
- "pathsim-chem>=0.2.0",
+ "pathsim==0.20.0",
]
[project.optional-dependencies]
diff --git a/scripts/config/pathsim-chem/blocks.json b/scripts/config/pathsim-chem/blocks.json
deleted file mode 100644
index 288a291d..00000000
--- a/scripts/config/pathsim-chem/blocks.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "$schema": "../schemas/blocks.schema.json",
- "toolbox": "pathsim-chem",
- "importPath": "pathsim_chem",
-
- "categories": {
- "Chemical": [
- "Process",
- "ResidenceTime",
- "Splitter",
- "Bubbler4",
- "GLC",
- "CSTR",
- "PFR",
- "HeatExchanger",
- "FlashDrum",
- "Mixer",
- "Valve",
- "Heater",
- "PointKinetics"
- ]
- }
-}
diff --git a/scripts/config/requirements-pyodide.txt b/scripts/config/requirements-pyodide.txt
index 75190211..2e5efc5c 100644
--- a/scripts/config/requirements-pyodide.txt
+++ b/scripts/config/requirements-pyodide.txt
@@ -4,4 +4,3 @@
# For Pyodide runtime: "# optional" means installation failure won't block app
pathsim
-pathsim-chem>=0.2.0 # optional
diff --git a/scripts/extract.py b/scripts/extract.py
index bc473337..5ae23f8c 100644
--- a/scripts/extract.py
+++ b/scripts/extract.py
@@ -24,117 +24,20 @@
from pathlib import Path
from typing import Any
-
-# Optional docutils for RST to HTML conversion
-try:
- from docutils.core import publish_parts
- HAS_DOCUTILS = True
-except ImportError:
- HAS_DOCUTILS = False
- print("Warning: docutils not installed, docstrings will not be converted to HTML")
-
-
-# =============================================================================
-# Shared Utilities
-# =============================================================================
-
-def rst_to_html(rst_text: str) -> str:
- """Convert RST docstring to HTML, preserving LaTeX math for KaTeX rendering."""
- if not rst_text or not HAS_DOCUTILS:
- return ""
-
- # Clean the docstring - removes common leading whitespace from indented docstrings
- cleaned = inspect.cleandoc(rst_text)
-
- try:
- parts = publish_parts(
- cleaned,
- writer_name="html",
- settings_overrides={
- "report_level": 5,
- "halt_level": 5,
- "initial_header_level": 3,
- "math_output": "MathJax",
- }
- )
- return parts["body"]
- except Exception as e:
- print(f"Warning: Failed to convert docstring to HTML: {e}")
- return ""
-
-
-def extract_first_line(docstring: str) -> str:
- """Extract first line/sentence from docstring as description."""
- if not docstring:
- return ""
-
- lines = docstring.strip().split("\n")
- first_line = ""
- for line in lines:
- stripped = line.strip()
- if stripped:
- first_line = stripped
- break
-
- if ". " in first_line:
- first_line = first_line.split(". ")[0] + "."
-
- return first_line
-
-
-def extract_param_description(docstring: str, param_name: str) -> str:
- """Extract parameter description from docstring (RST format)."""
- if not docstring:
- return ""
-
- pattern = rf"{param_name}\s*:\s*[^\n]*\n\s+(.+?)(?=\n\s*\w+\s*:|\n\n|$)"
- match = re.search(pattern, docstring, re.DOTALL)
- if match:
- desc = match.group(1).strip()
- desc = re.sub(r"\s+", " ", desc)
- return desc
-
- return ""
-
-
-def infer_param_type(value: Any, param_name: str = "") -> str:
- """Infer parameter type from default value."""
- if param_name.startswith('func_') or param_name.startswith('func'):
- return "callable"
- if callable(value) and not isinstance(value, type):
- return "callable"
- if isinstance(value, bool):
- return "boolean"
- if isinstance(value, int):
- return "integer"
- if isinstance(value, float):
- return "number"
- if isinstance(value, str):
- return "string"
- if isinstance(value, (list, tuple)):
- return "array"
- if value is None:
- return "any"
- return "any"
-
-
-def format_default(value: Any) -> str | None:
- """Format default value for TypeScript."""
- if value is None:
- return None
- if callable(value) and not isinstance(value, type):
- return None
- if isinstance(value, bool):
- return "true" if value else "false"
- if isinstance(value, (int, float)):
- return repr(value)
- if isinstance(value, str):
- return f'"{value}"'
- if isinstance(value, (list, tuple)):
- return json.dumps(list(value))
- if isinstance(value, type):
- return f'"{value.__name__}"'
- return repr(value)
+# Single source of truth for block/event introspection — also inlined into
+# the runtime toolbox installer in src/lib/toolbox/python.ts via Vite ?raw.
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from pathview_introspect import ( # noqa: E402
+ rst_to_html,
+ first_line as extract_first_line,
+ param_desc as extract_param_description,
+ infer_type as infer_param_type,
+ format_default,
+ process_port_labels,
+ extract_block,
+ extract_event,
+ extract_params_from_signature,
+)
def format_default_py(value: Any) -> str | None:
diff --git a/scripts/generated/registry.json b/scripts/generated/registry.json
index 11555c3d..7d4ee5fa 100644
--- a/scripts/generated/registry.json
+++ b/scripts/generated/registry.json
@@ -579,148 +579,6 @@
"blockClass": "Interface",
"importPath": "pathsim.blocks",
"params": []
- },
- "Process": {
- "blockClass": "Process",
- "importPath": "pathsim_chem",
- "params": [
- "tau",
- "initial_value",
- "source_term"
- ]
- },
- "ResidenceTime": {
- "blockClass": "ResidenceTime",
- "importPath": "pathsim_chem",
- "params": [
- "tau",
- "betas",
- "gammas",
- "initial_value",
- "source_term"
- ]
- },
- "Splitter": {
- "blockClass": "Splitter",
- "importPath": "pathsim_chem",
- "params": [
- "fractions"
- ]
- },
- "Bubbler4": {
- "blockClass": "Bubbler4",
- "importPath": "pathsim_chem",
- "params": [
- "conversion_efficiency",
- "vial_efficiency",
- "replacement_times"
- ]
- },
- "GLC": {
- "blockClass": "GLC",
- "importPath": "pathsim_chem",
- "params": [
- "P_in",
- "L",
- "D",
- "T",
- "BCs",
- "g",
- "initial_nb_of_elements"
- ]
- },
- "CSTR": {
- "blockClass": "CSTR",
- "importPath": "pathsim_chem",
- "params": [
- "V",
- "F",
- "k0",
- "Ea",
- "n",
- "dH_rxn",
- "rho",
- "Cp",
- "UA",
- "C_A0",
- "T0"
- ]
- },
- "PFR": {
- "blockClass": "PFR",
- "importPath": "pathsim_chem",
- "params": [
- "N_cells",
- "V",
- "F",
- "k0",
- "Ea",
- "n",
- "dH_rxn",
- "rho",
- "Cp",
- "C0",
- "T0"
- ]
- },
- "HeatExchanger": {
- "blockClass": "HeatExchanger",
- "importPath": "pathsim_chem",
- "params": [
- "N_cells",
- "F_h",
- "F_c",
- "V_h",
- "V_c",
- "UA",
- "rho_h",
- "Cp_h",
- "rho_c",
- "Cp_c",
- "T_h0",
- "T_c0"
- ]
- },
- "FlashDrum": {
- "blockClass": "FlashDrum",
- "importPath": "pathsim_chem",
- "params": [
- "holdup",
- "antoine_A",
- "antoine_B",
- "antoine_C",
- "N0"
- ]
- },
- "Mixer": {
- "blockClass": "Mixer",
- "importPath": "pathsim_chem",
- "params": []
- },
- "Valve": {
- "blockClass": "Valve",
- "importPath": "pathsim_chem",
- "params": [
- "Cv"
- ]
- },
- "Heater": {
- "blockClass": "Heater",
- "importPath": "pathsim_chem",
- "params": [
- "rho",
- "Cp"
- ]
- },
- "PointKinetics": {
- "blockClass": "PointKinetics",
- "importPath": "pathsim_chem",
- "params": [
- "n0",
- "Lambda",
- "beta",
- "lam"
- ]
}
},
"events": {
diff --git a/scripts/pathview_introspect.py b/scripts/pathview_introspect.py
new file mode 100644
index 00000000..582e19bd
--- /dev/null
+++ b/scripts/pathview_introspect.py
@@ -0,0 +1,263 @@
+"""
+Shared block/event introspection used by both:
+
+ - scripts/extract.py at build time (for built-in pathsim blocks)
+ - src/lib/toolbox/python.ts at runtime (for user-installed toolboxes,
+ inlined into Pyodide via Vite's ?raw import)
+
+Single source of truth for: RST-docstring parsing, parameter type inference,
+default-value formatting, block/event class detection, and the canonical
+extracted-metadata dict shape consumed by the TypeScript registry layer.
+"""
+
+import inspect
+import json
+import re
+
+# --- Optional docutils for RST->HTML --------------------------------------
+
+_publish_parts = None
+_docutils_checked = False
+
+
+def rst_to_html(rst):
+ """Convert an RST docstring to HTML using docutils when available.
+
+ Returns "" if docutils isn't installed or conversion fails. Build-time
+ bundles docutils via requirements-build.txt; runtime falls back gracefully.
+ """
+ global _publish_parts, _docutils_checked
+ if not rst:
+ return ""
+ if not _docutils_checked:
+ _docutils_checked = True
+ try:
+ from docutils.core import publish_parts # type: ignore
+ _publish_parts = publish_parts
+ except Exception:
+ _publish_parts = None
+ if _publish_parts is None:
+ return ""
+ try:
+ cleaned = inspect.cleandoc(rst)
+ parts = _publish_parts(
+ cleaned,
+ writer_name="html",
+ settings_overrides={
+ "report_level": 5,
+ "halt_level": 5,
+ "initial_header_level": 3,
+ "math_output": "MathJax",
+ },
+ )
+ return parts.get("body") or ""
+ except Exception:
+ return ""
+
+
+def first_line(docstring):
+ """First sentence of a docstring (used as the short description)."""
+ if not docstring:
+ return ""
+ for line in docstring.strip().split("\n"):
+ s = line.strip()
+ if not s:
+ continue
+ if ". " in s:
+ return s.split(". ")[0] + "."
+ return s
+ return ""
+
+
+def param_desc(docstring, name):
+ """Extract a `:param name:` description from an RST docstring."""
+ if not docstring:
+ return ""
+ pattern = (
+ re.escape(name)
+ + r"\s*:\s*[^\n]*\n\s+(.+?)(?=\n\s*\w+\s*:|\n\n|$)"
+ )
+ m = re.search(pattern, docstring, re.DOTALL)
+ if m:
+ return re.sub(r"\s+", " ", m.group(1).strip())
+ return ""
+
+
+# --- Parameter helpers ----------------------------------------------------
+
+
+def infer_type(value, name=""):
+ """Infer the param type for a default value, matching ParamType in TS."""
+ if name and (name.startswith("func_") or name.startswith("func")):
+ return "callable"
+ if callable(value) and not isinstance(value, type):
+ return "callable"
+ if isinstance(value, bool):
+ return "boolean"
+ if isinstance(value, int):
+ return "integer"
+ if isinstance(value, float):
+ return "number"
+ if isinstance(value, str):
+ return "string"
+ if isinstance(value, (list, tuple)):
+ return "array"
+ return "any"
+
+
+def format_default(value):
+ """Format a default as a TypeScript-compatible source string (or None)."""
+ if value is None or value is inspect.Parameter.empty:
+ return None
+ if callable(value) and not isinstance(value, type):
+ return None
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, (int, float)):
+ return repr(value)
+ if isinstance(value, str):
+ return json.dumps(value)
+ if isinstance(value, (list, tuple)):
+ try:
+ return json.dumps(list(value))
+ except Exception:
+ return repr(list(value))
+ if isinstance(value, type):
+ return json.dumps(value.__name__)
+ try:
+ return repr(value)
+ except Exception:
+ return None
+
+
+def extract_params_from_signature(cls, docstring):
+ """Extract parameters by introspecting the class __init__ signature."""
+ out = []
+ try:
+ sig = inspect.signature(cls.__init__)
+ except (TypeError, ValueError):
+ return out
+ for pname, p in sig.parameters.items():
+ if pname == "self":
+ continue
+ if p.kind in (
+ inspect.Parameter.VAR_POSITIONAL,
+ inspect.Parameter.VAR_KEYWORD,
+ ):
+ continue
+ out.append({
+ "name": pname,
+ "default": format_default(p.default),
+ "type": infer_type(p.default, pname),
+ "description": param_desc(docstring, pname),
+ })
+ return out
+
+
+# --- Port-label normalisation --------------------------------------------
+
+
+def process_port_labels(labels):
+ """Normalise the `*_port_labels` value into the canonical shape:
+
+ - None -> None (variable/unlimited ports)
+ - {} -> [] (no ports of this type)
+ - dict -> sorted list of names
+ - list -> the list itself
+ """
+ if labels is None:
+ return None
+ if isinstance(labels, dict):
+ if not labels:
+ return []
+ return [name for name, _ in sorted(labels.items(), key=lambda x: x[1])]
+ if isinstance(labels, (list, tuple)):
+ return list(labels)
+ return None
+
+
+# --- Class detection (runtime needs heuristics) --------------------------
+
+
+def is_block(cls):
+ """Best-effort check: subclass of pathsim's Block."""
+ if not inspect.isclass(cls):
+ return False
+ for base in cls.__mro__[1:]:
+ if base.__name__ == "Block" and base.__module__.startswith("pathsim"):
+ return True
+ return False
+
+
+def is_event(cls):
+ """Best-effort check: pathsim Event subclass or *Event-named class."""
+ if not inspect.isclass(cls):
+ return False
+ name = cls.__name__
+ if name.startswith("_"):
+ return False
+ for base in cls.__mro__[1:]:
+ if base.__name__.endswith("Event") and "pathsim" in base.__module__:
+ return True
+ return name.endswith("Event")
+
+
+# --- Canonical extraction ------------------------------------------------
+
+
+def extract_block(cls):
+ """Extract canonical block metadata from a class.
+
+ Uses `cls.info()` when available (the convention for pathsim Block
+ subclasses); falls back to signature introspection otherwise.
+ """
+ raw_doc = cls.__doc__ or ""
+
+ info = None
+ info_fn = getattr(cls, "info", None)
+ if callable(info_fn):
+ try:
+ info = info_fn()
+ except Exception:
+ info = None
+
+ if info is not None:
+ rst = (info.get("description") or raw_doc).strip()
+ params = []
+ for pname, meta in (info.get("parameters") or {}).items():
+ default = meta.get("default") if isinstance(meta, dict) else None
+ params.append({
+ "name": pname,
+ "default": format_default(default),
+ "type": infer_type(default, pname),
+ "description": param_desc(rst, pname),
+ })
+ return {
+ "className": cls.__name__,
+ "description": first_line(rst),
+ "docstringHtml": rst_to_html(rst),
+ "inputs": process_port_labels(info.get("input_port_labels")),
+ "outputs": process_port_labels(info.get("output_port_labels")),
+ "params": params,
+ }
+
+ # Fallback: signature introspection + class attribute port labels
+ return {
+ "className": cls.__name__,
+ "description": first_line(raw_doc),
+ "docstringHtml": rst_to_html(raw_doc),
+ "inputs": process_port_labels(getattr(cls, "input_port_labels", None)),
+ "outputs": process_port_labels(getattr(cls, "output_port_labels", None)),
+ "params": extract_params_from_signature(cls, raw_doc),
+ }
+
+
+def extract_event(cls):
+ """Extract canonical event metadata from a class."""
+ raw_doc = cls.__doc__ or ""
+ return {
+ "className": cls.__name__,
+ "description": first_line(raw_doc),
+ "docstringHtml": rst_to_html(raw_doc),
+ "params": extract_params_from_signature(cls, raw_doc),
+ }
diff --git a/src/app.css b/src/app.css
index 042b823e..ae20dca8 100644
--- a/src/app.css
+++ b/src/app.css
@@ -242,6 +242,65 @@ input::placeholder {
color: var(--text-muted);
}
+/* Checkbox — overrides the generic input styles above */
+input[type="checkbox"] {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ padding: 0;
+ flex-shrink: 0;
+ background: var(--surface-raised);
+ border: 1px solid var(--border-focus);
+ border-radius: 3px;
+ cursor: pointer;
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+ transition: background var(--transition-fast), border-color var(--transition-fast);
+}
+
+input[type="checkbox"]:hover {
+ border-color: var(--accent);
+}
+
+input[type="checkbox"]:checked {
+ background: color-mix(in srgb, var(--accent) 18%, var(--surface-raised));
+ border-color: var(--accent);
+}
+
+input[type="checkbox"]:checked::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--accent);
+ -webkit-mask: url("data:image/svg+xml;utf8,") center / 10px 10px no-repeat;
+ mask: url("data:image/svg+xml;utf8,") center / 10px 10px no-repeat;
+}
+
+input[type="checkbox"]:indeterminate {
+ background: var(--surface-raised);
+ border-color: var(--accent);
+}
+
+input[type="checkbox"]:indeterminate::after {
+ content: "";
+ position: absolute;
+ left: 3px;
+ right: 3px;
+ top: 50%;
+ height: 2px;
+ background: var(--accent);
+ transform: translateY(-50%);
+ border-radius: 1px;
+}
+
+input[type="checkbox"]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
/* Panel style (solid background) */
.glass-panel {
background: var(--surface);
diff --git a/src/app.d.ts b/src/app.d.ts
index da08e6da..ef3fdab7 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -10,4 +10,9 @@ declare global {
}
}
+declare module '*?raw' {
+ const content: string;
+ export default content;
+}
+
export {};
diff --git a/src/lib/components/dialogs/ToolboxManagerDialog.svelte b/src/lib/components/dialogs/ToolboxManagerDialog.svelte
new file mode 100644
index 00000000..0a6c6423
--- /dev/null
+++ b/src/lib/components/dialogs/ToolboxManagerDialog.svelte
@@ -0,0 +1,1150 @@
+
+
+
+ You're about to run third-party code. PathView will install {resolvedDisplayName} and + import it into Pyodide. The code runs in your browser, sandboxed inside this tab, but it can still make + network requests, read clipboard data, or consume CPU and memory. +
+Only continue if you trust the source.
+pip install {resolvedSource.pkg}{resolvedSource.version ? `==${resolvedSource.version}` : ''}
+ {:else if resolvedSource?.type === 'url'}
+ {resolvedSource.url}
+ {:else if resolvedSource?.type === 'inline'}
+ local file {resolvedSource.filename} ({resolvedSource.code.length.toLocaleString()} chars)
+ {/if}
+