Skip to content

WIP: Add Jupyter notebook widget for WebGL viewer#608

Draft
mvdoc wants to merge 15 commits intogallantlab:mainfrom
mvdoc:claude/webgl-jupyter-widget-dvTkg
Draft

WIP: Add Jupyter notebook widget for WebGL viewer#608
mvdoc wants to merge 15 commits intogallantlab:mainfrom
mvdoc:claude/webgl-jupyter-widget-dvTkg

Conversation

@mvdoc
Copy link
Contributor

@mvdoc mvdoc commented Mar 23, 2026

Summary

  • Add cortex.webgl.jupyter module with two display methods for embedding the WebGL brain viewer in Jupyter notebooks:
    • display(data) — IFrame mode: starts Tornado server in background thread, embeds via IFrame. Full interactivity with WebSocket/JSMixer control.
    • display(data, method="static") — Static mode: generates self-contained viewer via local HTTP server. Returns StaticViewer handle with close() for cleanup.
    • make_notebook_html(data) — returns raw HTML string for custom embedding.
  • Reliable port selection via socket.bind(('', 0)) instead of random.randint
  • StaticViewer class with proper resource cleanup (HTTP server shutdown + temp dir removal)
  • Error handling: make_static failures produce clear RuntimeError, HTTP errors logged
  • Add nbsphinx to Sphinx docs to render example notebook with live WebGL viewer
  • 20 unit tests covering dispatch, port handling, lifecycle, cleanup, and error paths

New files

  • cortex/webgl/jupyter.py — main module
  • cortex/webgl/notebook.html — notebook-optimized HTML template
  • cortex/tests/test_jupyter_widget.py — tests
  • docs/notebooks/jupyter_notebook.ipynb — example notebook (executed at docs build time)

Test plan

  • python -m pytest cortex/tests/test_jupyter_widget.py -v — 20 tests pass
  • Verify notebook renders in docs build (cd docs && make html)
  • Test display(vol) in live Jupyter notebook
  • Test display(vol, method="static") in live Jupyter notebook
  • Test viewer.close() cleans up server and temp files

claude and others added 12 commits March 23, 2026 09:51
Implements two display methods for embedding pycortex WebGL brain
viewers in Jupyter notebooks:

- display_iframe: Embeds the Tornado-served viewer in an IFrame for
  full interactivity (surface morphing, data switching, WebSocket)
