bridge_cfg.auto_show = True
bridge_cfg.auto_id = TrueBridget
Why Bridget?
Jupyter notebooks serve two primary functions: 1. Exploratory/Educational: Interactive computing and learning 2. Development: Literate programming and package creation (via nbdev)
While code creation is well-served by various notebook environments (Jupyter, VSCode, Marimo, etc.), the display and interaction capabilities are often limited by IPython’s well-thought but basic and aging display system. Modern development requires:
- Rich data visualization
- Interactive system integration
- Better control over output rendering
The Front-end Challenge
Notebooks already run in browsers (or browser-like environments), giving us access to powerful HTML/JavaScript capabilities. The front-end and kernel are inherently connected, suggesting we shouldn’t need additional communication layers.
However, the notebook ecosystem is fragmented: - Jupyter (nbclassic, Notebook, Lab): ipywidgets - VSCode/Cursor: Extensions - Marimo: Custom solutions - Google Colab: Proprietary package
This fragmentation forces users needing better display solutions to deal with complex, environment-specific tooling.
Enter FastHTML and HTMX
FastHTML provides an innovative approach to web apps, emphasizing HTML-first development through HTMX. While Jupyter support isn’t its core focus, FastHTML itself is developed using nbdev in notebooks.
Current FastHTML notebook integration: - Launches a separate Uvicorn server - Connects HTMX via standard HTTP/AJAX - Works across most notebook variants
This approach is general, clean and lightweight but involves spawning a full HTTP server with: - Multi-user capabilities (unnecessary for notebooks) - Async architecture (notebooks are sync) - Complex lifecycle management - Production-level features (overkill for notebook use) - IPython Javascript display object in each cell to trigger HTMX.
The Bridget Solution
Bridget proposes using a widget-based approach that: 1. Simplifies: Replaces HTTP server with direct widget communication 2. Generalizes: Works across notebook environments via AnyWidget 3. Extends: Enables creation of notebook-specific components 4. Integrates: Provides Python API for HTMX functionality
This proof-of-concept shows how we can maintain FastHTML’s powerful features while better adapting to the notebook environment’s unique characteristics and constraints.
Personal note: My interest here isn’t web apps, but notebook development.
I like ipywidgets, or at least the intention. I’ve written several and used them in many personal projects. However, they’re a challenging piece of software with complicated tooling. They inherit all the nightmarish complexity of the JavaScript ecosystem, where the tooling is more involved than the language itself. AnyWidget is a step in the right direction, liberating us Pythonistas from the JS ecosystem. But the notebook part, the Python part, remains an unsolved problem in my opinion.
Of all solutions I’ve explored for achieving full interactivity in notebooks, HTMX + FastHTML comes closest, feeling more natural and integrating better with the notebook environment.
These flags control automatic behaviors throughout this notebook. See 01_helpers.ipynb for full configuration options.
Helpers
Some convenience utils to work with FastHTML in Notebooks.
# nb = get_nb(show_logger=True, show_feedback=True, summary=True)
# bridge = get_bridge(plugins=[HTMXCommanderPlugin()], wait=5)
bridge = get_bridge(plugins=[HTMXCommanderPlugin(), NBHooksPlugin()], show_logger=True, summary=True, wait=5)Bridget automatically loads required JavaScript libraries:
- HTMX
- FastHTML core scripts
- Awesome gnat’s Scope and Surreal scripts
- Bridge helpers
These are loaded by default when creating a Bridget app. You can customize this by overriding defaults when creating the app (covered in app creation section)
ClientP
ClientP
ClientP (*args, **kwargs)
HTTP client interface supporting REST operations (get, post, delete)
Bridget utils
request2httpx_request
request2httpx_request (cli:httpx.AsyncClient, http_request:dict[str,typing.Any])
Convert bridget request dict to httpx Request object
httpx_response_to_json
httpx_response_to_json (response:httpx.Response)
Convert httpx Response to JSON dict with headers and content
request2response
request2response (cli:httpx.AsyncClient, http_request)
Execute bridget request and return httpx Response
HasHTML
HasHTML (*args, **kwargs)
Objects that can render themselves as HTML strings
HasFT
HasFT (*args, **kwargs)
Objects that can convert themselves to FastHTML FT components
@FC.patch
def _ipython_display_(self: Response):
# dhdl = DisplayId()
# dhdl.display(self.text)
IDISPLAY(HTML(self.text))BridgetClient
A mixin class that wraps FastHTML and Client functionality for displaying and mounting components.
Display: - FT & objects with __ft__ or __html__ - Mappings in the form of HTTP requests - URLs
Mount: - APIRouter or RouteProvider
BridgetClient
BridgetClient ()
A simple wrapper around FastHTML and Client.
BridgeClient is a mixin class that provides:
- Client functionality:
- Display functionality: Renders FastHTML objects and route responses in notebook cells
- Component mounting: Helper for mounting route providers
It serves as a mixin class for Bridget, which provides the full HTMX integration.
The class handles different types of “bridgeable” content: - FastHTML objects (FT) - Objects with __ft__ or __html__ methods - Route paths - Request mappings
brt_cli = BridgetClient().setup()
app, rt = brt_cli.app, brt_cli.app.route
brt_cli();@app.get("/") # type: ignore
def home():
# return "<strong>Config</strong>: " + str(bridge_cfg.as_dict())
# return Span(Strong('Config'), ': ' + str(bridge_cfg.as_dict()))
return DetailsJSON(bridge_cfg.as_dict(), summary='Bridget Config').__ft__()
brt_cli();@rt("/hi")
def get():
return 'Hi there'
http_request = {
'upload': {},
'headers': {
'HX-Request': 'true',
'HX-Current-URL': 'vscode-webview://1ql27b...er'
},
'headerNames': {'hx-request': 'HX-Request', 'hx-current-url': 'HX-Current-URL'},
'status': 0,
'method': 'GET',
'url': '/hi',
'async': True,
'timeout': 0,
'withCredentials': False,
'body': None
}
response = brt_cli._response(http_request)
test_eq(response.status_code, 200)
test_eq(response.text, 'Hi there')
console.print_json(data=(json_resp := httpx_response_to_json(response)))
test_eq(json_resp['status'], 200)
test_eq(json_resp['data'], 'Hi there')
test_eq(json_resp['headers']['content-length'], '8')
response{ "headers": { "vary": "HX-Request, HX-History-Restore-Request", "content-length": "8", "content-type": "text/html; charset=utf-8", "last-modified": "Wed, 03 Dec 2025 14:22:36 GMT", "cache-control": "no-store, no-cache, must-revalidate" }, "status": 200, "statusText": "OK", "data": "Hi there", "xml": null, "finalUrl": "http://nb/hi" }
brt_cli(http_request);class Buttons:
def __ft__(self):
return (
Button(garlic=True, hx_get='/test', hx_select='button[vampire]', hx_swap='afterend')(_n,
Style(self._css_.format('hsl(264 80% 47%)', 'hsl(264 80% 60%)')),
'garlic ', Span('🧄', cls='icon'),
_n), _n,
Button(vampire=True, hx_get='/test', hx_select='button[garlic]', hx_swap='afterend')(_n,
Style(self._css_.format('hsl(150 80% 47%)', 'hsl(150 80% 60%)')),
'vampire ', Span('🧛', cls='icon'),
_n), _n,
)
_css_ = '''
me {{ margin: 4px; padding: 10px 30px; min-width: 80px; background: {0}; border-bottom: 0.5rem solid hsl(264 80% 20%); }}
me {{ color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }}
me:hover {{ background: {1}; }}
me span.icon {{ font-size:16pt; }}
'''@rt("/test")
def get():
return Buttons()
http_request = {
'headers': {'hx-request': '1'},
'method': 'GET',
'url': 'http://nb/test',
}
r = brt_cli._response(http_request)
display(Markdown(f"```HTML\n{r.text}\n```"))
r<button garlic hx-get="/test" hx-select="button[vampire]" hx-swap="afterend">
<style>
me { margin: 4px; padding: 10px 30px; min-width: 80px; background: hsl(264 80% 47%); border-bottom: 0.5rem solid hsl(264 80% 20%); }
me { color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }
me:hover { background: hsl(264 80% 60%); }
me span.icon { font-size:16pt; }
</style>
garlic <span class="icon">🧄</span>
</button>
<button vampire hx-get="/test" hx-select="button[garlic]" hx-swap="afterend">
<style>
me { margin: 4px; padding: 10px 30px; min-width: 80px; background: hsl(150 80% 47%); border-bottom: 0.5rem solid hsl(264 80% 20%); }
me { color: antiquewhite; font-size: 14pt; font-variant: all-small-caps; font-weight: bold; }
me:hover { background: hsl(150 80% 60%); }
me span.icon { font-size:16pt; }
</style>
vampire <span class="icon">🧛</span>
</button>brt_cli('/test');brt_cli(Buttons());# css = Style(':root {--pico-font-size:90%,--pico-font-family: Pacifico, cursive;}')
@app.route("/page")
def get():
return (Title("Hello World"),
Main(H1('Hello, World'), cls="container"))
r = brt_cli._response(req := {
# 'headers': {'hx-request': '1'},
'method': 'GET',
'url': 'http://nb/page',
})
display(Markdown(f"```HTML\n{r.text}\n```"))
brt_cli('page'); <!doctype html>
<html>
<head>
<title>Hello World</title>
<link rel="canonical" href="http://nb/page">
<script>
function sendmsg() {
window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');
}
window.onload = function() {
sendmsg();
document.body.addEventListener('htmx:afterSettle', sendmsg);
document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
};</script> </head>
<body>
<main class="container"> <h1>Hello, World</h1>
</main> </body>
</html>Hello, World
dtl = Details(cls='pale', open=True)(
Style('me details { border: 1px solid #aaa; padding: 0.5em 0.5em 0; } me summary { font-weight: bold; margin: -0.5em -0.5em 0; padding: 0.5em; } me pre { margin: 0; }'),
Summary('What Lucy get?'),
Div(cls='contents', style='display: flex; flex-direction: column;')(
Pre('She'),
Pre('got'),
Pre('diamonds!')
)
)
display(dtl)
brt_cli(dtl);What Lucy get?
She
got
diamonds!
What Lucy get?
She
got
diamonds!
def details_ft(
*contents,
summary:str|None=None,
closed:bool=False,
direction:str='column',
height:str|None=None,
contents_style:str='', item_style:str=''):
style = f"display: flex; flex-direction: {direction};{height or ''}{contents_style or ''}"
return Details(cls='pale', open=not closed)(
Summary(summary),
Div(cls='contents', style=style)(
*(Div(style=item_style)(_) for _ in contents)
)
)
dtl = details_ft(
*(Pre(_) for _ in ('She', 'got', 'diamonds')),
summary='What Lucy get?')
display(dtl)
brt_cli(dtl);What Lucy get?
She
got
diamonds
What Lucy get?
She
got
diamonds
bridge.logger.show()As there only one instance of a logger running, use logger.show() to display the logger near a cell to see what’s going on.
<div id="output-99">Original</div>bridge.commander.swap('#output-99', '<div>Swapped!</div>', swapStyle='innerHTML')We can also use HTMX API through Bridge (documented in 16_bridge_plugins.ipynb).
But we’ll see a more convenient way with Bridget, without peppering the notebook of IPython Javascript display objects.
# fbridge(Div('Hey, Foo!'), display_id='ultra-cool-display-id')
Div('Hey, Foo!')# bridge(Div('Hey, Bar!'), display_id=did, update=True)
dh = bridge.nbhooks.brdd.dh
brt_cli(Div('Hey, Bar!'), display_id=dh)Use display_id and update kwargs to modify or update an existing output cell.
Bridget plugin
A specialization of
Bridgethat connects HTMX with FastHTML in notebooks.
get_bridget
get_bridget (app=None, logger:NBLogger|None=None, show_logger:bool=False, lnks:dict[str,FT]|None=None, esms:dict[str,str|Path]|None=None, plugins:Sequence[BridgePlugin]|None=None, kwplugins:dict[str,str]|None=None, wait:int=0, summary:bool=False, factory:Callable[...,Any]|None=None, timeout:float=10, sleep:float=0.2, n:int=10, show:Callable[[bool],None]|None=None, **kwargs)
| Type | Default | Details | |
|---|---|---|---|
| app | NoneType | None | |
| logger | NBLogger | None | None | |
| show_logger | bool | False | |
| lnks | dict[str, FT] | None | None | |
| esms | dict[str, str | Path] | None | None | |
| plugins | Sequence[BridgePlugin] | None | None | |
| kwplugins | dict[str, str] | None | None | |
| wait | int | 0 | |
| summary | bool | False | |
| factory | Callable[…, Any] | None | None | |
| timeout | float | 10 | |
| sleep | float | 0.2 | |
| n | int | 10 | |
| show | Callable[[bool], None] | None | None | |
| kwargs | VAR_KEYWORD | ||
| Returns | Bridget | type: ignore |
Bridget
Bridget (app:fasthtml.core.FastHTML|None=None, *args, **kwargs)
Inherit from this to have all attr accesses in self._xtra passed down to self.default
Bridget is a specialized widget that: 1. Inherits from BridgeBase for FastHTML/display functionality 2. Uses AnyWidget tooling-free, general widget solution, for browser-kernel communication 3. Implements a singleton pattern to ensure one instance per notebook
Attributes: - _esm: Path to bundled JavaScript module - request/response: Traitlets for HTMX communication - htmx/htmx_sels: Configuration for HTMX integration
The core part is using the widget’s bidirectional communication to replace HTMX’s HTTP transport: 1. Browser: HTMX makes requests thinking it’s talking to a server 2. Widget: Captures these requests via traitlets 3. Kernel: Processes requests using FastHTML routing 4. Widget: Returns responses that HTMX understands
Bridget JavaScript Implementation bridget.js
Bridget’s JavaScript code provides the browser-side implementation that: 1. Intercepts HTMX AJAX requests (SSE, WS in the future) 2. Routes them through the widget’s communication channel 3. Processes responses back to HTMX 4. Manages HTMX initialization in notebook output cells
Some details
Request Flow
- HTMX makes an AJAX request
- xhook intercepts it via
on_request - Request is serialized and sent to kernel via traitlets
- Kernel processes request and sends response
- Response is processed by
response_changed - HTMX receives response and updates DOM
HTMX Integration
BridgetObserverwatches for new notebook output cells- New cells are processed with
htmx.process() - HTMX attributes become active
- AJAX requests are intercepted and routed through widget
Note that Bridget JS is backend agnostic. We’re using FastHTML here because its the best for my quest of replacing ipywidgets as the main form of interactivity in a notebook, but it could be any other backend libarary.
get_app
Helper function to initialize the root-level app, bridget, and route.
get_app
get_app (hooks=False, nb=False, cfg:Mapping|None=None, logger:NBLogger|None=None, show_logger:bool=False, lnks:dict[str,FT]|None=None, esms:dict[str,str|Path]|None=None, plugins:Sequence[BridgePlugin]|None=None, kwplugins:dict[str,str]|None=None, wait:int=0, summary:bool=False, factory:Callable[...,Any]|None=None, timeout:float=10, sleep:float=0.2, n:int=10, show:Callable[[bool],None]|None=None, **kwargs)
| Type | Default | Details | |
|---|---|---|---|
| hooks | bool | False | |
| nb | bool | False | |
| cfg | Mapping | None | None | |
| logger | NBLogger | None | None | |
| show_logger | bool | False | |
| lnks | dict[str, FT] | None | None | |
| esms | dict[str, str | Path] | None | None | |
| plugins | Sequence[BridgePlugin] | None | None | |
| kwplugins | dict[str, str] | None | None | |
| wait | int | 0 | |
| summary | bool | False | |
| factory | Callable[…, Any] | None | None | |
| timeout | float | 10 | |
| sleep | float | 0.2 | |
| n | int | 10 | |
| show | Callable[[bool], None] | None | None | |
| kwargs | VAR_KEYWORD | ||
| Returns | tuple[FastHTML, Bridget, MethodType] | type: ignore |
app, brt, rt = get_app(show_logger=True)
test_is(brt.app, app)Let’s see Bridget in action.
def counter():
n = 0
@rt('/inc')
def increment():
nonlocal n
n += 1
return f"{n}"
return Div()(
Button(hx_get='/inc', hx_target='find span', hx_swap='textContent')(
'Count: ', Span(f"{n}")))
counter()# req = {
# "headers": { "HX-Request": "true", "HX-Current-URL": "vscode-webview://1ql27b0grt14rkuj26idbt9ktekivp44iu2s8pgfndvblm2j3iep/index.html?id=3d1cf833-fbc2-442d-885b-05f7dee12052&origin=1e95bb6a-280d-4f6e-8090-7f55f54cbfbd&swVersion=4&extensionId=&platform=electron&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&parentOrigin=vscode-file%3A%2F%2Fvscode-app&purpose=notebookRenderer" },
# "headerNames": { "hx-request": "HX-Request", "hx-current-url": "HX-Current-URL" },
# "status": 0, "method": "GET", "url": "/inc", "async": True, "timeout": 0, "withCredentials": False,
# "body": None,
# "req_id": "652540cd-1ebe-4983-8701-88074dfe6328" }# bridge._message_hdlr(None, {'ctx': 'bridget', 'kind': 'request', 'request': req}, None)def random_hsl(saturation=50, lightness=90):
hue = random.randint(0, 360)
return f"hsl({hue} {saturation}% {lightness}%)"
def counter(n=0):
@rt('/inc')
def increment(n:int):
return Button(f"Count: {n+1}", value=f"{n+1}", name='n',
hx_post='/inc', hx_swap='outerHTML',
style=f"background-color:{random_hsl()}; font-weight: bold")
return increment(n-1)
counter()@rt("/test")
def get():
return Buttons()
html = Div()(
H2('HTMX Test'),
Div('Swapped DOM elements are styled instantly when they arrive.'),
Buttons(),
)
# brt(html); # or simply, if bridge_cfg.auto_show is True:
htmlHTMX Test
An example from gnat’s css-scope-inline. Click the buttons.
See 40_details_json.ipynb for a lazy JSON browser.
Because all changes made with Bridge/Bridget/FastHTML/HTMX are transient.
Let’s talk about what’s happening under the hood:
We’re modifying the DOM - the HTML structure in your browser - at runtime. This is not a notebook editor; it’s a runtime tool. For anything to work, you need to execute the cells.
Here’s the thing: a notebook is just JSON. Ultimately, output cells are just HTML (derived from any IPython displayable object), environments like VSCode won’t render them until display time.
When you open a notebook, tjhe front-end usually takes a lazy approach (VSCode/Cursor more than others): - It renders existing outputs from the JSON (including JavaScript) - But it won’t execute any cells automatically - Even ipywidgets get special (and sometimes quirky) treatment
Think of the notebook’s JSON as a snapshot from when you ran the cells. Any DOM changes we make don’t get saved back to this JSON.
This means: 1. Bridge needs explicit initialization to load its JS/CSS 2. On load, saved notebooks will execute the JavaScript/HTML put there by cell runs 3. Kernel-side code won’t run until you execute the cells
Could we make Bridget modify the actual notebook outputs? Technically, yes, maybe. But given the labyrinthine complexity of the Jupyter ecosystem (and the mountains of JavaScript involved), I get a headache just thinking about it. Some mountains are better left unclimbed! 😅
Simple widget
class BWidget(T.HasTraits, RouteProvider):
bridget: Bridget = None # type: ignore
_mounted = False
def __ft__(self): ...
def _ipython_display_(self):
brt = get_bridget()
if bridge_cfg.auto_mount and not self._mounted: brt.mount(self, show=False)
brt(self);class BValue(BWidget):
value=T.CInt(0).tag(sync=True)
_updating = False
def wrapper_id(self): return self.ar.name().replace(':', '_')
@contextmanager
def _update_ctx(self):
self._updating = True
yield
self._updating = False
@T.observe('value')
def on_value(self, _):
if self.bridget and not self._updating:
self.bridget.bridge.commander.swap(f"#{self.wrapper_id()}", to_xml(self.__ft__()), swapStyle='innerHTML')
@ar.post('/value') # type: ignore
def changed(self, value:int):
with self._update_ctx(): self.value = value
return str(value)@dataclasses.dataclass
class BIntSlider(BValue):
min:int=0; max:int=100; step:int=1; readout:bool=True; readout_format:str='d'
def __ft__(self):
if bridge_cfg.auto_mount and not self._mounted: get_bridget().mount(self, show=False)
return Div(id=self.wrapper_id(), cls='bridget slider')(
Label(_for='value')('Scale'), _n,
Input(type='range', name='value', min=self.min, max=self.max, step=self.step, value=self.value,
# hx_post=f"{self.ar.to()}{self.ar.to('changed')}", hx_trigger='input changed',
hx_post=self.ar.to('changed'), hx_trigger='input changed',
hx_target='next text', hx_swap='textContent'),
Text(id='spanscale', style='inline')(self.value), _n
)
app.routes.clear()
bridge_cfg.auto_mount = True
rp = BIntSlider()
rprp.value = 77sld2 = BIntSlider(step=2)
sld2with bridge_cfg(auto_mount=True):
sld3 = BIntSlider()
sld4 = BIntSlider()
# brt.mount(sld3, show=False)
# brt.mount(sld4, show=False)
T.link((sld3, 'value'), (sld4, 'value'))
(box := Div(style='display: flex; gap: 1em;')(sld3, sld4))sld3.value = 22
test_eq(sld4.value, 22)Hydrate (TBD)
Can we edit
.ipynbs directly to capture actual output withoutnbformator editing the JSON in disk?
# def hydrate(bridget=True, app: FastHTML | None=None, appkw:dict[str, Any]={}, **kwargs):
# app, bridge, rt = get_app(True, app, appkw=appkw, **kwargs)
# bridget = Bridget(bridge) if bridget else None
# return app, bridge, rt, bridget
# hydrate()What about WebSockets and SSE Support?
While HTMX supports WebSockets and Server-Sent Events (SSE) through extensions(1), this proof-of-concept focuses only on AJAX functionality for several reasons:
Core Functionality: HTMX is primarily an AJAX framework - WS and SSE support are add-ons with simpler implementations(2)
Proof of Concept: For demonstrating the viability of using HTMX in notebooks, AJAX support is sufficient
Future Extension: Adding WS/SSE support would be straightforward since:
- The notebook Comm layer already uses WebSockets
- HTMX’s extension system is well-documented(3)
- The transport layer replacement pattern is already established with AJAX