- display_static: Generates self-contained HTML served via a lightweight
  HTTP server, suitable for sharing

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Covers IFrame mode, static mode, programmatic control via JSMixer,
viewer customization options, and raw HTML generation.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Sphinx-gallery only processes files matching the /plot_ pattern.
Renamed jupyter_notebook.py -> plot_jupyter_notebook.py and added
sphinx_gallery_dummy_images directive since this example cannot
execute during doc builds (requires Jupyter + browser).

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
- Add nbsphinx extension to Sphinx conf with execute='never'
- Exclude auto_examples/*.ipynb to avoid conflict with sphinx-gallery
- Replace plot_jupyter_notebook.py with jupyter_notebook.ipynb
- Symlink notebook from examples/webgl/ into docs/notebooks/
- Add "Jupyter Notebooks" section to docs index toctree

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Restructured the notebook so the static viewer uses
make_notebook_html() + IPython.display.HTML with an srcdoc IFrame,
which persists the full 3D viewer in the cell output. The IFrame
mode and other server-dependent examples are shown as markdown
code blocks since they require a live Jupyter session.

Executed via nbconvert --execute --inplace so nbsphinx renders
the static viewer output directly in the built docs.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
The executed outputs embedded ~4MB of HTML in the .ipynb (74k lines).
Strip outputs so the notebook stays lightweight; nbsphinx is configured
with nbsphinx_execute = 'never' so the docs render the code cells
without re-executing.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Set nbsphinx_execute = 'always' so the notebook cells run during
sphinx-build. The committed .ipynb stays clean (no outputs).

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
- Fix invalid port 65536 by using socket.bind(('', 0)) to find free ports
- Remove dead html_content read in display_static
- Tighten import guard: only suppress ImportError when IPython is missing
- Add StaticViewer class with close() for server/tmpdir cleanup
- Wrap make_static in try/except with clear RuntimeError on failure
- Log HTTP 4xx/5xx errors instead of suppressing all server output
- Use TemporaryDirectory context manager in make_notebook_html
- Use port 0 for HTTPServer to get OS-assigned free port
- Expand tests from 10 to 20: dispatch, port handling, cleanup, errors
The previous approach embedded ~4MB of HTML in an iframe srcdoc
attribute, which broke JavaScript due to escaping issues. Now the
notebook calls make_static() to generate the full viewer directory
(HTML + CTM + data files) and references it via a regular IFrame src.

Added a build-finished hook in conf.py to copy the generated
static_viewer/ directory into the Sphinx build output.

Verified with Playwright: brain renders correctly in ~2s.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
@mvdoc mvdoc requested a review from kroq-gar78 March 23, 2026 17:36
@mvdoc
Copy link
Contributor Author

mvdoc commented Mar 23, 2026

The goal is to have something like this in a jupyter notebook. The widgets should allow that (need to test it)

image

(This is another one of those "I wonder if Claude can do it" things coded on my phone)

mvdoc added 2 commits March 23, 2026 10:41
The Jupyter notebook example requires nbsphinx (Sphinx extension),
ipykernel (notebook execution), and pandoc (notebook conversion)
to build during CI.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
The upstream CI workflow doesn't install nbsphinx. Make it optional:
only add the extension if importable, otherwise exclude the notebooks
directory so the build doesn't fail on .ipynb files.

Also keep the workflow update (nbsphinx + pandoc + ipykernel) for
when the workflow is merged upstream.

https://claude.ai/code/session_018vcqQBXvodW9oJozRwEpmd
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class Jupyter notebook support for embedding the cortex.webgl viewer, along with documentation and CI updates to build/render an example notebook in the Sphinx docs.

Changes:

  • Add cortex.webgl.jupyter with display() dispatch plus iframe/static helpers and a StaticViewer lifecycle handle.
  • Add a notebook-optimized WebGL HTML template and an example executed notebook in the docs.
  • Update docs build configuration and workflows to support rendering notebooks (nbsphinx + dependencies).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
cortex/webgl/jupyter.py New Jupyter integration module (iframe/static display + HTML generation helpers).
cortex/webgl/notebook.html New HTML template variant tailored for notebook iframe usage.
cortex/webgl/__init__.py Exposes cortex.webgl.jupyter when IPython is available.
cortex/tests/test_jupyter_widget.py Adds unit tests for dispatch, port selection, and static viewer lifecycle.
docs/notebooks/jupyter_notebook.ipynb New example notebook demonstrating usage.
docs/notebooks/.gitignore Ignores artifacts generated during notebook execution.
docs/index.rst Adds the notebook page to the docs toctree.
docs/conf.py Conditionally enables nbsphinx and copies notebook artifacts into build output.
.github/workflows/build_docs.yml Installs pandoc/nbsphinx/ipykernel and builds docs in CI.
docs/.github/workflows/build_docs.yml Adds a second (duplicate) docs workflow under docs/ (note: not a standard Actions location).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import tempfile
import threading

from IPython.display import HTML, IFrame
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTML is imported from IPython.display but never used in this module. This adds an unnecessary hard dependency at import-time and may trigger unused-import checks in downstream tooling; please remove it or use it.

Suggested change
from IPython.display import HTML, IFrame
from IPython.display import IFrame

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +77

def close(self):
"""Shut down the HTTP server and remove temp files."""
try:
self._httpd.shutdown()
except Exception:
logger.warning("Failed to shut down static viewer server", exc_info=True)
try:
shutil.rmtree(self._tmpdir, ignore_errors=True)
except Exception:
logger.warning(
"Failed to clean up temp dir %s", self._tmpdir, exc_info=True
)

def __del__(self):
try:
self._httpd.shutdown()
except Exception:
pass
try:
shutil.rmtree(self._tmpdir, ignore_errors=True)
except Exception:
pass
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StaticViewer.close() only calls HTTPServer.shutdown() but never calls server_close() and never joins the serving thread. This can leave the listening socket open longer than intended and makes cleanup less reliable; consider making close() idempotent, calling shutdown() + server_close(), and joining the thread with a short timeout.

Suggested change
def close(self):
"""Shut down the HTTP server and remove temp files."""
try:
self._httpd.shutdown()
except Exception:
logger.warning("Failed to shut down static viewer server", exc_info=True)
try:
shutil.rmtree(self._tmpdir, ignore_errors=True)
except Exception:
logger.warning(
"Failed to clean up temp dir %s", self._tmpdir, exc_info=True
)
def __del__(self):
try:
self._httpd.shutdown()
except Exception:
pass
try:
shutil.rmtree(self._tmpdir, ignore_errors=True)
except Exception:
pass
# Track shutdown state to make close() idempotent and thread-safe.
self._closed = False
self._lock = threading.Lock()
def close(self, timeout=1.0):
"""Shut down the HTTP server, wait for the thread, and remove temp files.
Parameters
----------
timeout : float, optional
Maximum number of seconds to wait for the server thread to finish.
"""
# Ensure idempotent, thread-safe shutdown.
try:
lock = self._lock
except AttributeError:
# If attributes are partially initialized or being torn down,
# fall back to best-effort cleanup without synchronization.
lock = None
if lock is not None:
with lock:
if getattr(self, "_closed", False):
return
self._closed = True
else:
if getattr(self, "_closed", False):
return
self._closed = True
# Stop the HTTP server and close its listening socket.
httpd = getattr(self, "_httpd", None)
if httpd is not None:
try:
httpd.shutdown()
httpd.server_close()
except Exception:
logger.warning(
"Failed to shut down static viewer server",
exc_info=True,
)
# Give the serving thread a chance to exit cleanly.
thread = getattr(self, "_thread", None)
if thread is not None and thread.is_alive():
try:
thread.join(timeout=timeout)
except Exception:
logger.warning(
"Failed to join static viewer server thread",
exc_info=True,
)
# Finally, remove the temporary directory.
tmpdir = getattr(self, "_tmpdir", None)
if tmpdir is not None:
try:
shutil.rmtree(tmpdir, ignore_errors=True)
except Exception:
logger.warning(
"Failed to clean up temp dir %s", tmpdir, exc_info=True
)
def __del__(self):
# Best-effort cleanup during garbage collection; avoid raising.
try:
self.close(timeout=0.1)
except Exception:
pass

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +16
Usage
-----
>>> import cortex
>>> vol = cortex.Volume.random("S1", "fullhead")
>>> cortex.webgl.jupyter.display(vol) # auto-detects best approach
"""
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module docstring example says cortex.webgl.jupyter.display(vol) “auto-detects best approach”, but display() defaults to method="iframe" and does not perform any auto-detection. Please update the docstring/example to match the actual behavior (or implement auto-detection if that’s intended).

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +169
"""Display brain data as a self-contained HTML viewer inline.

Generates a complete static viewer with all JS/CSS/data embedded,
then displays it in the notebook via a lightweight local HTTP server.

Note: The embedded HTML is large (~4-5MB) because all JavaScript
libraries and CSS are inlined.
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display_static() is documented as generating a “self-contained HTML viewer” with “all JS/CSS/data embedded” and as working in static notebook renderers, but view.make_static(..., html_embed=True) still writes CTM/JSON/PNG assets alongside index.html (and the HTML expects to fetch them). The docs should clarify that the output is a directory that must be served (and isn’t a single self-contained HTML string).

Suggested change
"""Display brain data as a self-contained HTML viewer inline.
Generates a complete static viewer with all JS/CSS/data embedded,
then displays it in the notebook via a lightweight local HTTP server.
Note: The embedded HTML is large (~4-5MB) because all JavaScript
libraries and CSS are inlined.
"""Display brain data using a temporary static WebGL viewer inline.
This function uses ``cortex.webgl.make_static`` to generate a *directory*
containing ``index.html`` plus all required JS/CSS/data assets, then serves
that directory via a lightweight local HTTP server and embeds it in the
notebook inside an ``<iframe>``.
Note
----
- The output is **not** a single, self-contained HTML string; it is a
static viewer directory that must be served for the page to function.
- This works in live Jupyter environments that can run a local HTTP server,
but most static notebook renderers will not execute the server and will
therefore not display the interactive viewer.

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +249
def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs):
"""Generate a self-contained HTML string for the WebGL viewer.

This is a lower-level function that returns the raw HTML string rather
than displaying it. Useful for saving or embedding in custom contexts.

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make_notebook_html() claims to return a “self-contained HTML string”, but it currently returns only the generated index.html while the viewer still depends on adjacent asset files produced by make_static() (CTM/JSON/PNGs). Either adjust the docstring to reflect that additional assets are required, or extend the implementation to truly inline those assets if self-contained HTML is required.

Copilot uses AI. Check for mistakes.
Comment on lines +230 to +237
httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler)
port = httpd.server_address[1]

thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()

iframe = IFrame(
src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The static-mode server is bound to 127.0.0.1 and the iframe src also uses 127.0.0.1. This will not work for common remote-notebook scenarios (JupyterHub/SSH remote kernels) because the browser’s 127.0.0.1 is the client machine, not the notebook server. Consider making the bind host / iframe host configurable (or using serve.hostname / a notebook-proxy approach) so this feature works beyond purely local notebooks.

Suggested change
httpd = http.server.HTTPServer(("127.0.0.1", 0), _QuietHandler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
iframe = IFrame(
src="http://127.0.0.1:%d/index.html" % port, width=width_str, height=height
# Host for the temporary HTTP server and iframe. Default to 127.0.0.1 for
# backwards compatibility, but allow override for remote/proxied setups.
host = os.environ.get("CORTEX_JUPYTER_STATIC_HOST", "127.0.0.1")
httpd = http.server.HTTPServer((host, 0), _QuietHandler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
iframe = IFrame(
src="http://%s:%d/index.html" % (host, port), width=width_str, height=height

Copilot uses AI. Check for mistakes.
Comment on lines +38 to 53
]

exclude_patterns = ["_build", "auto_examples/**/*.ipynb"]

# nbsphinx – render and execute Jupyter notebooks in docs
# Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed
try:
import nbsphinx # noqa: F401

extensions.append("nbsphinx")
nbsphinx_execute = "always"
except ImportError:
# nbsphinx not installed – exclude notebook sources so the build
# doesn't fail on unrecognised .ipynb files in the toctree
exclude_patterns.append("notebooks")

Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When nbsphinx isn’t installed, exclude_patterns.append("notebooks") hides the notebook sources, but docs/index.rst always includes notebooks/jupyter_notebook in the toctree. This will produce a “toctree contains reference to nonexisting document” warning/error. Either make nbsphinx a required docs dependency (remove the try/except) or conditionally include the notebook in the toctree.

Suggested change
]
exclude_patterns = ["_build", "auto_examples/**/*.ipynb"]
# nbsphinx – render and execute Jupyter notebooks in docs
# Only enable if nbsphinx (and its deps: pandoc, ipykernel) are installed
try:
import nbsphinx # noqa: F401
extensions.append("nbsphinx")
nbsphinx_execute = "always"
except ImportError:
# nbsphinx not installed – exclude notebook sources so the build
# doesn't fail on unrecognised .ipynb files in the toctree
exclude_patterns.append("notebooks")
"nbsphinx",
]
exclude_patterns = ["_build", "auto_examples/**/*.ipynb"]
nbsphinx_execute = "always"

Copilot uses AI. Check for mistakes.
docs/conf.py Outdated
Comment on lines +63 to +65
src = os.path.join(app.srcdir, "notebooks", "static_viewer")
dst = os.path.join(app.outdir, "notebooks", "static_viewer")
if os.path.isdir(src) and not os.path.isdir(dst):
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_copy_notebook_artifacts runs even when the build failed (it ignores the exception argument) and only copies if the destination directory doesn’t already exist. Consider returning early when exception is not None, and copying with overwrite semantics (e.g., remove existing dst first or use dirs_exist_ok=True) to avoid stale notebook artifacts on incremental builds.

Suggested change
src = os.path.join(app.srcdir, "notebooks", "static_viewer")
dst = os.path.join(app.outdir, "notebooks", "static_viewer")
if os.path.isdir(src) and not os.path.isdir(dst):
# Do not attempt to copy artifacts if the build failed.
if exception is not None:
return
src = os.path.join(app.srcdir, "notebooks", "static_viewer")
dst = os.path.join(app.outdir, "notebooks", "static_viewer")
if os.path.isdir(src):
# Remove any existing destination to avoid stale artifacts,
# then copy the current notebook static viewer files.
if os.path.isdir(dst):
shutil.rmtree(dst)

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +64
name: Build docs

on:
push:
branches:
- main
tags:
- '*'
pull_request:
branches:
- main
workflow_dispatch: # Manual trigger for publishing docs

jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.12

- uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y inkscape --no-install-recommends
pip install --upgrade pip
pip install wheel setuptools numpy cython
# force using latest nibabel
pip install -U nibabel
pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml
pip install -e . --no-build-isolation --group dev
python -c 'import cortex; print(cortex.__full_version__)'

- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }}

- name: Install Playwright Chromium
run: playwright install --with-deps --only-shell chromium

- name: Build documents
timeout-minutes: 20
run: |
cd docs && make html && cd ..
touch docs/_build/html/.nojekyll

- name: Publish to gh-pages if tagged
if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch'
uses: JamesIves/github-pages-deploy-action@v4.8.0
with:
branch: gh-pages
folder: docs/_build/html
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub Actions only loads workflows from the repository root at .github/workflows/. This workflow file under docs/.github/workflows/ won’t run and duplicates the root docs workflow, which can be confusing to maintain. Consider removing it or moving it to .github/workflows/ if it’s meant to be active.

Suggested change
name: Build docs
on:
push:
branches:
- main
tags:
- '*'
pull_request:
branches:
- main
workflow_dispatch: # Manual trigger for publishing docs
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.12
- uses: actions/cache@v5
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py', '**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y inkscape --no-install-recommends
pip install --upgrade pip
pip install wheel setuptools numpy cython
# force using latest nibabel
pip install -U nibabel
pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml
pip install -e . --no-build-isolation --group dev
python -c 'import cortex; print(cortex.__full_version__)'
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }}
- name: Install Playwright Chromium
run: playwright install --with-deps --only-shell chromium
- name: Build documents
timeout-minutes: 20
run: |
cd docs && make html && cd ..
touch docs/_build/html/.nojekyll
- name: Publish to gh-pages if tagged
if: startsWith(github.ref, 'refs/tags') || github.event_name == 'workflow_dispatch'
uses: JamesIves/github-pages-deploy-action@v4.8.0
with:
branch: gh-pages
folder: docs/_build/html
# NOTE: This file is intentionally not an active GitHub Actions workflow.
#
# GitHub Actions only loads workflows from the repository root at:
# .github/workflows/
#
# The actual docs build workflow lives in:
# .github/workflows/build_docs.yml
#
# This stub is kept only to avoid confusion if someone looks under
# docs/.github/workflows/. Do not add a `name:`, `on:`, or `jobs:` key here,
# as this file is not used by GitHub Actions.

Copilot uses AI. Check for mistakes.
mock_make_static.side_effect = self._fake_make_static
result = display_static("fake_data", width=800)

mock_display.assert_called_once()
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_width_int_converted doesn’t currently assert that the integer width is converted to a pixel string in the produced IFrame (it only asserts that display was called). To actually cover the behavior, inspect the IFrame argument (e.g., iframe_arg.width) or the StaticViewer.iframe and assert it matches the expected "px" format.

Suggested change
mock_display.assert_called_once()
mock_display.assert_called_once()
iframe_arg = mock_display.call_args[0][0]
self.assertEqual("800px", iframe_arg.width)

Copilot uses AI. Check for mistakes.
- Remove unused HTML import
- Make StaticViewer.close() idempotent with server_close() and thread.join()
- Add viewer registry with close_all() and atexit cleanup to prevent tmp buildup
- Fix misleading docstrings (auto-detect claim, self-contained HTML claims)
- Make static host configurable via CORTEX_JUPYTER_STATIC_HOST env var
- Add TOCTOU race documentation to _find_free_port
- Make nbsphinx a required docs dependency (fixes toctree mismatch)
- Fix _copy_notebook_artifacts to skip on failure and overwrite stale builds
- Remove misplaced docs/.github/workflows/build_docs.yml
- Fix test_width_int_converted to actually assert IFrame width
- Add tests for viewer registry and close_all
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants