Simple Custom Styling Notebooks in JupyterLab & RetroLab… Or Not… Again…

Pondering an earlier fragment on Previewing Richly Formatted Jupyter Book Style Content Authored Using MyST-md, a JupyterLab/RetroLab extension that allows you to render rich MyST markdown content blocks, it struck me that I should be able to extend it to provide a way of displaying customised single cell markdown exercise blocks, if nothing else.

After all, how hard could it be?

The JupyterLab extension that adds the functionality is the executablebooks/jupyterlab-myst extension, which seems in turn to rely on executablebooks/markdown-it-docutils, a plugin for markdown-it (whatever that is…). You can find a demo of what the docutils plugin supports here.

The repo is full of developer voodoo files, although the Getting Started section does tell you what you need to be able to type in order to build things (node required (good luck…); running the provided commands will download the internet and install whatever other node packages appear to be required):

Looking at the source TypeScript code in src/directives directory the admonitions.ts, adding a new admonition type seems simple enough:

// ...
export class Tip extends BaseAdmonition {
  public title = "Tip"
  public kind = "tip"
}

export class Warning extends BaseAdmonition {
  public title = "Warning"
  public kind = "warning"
}

export const admonitions = {
  admonition: Admonition,
  attention: Attention,
  caution: Caution,
  danger: Danger,
  error: Error,
  important: Important,
  hint: Hint,
  note: Note,
  seealso: SeeAlso,
  tip: Tip,
  warning: Warning
}

The colour theme and the icon for the styled admonition block are set by a simple bit of CSS:

$admonitions: (
  // Each of these has a reST directives for it.
  "caution": #ff9100 "spark",
  "warning": #ff9100 "warning",
  "danger": #ff5252 "spark",
  "attention": #ff5252 "warning",
  "error": #ff5252 "failure",
  "hint": #00c852 "question",
  "important": #00bfa5 "flame",
  "note": #00b0ff "pencil",
  "seealso": #448aff "info",
  "tip": #00c852 "info",
  "admonition-todo": #808080 "pencil"
);

So adding a new admonition type looks relatively straighforward, albeit constraining you to just custom styling the top bar and icon for the special content block. I reckon I should be able to at least build the markdown-it plugin. But what then? How can I use it?

This plugin is mentioned by name in several places in the jupyterlab-myst extension, so if I can find a way of distributing the plugin, for example, by publishing my own distribution via npm using a new, unique package name, then I should be able to reference this, instead of the original, in a fork of the jupyterlab-myst extension installed into my own Jupyter environment? At the cost of having to fork and install my own version of jupyterlab-myst, of course. And compared to the trivial way in which we can create a really simple Pyhton package to extend Sphinx to provide us with similar, custom admonition blocks.

A simpler way might be to use the executablebooks/markdown-it-plugin-template (from which the markdown-it-docutils plugin was created) and create a separate plugin that I can import directly into jupyterlab-myst; but there is a ton of stuff in the executablebooks/markdown-it-docutils extension that the parser seems to be required, and it seems much easier to just add 5 lines of code to something that already works. Were it not for the issue of actually getting those tweaks into my running Jupyter environment, of course.

And, of course, this still isn’t ideal, because I don’t want to just tweak the colour scheme the header of the custom block: I want to make changes to the background of all of it so that it looks like the exercise block provided by executablebooks/sphinx-exercise, for example:

The styling for the “erroneous” directive is a bit closer to what we want, but this is hard coded for “erroneous” classed cells.

And in the way that the styles are defined for the custom admonitions, they are templated to only tweak the header colour and icon in an easily customisable way.

I wonder if the simplest way is to just appropriate one of the custom admonition classes I am not likely to use (eg the attention class) and manually hack some overrides for that class into the CSS file?

But of course, to add a custom CSS file to JupyterLab, you have to do what? Create a dummy extension that just includes a CSS file? Because of course, JupyterLab doesn’t support custom.css.

UPDATE: here’s a trick for getting a custom CSS file into the JupyterLab environment via a dummy, otherwise empty, extension: https://blog.ouseful.info/2022/03/28/finally-simple-custom-styled-markdown-cells-in-jupyterlab/

Is there anything about JupyterLab that is not really hostile to simple end-user customisation by end-user developers?! I really do HATE IT!

Finally… Simple Custom Styled Markdown Cells in JupyterLab

So, I have a recipe for custom styling Markdown cells in JupyterLab / RetroLab, sort of. It actually co-opts jupyterlab-myst, which can parse MyST special content / admonition blocks contained inside a markdown cell and add class attributes associated with the special content block type to the corresponding cell DOM elements:

We can then tune in to that <aside/> block using a CSS selecter of the form .admonition.note:

.admonition.note {
    outline: 0.0625rem solid #f08080;
    background-color: lightblue;
    border-left-color: green !important;
}

To get the CSS in to the appropriate place, you have to download the internet and a developer toolchain and build a JupyterLab extension. Me neither. But the jupyterlab/extension-cookiecutter-js seems to provide an “easy” way of doing this (I chose the javascript package in the hope it was a bit more compact ands easier to build than the .ts/TypeScript one (though I’m not sure it is):

  • install the cookiecutter package: pip install cookiecutter
  • run it against the JupyterLab extension cookiecutter: cookiecutter https://github.com/jupyterlab/extension-cookiecutter-js
  • you’ll be prompted for various things: give it your name, and then use the same project name (for convenience) for the Python package and the extension name;
  • edit the style/base.css file and save it; <- this is the customisation bit;
  • build the package in editable mode: python -m pip install -e . if you are in the top level directory of the extension directory you created, or python -m pip install -e . ./MY_EXTENSION_NAME if you’re in the parent directory; install your development version of the extension with JupyterLab jupyter labextension develop . --overwrite; and if you make changes, run jlpm run build and restart the JupyerLab server; and that’s it, I think (at least, after a while, as the build process downloads the internet and maybe rebuilds JupyterLab and does who knows what else?!); if you aren’t developing, you should be able to just pip install .; be wary though, I did a simple pip install . and no matter how I tried, I couldn’t seem to upgrade the package version that JupyterLab ran away from the first version I managed to build (it’s languishing there still for all I know…). You can check this extension is installed and available from the command-line using the command: jupyter labextension list; if the extension isn’t enabled, try enabling it jupyter labextension enable MY_EXTENSION_NAME; if it still doesn’t work, or you can’t see it in the listing, try jupyter labextension install MY_EXTENSION_NAME and then perhaps the enable command. As this is JupyterLab, shouting at it a bit as well might help you feel better.
  • I don’t really understand how the build works, because after install the package, if you update it you need to run jlpm run build ?

Things were so much easier when you could just pop a CSS file into a classic notebook config directory…

Usual caveats apply to the below: this is not meant to cause offence, isn’t meant to be disrespectful, isn’t intended as an ad hominem attack; it does involve a certain amount of caricature and parody, etc etc, and may well be wrong, misguided or even complete and utter nonsense.

This is still not very useful if you want to custom style code cells. Various suggestions for being able to add class attributes based on (code cell) tag metadata have been in the issues queue for ages (I sincerely hope, if any of the PRs ever get merged, that support for propagating markdown cell tags over to class attributes is also provided, and not just copying over tags for code cells); several of the issues and PRs I’ve been aware of over the years include the following, but there may be more:

Over in the RetroLab repo — where I suspect that all sorts of stuff you can’t do in the new UI that you could do, or relatively straightforwardly hack into, the classic notebook UI, will soon start raising its head — there’s an open issue on Should custom.css equivalent be supported? https://github.com/jupyterlab/retrolab/issues/308 .

What I’ve felt right from the start is that UI / notebook presentation level “innovation outside” is really difficult in JupyterLab even at the user experience level, particularly around the boundary of notebook structure and notebook content. The notebook cell structure provides some really useful levels of structured content separation (markdown, code and code output) as well as structural metadata (cell tags). If you can exploit the structural elements in the way you present the content, then there is a lot you can do to customise the presentation of the content in a way that is sympathetic to the content and is sensitive to the metadata (cell type, cell tags or other metadata, etc.).

I think we’re still at the early stages of finding out how to make most effective use of notebooks in education, and this means finding playful ways of creating really simple extensions that help explore that edge space where notebook structure can be mapped onto, which is to say, used to transform, presentational elements, for example, that space where tags and other metadata elements can be used to control style.

But the architecture really gets in the way of that.

Currently, the content author has control over the content of a cell, and, to a limited extent, by virtue of the cell type, the presentation of the cell. But if they had additional control over the presentation of the content, for example, tag-sensitive styling, they could author even richer documents, particularly if the styling was also user customisable.

Whilst things like jupyter-myst make additional styling features available to the author, it does so in a way that forces an element of structured authoring inside the content field. To create an admonition block, I don’t select a markdown block with an admonition style (as I might do with tag based styling), but instead I select a markdown block and then put structural information inside it (the labelled, triple backticked code fence markers). (Cf. being able to put HTML content into markdown cells: this is really messy and can clutter thngs badly. Markdown is much cleaner and uses “natural” text to indicate structure; but even better if you can put block level metadata/structure at the level of the block, but not inside the block.)

Presumably because of the way the jupyterlab-myst plugin works (a simplification that perhaps allows the contents of a markdown cell to be treated as “code” that is then parsed and rendered subject to the markdown-as-code parsing extension without having to mess with core JupyterLab code), the contents of the markdown cell, and its parser, have no sight of the structural metadata associated with the cell. So we can’t just tag the code cell and expect it to be rendered as a special content block because that would required hacking JupyterLab core.

Right from the start, it seems as if a decision was taken in the JupyterLab development that the users could do want they want inside code scope and insde a notebook cell source element, and that code execution could return things into the output element, but that the cell metadata was essentially out of bounds unless you were coding at the JupyterLab core level (“it’s our editor and it’s for looking at notebooks how we want look at ’em.”; there’s also a second, later take, which is that the geeky dev user should be able to choose their own theme. In education, it’s often useful for the publisher to control the styling, because the styling is the classroom and style can be an important signposter, reinforcer, framer and contextualiser. Which is not to say that users shouldnlt also be able to select eg light, or dark, or accessible-yellow tinted themes which the publisher should provide and support). This is a real blocker. If the above mentioned, languishing PRs had been (capable of being?) published as simple extensions, they’d have been useable by end-users for months and years already; as extensions, with less code to make sense of, it’s possibly more likely that other people would have been able to develop them further and/or use them as cribs for other extensions; (although there is still the question of dev tools and what to type where to even get your dev environment up and running…) Extensions also mean there are no side effects on the core code-base if the extension code is substandard; if bad things happen from installing the extension, uninstall it. As it is, if you want the functionality that apparently resides inside the PRs, then you have to install jupyterlab from a personal fork / PR branch of the JupyterLab repo, and build it yourself from source in order to even try it out. (Providing good automation in the repo can help here because it means that people can rely on the automation process to mange the development environment and build the distribution, rather than the user necessarily having to figure out a developmnt environment of their own and what commands to run when in order to manage the build process.)

There may well be good reasons why you don’t want “portable” documents such as notebooks to have too much influence over the UI (for example, malicious Python code that spoofs UI elements to steal credentials); but with tools such as ipywidgets, you do allow Python code to have sight of various elements of the DOM, and control over them. Related to this, I note things like davidbrochart/ipyurl, a “Jupyter Widget Library for accessing the server’s URL” that uses the ipywidgets machinery as a hack to get hold of a Jupyter server’s URL because it’s not directly accessible as jupyter scoped state in a code execution environment. I also note jtpio/ipylab, the aim of which is to “provide access to most of the JupyterLab environment from Python notebooks”. But whilst this means that code in notebooks can tinker with the UI, this is not really relevant if I want to expose users to subject matter content in a modified UI. Unless, perhaps, I can create a JupyterLab workspace that is capable of auto-running (in the background, on launch) a configuration notebook that uses ipylab to set up the environment/workspace when I open a workspace?

The jupyterlab/jupyterlab-plugin-playground is another extension that seems to provide a shortcut way of testing plugin code without the need for a complex build environment, but I’m not really sure how it helps anyone other than folk who already know what they’re doing when it comes to developing plugins, if not setting up build and release environments for them. Looking at the code for something like jupyterlab-contrib/jupyterlab-cell-flash (could that be run from the jupyterlab-plugin-playground ? If so, what role does all the other stuff in the cell-flash repo play?!) I note a handy looking NotebookActions.executed method for handling an event, or some other sort of signal. But what other events / signals are available, and how might they be used? (The onModelDBMetadataChange method looks like it might also be handy, but how do I use it to monitor all cells?) And where do I find a convenient list of hooks for things that a handler might influence in turn, other than perhaps poring through jupyterlab/extension-examples looking for cribs?

Enough… What I meant to do when I started this post was publish an example of some simple CSS to style a simple example by co-opting an attention admonition class. And I’ve still not done that!

Run Wine Apps in Your Browser via the daedalOS WASM Desktop

Via @simonw, I note WebAssembly in my Browser Desktop Environment [repo], a desktop that runs in your browser. It also supports Wine apps, so open the demo, drag a Windows exe file onto the desktop that should have loaded in your browser, and run the app…

For example, here’s the old OU RobotLab app running, as a Wine app, in my browser, having dragged it into the browser from my own desktop.

As more and more stuff runs in the browser, the blocker becomes file persistence. I’m guessing that this browser desktop saves into browser storage; but to be properly useful, things like this need to be able synch either with remote storage, or with your own physical desktop using something like the browser File System Access API (browser availability)?

I’m guessing that I may be coming across as all negative on this. I’m just pre-empting one of the two most obvious reasons why I think “colleagues” will say this is a non-starter for use with OUr students; the other being browser-spec requirements. The next most obvious “but we can’t use this with students becuase…” argument will probably but “but you can’t use it offline”. Having to install something to the desktop to serve it locally cancels the “install free” benefits of running things in the server, so the next desirable feature would be the ability to “install” it as a browser app / progressive web app. (For an example PWA installed as a Chrome app from a website/URL, see eg SQL Databases in the Browser, via WASM: SQLite and DuckDB.)

Compiling Full Text Search (FTS5) Into sql.js (SQLite WASM Build)

As part of another side project, scraping traditional stories into various searchable databases, I figured I should probably start making the simple SQlite databases searchable over the web. I don’t really want to have to run a db server, but there is an in-browser WASM build of SQLite available, sql.js, that can be used to provide SQLite support directly within a web page and hence served from a simple web server.

Using the off-the-shelf build with my database tables fails because I make use of the FTS-5 extension (the sql.js release only bundles FTS3). So can I find a build with FTS-5 support, or build one myself?

Poking around the sql.js repo turns up a note in the CONTRIBUTING.md guide a note that you can add extensions, such as FTS5, by making a tweak the the Makefile and adding a -DSQLITE_ENABLE_FTS5 switch to the CFLAGS declaration.

Checking the repo, there is indeed a Makefile and a place to add the switch:

To set up the development enviroment, a VS Code containerised environment is provided. This can be activitated simply by opening the repo folder inside VS Code which then detects the .devcontainer/devcontainer.json and associated Dcokerfile (the build iteself took quite some time…). To avail yourself of this route, you need to have VS Code and Docker installed in advance.

One of the handy things I noted was that the file mount from the directory on my desktop into the container was handled automatically. I also noted in passing (I forget where) the ability to forward ports from inside the container. For the official docs on this sort of development, see for example the VS Code docs Developing inside a Container. I’m also wondering now whether this would be a useful way of distributing code environments to students…

The sql.js docs then suggest all you need to do is run npm run rebuild. This didn’t actually run the build properly for me at all; instead, I had to manually invoke make. But when I did, everything I needed seemed to build okay, the distribution packages appeared in the dist directory, and now I can run my full text FTS5 searches solely within the browser.

As a PS, having managed to create my own custom build so easily, I guess there’s no reason now not to compile in other extensions or perform custom builds… such as the sql.js-httpvfs variant which lets you make requests from a web page to remotely hosted (and potentially very large) sqlite database files (about: Hosting SQLite databases on Github Pages (or any static file hoster)). Various bits of third party guidance about how best to do that in a simple web page context are also starting to appear, as for example here and here.

Notes on the JupyterLab Notebook HTML DOM Model, Part 1: Rendered Markdown Cells

For a great review of the DOM structure of the JupyterLab UI, see [Jupyter Accessibility docs](https://jupyter-accessibility.readthedocs.io/en/latest/resources/map-jupyterlab-frontend-architecture/README.html).

I finally relented, and after going through the apparent overkill of building a JupyterLab extension simply to sneak a custom CSS file into the distribution, I posted a query onto Stack Overflow to see if I could crib enough to have a go at a JupyterLab extension that replicates my classic notebook empinken extension that uses toolbar buttons to toggle persistent state on a notebook that can be used to colour highlight cells in particular ways.

The query was comprehensivley answered and gave me all the clues I needed to make a stab at it. I’ll post a note about the extension when I finish a first draft of it, along with some reflections about the process…

The empinken extension I’m interested in building essentially requires four components:

  • buttons to toggle empinken style state on a cell;
  • a means of persisting empinken style state, eg via cell metadata or cell tags;
  • a means of styling cells appropriately (a combination of HTML tag classes and CSS style rules);
  • a means of adding class attributes to the DOM based on the cell empinken style state.

There is an optional fifth consideration in the original, which was a simple YAML file defined control panel exposed by the classic notebook jupyter_nbextensions_configurator. In the case of the empinken extension, this let you set cell colours, with the whole configurator defined by a simple YAML file:

I don’t think JupyerLab supports any similar extension configurator tool: you have to write your own and find somewhere to display it.

I haven’t yet figured out the styling, so the rest of this post will be a transcript of how notebook cells seem to be represented in the DOM. (I haven’t spotted any docs on this? If you know of any, please post a link in the comments.)

So let’s get started… (I’m only going to focus on markdown and code cells for now, because they are generally the only ones I tend to use…)

A Note on Tags

Tags are used by certain extensions and document processing tools (Jupytext, Jupyter Book, etc.) to modify how documents are processed and rendered.

In some cases, it may be that JupyterLab extensions can be used to modify how cells are rendered on the basis of cell tags.

In other cases, it might be that document processors support conventions within a cell for extended rendering. For example, in Jupyter Book, directive blocks can be rendered using styled HTML block. (The JupyterLab Myst extension can used an extended markdown parser to render these elements in a notebook, though I have found it can be a bit slow to render the elements…) However, it is also possible to construct document processing pipelines that use cell tags to denote, eg a certain directive block, and then generate an intermediate MyST document with MyST style directives that is parsed by the Jupyter Book processor. In such a case, the notebook tag would be available to a JupyterLab extension to tune the rendering of the cell. (For a crude example, see innovationOUtside/ou-jupyter-book-tools.)

Markdown Cell Structure

The .ipynb JSON format for a markdown cell is defined as follows:

{
  "cell_type" : "markdown",
  "metadata" : {
       "tags": ["mytag", "my-other-tag"]
  },
  "source" : ["some *markdown*"],
}

When rendered as a JupyterLab cell, the following structure is evident:

<div class="lm-Widget p-Widget jp-Cell jp-MarkdownCell jp-Notebook-cell jp-mod-rendered">
    <div class="lm-Widget p-Widget jp-CellHeader jp-Cell-header"></div>
    <div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-inputWrapper">
        <div class="lm-Widget p-Widget jp-Collapser jp-InputCollapser jp-Cell-inputCollapser">
            <div class="jp-Collapser-child"></div>
        </div>
        <div class="lm-Widget p-Widget jp-InputArea jp-Cell-inputArea">
            <div class="lm-Widget p-Widget jp-InputPrompt jp-InputArea-prompt"></div>
            <div class="lm-Widget p-Widget jp-CodeMirrorEditor jp-Editor jp-InputArea-editor lm-mod-hidden p-mod-hidden flash-effect" data-type="inline">
                <div class="CodeMirror cm-s-jupyter CodeMirror-wrap">
                    <div style="overflow: hidden; position: relative; width: 3px; height: 0px; top: 5px; left: 74.4375px;"><textarea autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="0" style="position: absolute; bottom: -1em; padding: 0px; width: 1000px; height: 1em; outline: none;"></textarea></div>
                    <div class="CodeMirror-vscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="min-width: 1px; height: 0px;"></div>
                    </div>
                    <div class="CodeMirror-hscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="height: 100%; min-height: 1px; width: 0px;"></div>
                    </div>
                    <div class="CodeMirror-scrollbar-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-gutter-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-scroll" tabindex="-1">
                        <div class="CodeMirror-sizer" style="margin-left: 0px; margin-bottom: -15px; border-right-width: 35px; min-height: 61px; padding-right: 0px; padding-bottom: 0px;">
                            <div style="position: relative; top: 0px;">
                                <div class="CodeMirror-lines" role="presentation">
                                    <div role="presentation" style="position: relative; outline: none;">
                                        <div class="CodeMirror-measure"><pre class="CodeMirror-line-like"><span>xxxxxxxxxx</span></pre></div>
                                        <div class="CodeMirror-measure"></div>
                                        <div style="position: relative; z-index: 1;"></div>
                                        <div class="CodeMirror-cursors" style="visibility: hidden;">
                                            <div class="CodeMirror-cursor" style="left: 74.4375px; top: 0px; height: 17px;">&nbsp;</div>
                                        </div>
                                        <div class="CodeMirror-code" role="presentation"><pre class=" CodeMirror-line " role="presentation"><span role="presentation" style="padding-right: 0.1px;">A markdown cell.</span></pre><pre class=" CodeMirror-line " role="presentation"><span role="presentation" style="padding-right: 0.1px;"><span cm-text="">​</span></span></pre><pre class=" CodeMirror-line " role="presentation"><span role="presentation" style="padding-right: 0.1px;">Over several lines.</span></pre></div>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div style="position: absolute; height: 35px; width: 1px; border-bottom: 0px solid transparent; top: 61px;"></div>
                        <div class="CodeMirror-gutters" style="display: none; height: 96px;"></div>
                    </div>
                </div>
            </div>
            <div class="lm-Widget p-Widget jp-RenderedHTMLCommon jp-RenderedMarkdown jp-MarkdownOutput" data-mime-type="text/markdown">
                <p>A markdown cell.</p>
                <p>Over several lines.</p>
            </div>
        </div>
    </div>
    <div class="lm-Widget p-Widget jp-CellFooter jp-Cell-footer"></div>
</div>

In what follows, the colouring of the block elements corresponds to a particular CSS element scope:

Let’s start at the top, the <div class="lm-Widget p-Widget jp-Cell jp-MarkdownCell jp-Notebook-cell jp-mod-rendered"> element that contains everything:

Note the padding that extends around the element.

This element contains three child elements: a header (which appears to be empty – what is this for?), the body (or panel), and a footer (which also appears to be empty; again, what is it for and is there any content that can easily be rendered into it?):

If the cell is selected, the top level block adds a couple of extra classes: jp-mod-active jp-mod-selected. The selected cell is highlighted:

And what does the panel element cover?

Let’s look inside the panel element:

This element has two child elements: a collapser, and an input area.

Let’s look at the collapser first, which is quite a simple element:

The collapser elements cover the gutter area that is highlighted when a markdown cell is selected (the child element appears to cover the same extent):

The next element is the inputarea into which the notebook source is eventually parsed and rendered. This appears to cover everything to the right of the collapser:

The element itself contains three elements, an InputPrompt, an Editor and a MarkdownOutput/RenderedMarkdown element:

In a rendered markdown cell, the editor cell (which has quite a complex internal structure) does not appear to have a rendered extent. When the markdown cell is put into edited mode, the MarkdownOutput element seems to disappear from the DOM.

This might be a hassle if we are tag styling cells: we would need to ensure that when the MarkdownOutput element is added back to the DOM the element gets classed appropriately. Which means we would have to set a watcher on the cell.

The extent of the MarkdownOutput element (which is the same as the extent of the editor element when in edit mode) is everything to the right of the input area. Note the padding extends to the left and further to the right.

The MarkdownOutput element simply contains the rendered markdown HTML:

Each paragraph line then has its own lower margin:

In the next post in this series, we’ll consider the code cell DOM elements.

Notes on the JupyterLab Notebook HTML DOM Model, Part 2: Code Cells

For a review of markdown cell DOM structure, see here.

Code Cell Structure

The .ipynb JSON format for a markdown cell is defined as follows:

{
  "cell_type" : "code",
  "execution_count": 1, # integer or null
  "metadata" : {
      "collapsed" : True, # whether the output of the cell is collapsed
      "autoscroll": False, # any of true, false or "auto"
      "tags": []
  },
  "source" : ["some code"],
  "outputs": [{
      # list of output dicts (described below)
      "output_type": "stream",
      ...
  }],
}

When rendered as an (empty) code cell, the following HTML DOM structure is evident:

<div class="lm-Widget p-Widget jp-Cell jp-CodeCell jp-mod-noOutputs jp-Notebook-cell jp-mod-active jp-mod-selected">
    <div class="lm-Widget p-Widget jp-CellHeader jp-Cell-header"></div>
    <div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-inputWrapper">
        <div class="lm-Widget p-Widget jp-Collapser jp-InputCollapser jp-Cell-inputCollapser">
            <div class="jp-Collapser-child"></div>
        </div>
        <div class="lm-Widget p-Widget jp-InputArea jp-Cell-inputArea">
            <div class="lm-Widget p-Widget jp-InputPrompt jp-InputArea-prompt">[ ]:</div>
            <div class="lm-Widget p-Widget jp-CodeMirrorEditor jp-Editor jp-InputArea-editor" data-type="inline">
                <div class="CodeMirror cm-s-jupyter">
                    <div style="overflow: hidden; position: relative; width: 3px; height: 0px; top: 5px; left: 4px;"><textarea autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="0" style="position: absolute; bottom: -1em; padding: 0px; width: 1000px; height: 1em; outline: none;"></textarea></div>
                    <div class="CodeMirror-vscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="min-width: 1px; height: 0px;"></div>
                    </div>
                    <div class="CodeMirror-hscrollbar" tabindex="-1" cm-not-content="true">
                        <div style="height: 100%; min-height: 1px; width: 0px;"></div>
                    </div>
                    <div class="CodeMirror-scrollbar-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-gutter-filler" cm-not-content="true"></div>
                    <div class="CodeMirror-scroll" tabindex="-1">
                        <div class="CodeMirror-sizer" style="margin-left: 0px; min-width: 7px; margin-bottom: -15px; border-right-width: 35px; min-height: 27px; padding-right: 0px; padding-bottom: 0px;">
                            <div style="position: relative; top: 0px;">
                                <div class="CodeMirror-lines" role="presentation">
                                    <div role="presentation" style="position: relative; outline: none;">
                                        <div class="CodeMirror-measure"></div>
                                        <div class="CodeMirror-measure"></div>
                                        <div style="position: relative; z-index: 1;"></div>
                                        <div class="CodeMirror-cursors">
                                            <div class="CodeMirror-cursor" style="left: 4px; top: 0px; height: 17px;">&nbsp;</div>
                                        </div>
                                        <div class="CodeMirror-code" role="presentation"><pre class=" CodeMirror-line " role="presentation"><span role="presentation" style="padding-right: 0.1px;"><span cm-text="">​</span></span></pre></div>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div style="position: absolute; height: 35px; width: 1px; border-bottom: 0px solid transparent; top: 27px;"></div>
                        <div class="CodeMirror-gutters" style="display: none; height: 62px;"></div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="lm-Widget p-Widget jp-CellResizeHandle"></div>
    <div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-outputWrapper">
        <div class="lm-Widget p-Widget jp-Collapser jp-OutputCollapser jp-Cell-outputCollapser">
            <div class="jp-Collapser-child"></div>
        </div>
        <div class="lm-Widget p-Widget jp-OutputArea jp-Cell-outputArea"></div>
    </div>
    <div class="lm-Widget p-Widget jp-CellFooter jp-Cell-footer"></div>
</div>

As in the previous post, the colouring of the block elements corresponds to a particular CSS element scope:

Let’s start at the top, with the <div class="lm-Widget p-Widget jp-Cell jp-CodeCell jp-mod-noOutputs jp-Notebook-cell jp-mod-active jp-mod-selected"> element which covers everything. As with the mardown cell, note the padded area around the cell.

Inside the block are five child elements, including a header and a footer, as with the markdown cell. But in this case, there are two panel elements, (a Cell-inputWrapper and a Cell-outputwrapper), separated by a CellResizeHandle element.

To ground this review in a little more detail, let’s consider the following cell:

The first panel element, the Cell-inputWrapper, covers the whole of the “input” area, which is to say, the whole extent of the block that contains the cell source.

The block contains two elements, an InputCollapser and a the InputArea:

The InputCollapser follows the model used in the markdown cell. One of its most notable features is that it denotes the selected cell. The InputCollapser area is also the one targeted by the nbsafety extension, which provides warning status indications around cells that have been run out of order.

The InputArea cell only ever has two child elements, the InputPrompt interstitial area between the InputCollapser and the Editor, and the Editor itself.

Not how the InputPrompt covers the cell run history count indicator:

The InputPrompt is the one we should target if we were to try to port something like the valuable classic notebook cell execution status extension, which provides a visual indication of cell run statuses (executing, awaiting-exectution, executed-success, executed-error).

The Cell-outputWrapper is the area that contains the cell outputs. Note the upper padding defined on the cell block.

The Cell_outputWrapper contains two elements, the by now familiar Collapser and an OutputArea:

The OutputArea covers the various sorts of output, “printed” output and code cell returned output:

The OutputArea is thus composed of two OutputArea-child elements, only the second of which carries an element distinguishing class, OutputArea-executeResult:

The first child, for printed display output, covers the area to the right of the collapser:

As such, it includes an OutputPrompt and a RenderedText area:

The RenderedText area extends to the right of the gutter area; note the padded area at the start of the element.

The OutputArea-output then contains the printed output content, the styling of which extends the right.

The second OutputArea-child, which is to say, the OutputArea-executeResult element also contains an OutputArea-prompt and a RenderedText area:

The OutputPrompt area includes the cell run history count indicator for the output:

The RenderedText/OutputArea-output element contains the rendered output returned from the executed code.

In the next post in this series, we will look at how we can set classes on the DOM from a JupyterLab extension.

Notes on the JupyterLab Notebook HTML DOM Model, Part 3: Setting Classes Based on Cell Tags From a JupyterLab Extension

In the previous two posts in this series, I reviewed the HTML DOM structure or rendered notebook markdown and code cells in JupyterLab (I assume the same structure exists in RetroLab, but need to check…). In this post, I’ll review one way in which we can set classes on those elements from a JupyterLab extension.

To simplify the extension building process, the following recipe has been tested using the JupyterLab Plugin Playground, a JupyterLab extension that lets you write and test simple extensions purely within a JupyterLab context (i.e. without any need to build and install an extension from the command line).

Note that this is a playground with caveats: you need to be strict in your Javascript or things will fail to run, even though they could be executed in a more forgiving environment. I’m also not sure if this is a supersitious behaviour or a necessary one: each time I modified the script, I closed my test notebook, saved the script, reloaded the browser page, clicked the jigsaw icon in the playground editor to “install” the extension, then opened the test notebook to see if things had worked. Certainly, I often had no success in effecting changes without the above steps. But it’s probably quicker than rebuilding JupyterLab each time.

To begin with, I’ll see if I can create a simple extension to add class tags to various parts of the cell HTML DOM based on the presence of cell metadata tags.

Docs for how to drive changes from notebook events seem to be in JupyterLab API notebook module notebookactions, but they aren’t relevant if you want to take action over a notebook on load, as, for example, when cells have been rendered into the DOM and are available for classing.

The notebook class docs briefly reference various accessor methods that may be connected to. I thought a promising one (from the name of it) might be the fullyrendered accessor, which seems to be “[a] signal emitted when the notebook is fully rendered” but a Stack Overflow comment suggests otherwise:

notebook.fullyRendered is fairly new and may not be what you are looking for. Previously there was no concept of “notebook.ready” (it was just cells getting added one by one on load), but with the virtualized rendering – if configured to rely solely on content intersection) users may never see a fully rendered notebook (if they do not scroll down to the very last cell); in any case this is all subject to change based on the feedback we got about problems with this feature (a lot of changes were already made), so personally I would just go one-by-one on every change.

@krassowski

That said, I found the fullyRendered signal did fire, albeit multiple times, when I loaded a notebook and could be used to class the cells based on metadata, but the modelChanged signal did not seem to do anyhting when the notebook loaded, when I added metadata to a cell, or when I moved a cell? (I note there is also a stateChanged signal?)

Just as an aside, I note some signals that may be useful for a cell execution status extension in the notebookActions class, specifically the executed (“A signal that emits whenever a cell execution is scheduled.”) and executionScheduled (“A signal that emits whenever a cell execution is scheduled”) signals, although I note that selectionExecuted also claims to be “A signal that emits whenever a cell execution is scheduled”? An example of connecting to the NotebookActions.executed signal can be found in the jupyterlab-cell-flash extension. I’m not sure if there’s a way of detecting whether the execution ran successfully or returned an error? There NotebookActions.executed signal does seem to return an error? (yes, the ? is there…) KernelError object and a success boolean, so maybe we can use those. In the classic cell execution status extension, it looks like there was a code cell _handle_execute_reply action that returned various status messages (ok, aborted) that could be reasoned around.

Another possibly useful crib is the agoose77/jupyterlab-imarkdown extension which resembles the classic notebook Python Markdown extension which renders variables into markdown content (via this discourse forum post by @agoose77).

Anywhere, here’s the code I used, which was largely inspired by @krassowski’s response to my Stack Overflow question on Accessing notebook cell metadata and HTML class attributes in JupyterLab Extensions.

// Most of the code cribbed from @krassowski
// https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html

*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                if tag.startsWith("class-") {
                    /* 
                    The plugin playground extension is rather hostile 
                    to have-a-go end-user devs (it's rather strict in 
                    its Javascript epxectations) so if you don't 
                    declare things with let, const or var then an 
                    error of the following form is likely to be thrown 
                    that stops further execution:
                    
                    ReferenceError: class_name is not definred
                    */
                    
                    //This will replace just the first instance of class-
                    const class_name = tag.replace("class-", "tag-")
                    
                    // Add class tags to various parts of the DOM
                    // What sort of cell are we?
                    console.log("Cell type: " + cell.model.type)

                    // Try to see what's going on...
                    console.log("Try node...")
                    cell.node.classList.add(class_name + "-" + cell.model.type + "-node");
                    
                    console.log("Try inputArea.node...")
                    cell.inputArea.node.classList.add(class_name + "-" + cell.model.type + "-inputArea_node");
                    
                    console.log("Try editor.node...")
                    cell.editor.host.classList.add(class_name + "-" + cell.model.type + "-editor_host");
                    
                    console.log("Try .inputArea.promptNode...")
                    cell.inputArea.promptNode.classList.add(class_name + "-" + cell.model.type + "-inputArea_promptNode");
                    
                    if (cell.model.type=="markdown") {
                        //console.log("Try RenderedHTMLCommon.node...")
                        //cell.RenderedHTMLCommon.node.classList.add(class_name + "-" + cell.model.type + "-RenderedHTMLCommon.node")
                        //TO DO - access lm-Widget p-Widget jp-RenderedHTMLCommon jp-RenderedMarkdown jp-MarkdownOutput
                        // DOESN'T WORK: cell.MarkdownOutput.node, MarkdownOutput.host, RenderedMarkdown.node, RenderedHTMLCommon.node
                    }
                    else {
                        console.log("Try outputArea.node...")
                        cell.outputArea.node.classList.add(class_name + "-" + cell.model.type + "-outputArea_node")
                    }
                } // end: if class starts with tag...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
    
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
        
    });
        
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
        
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
        
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
    
    return new DisposableDelegate(() => {
    });
  }
}


/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

With the extension, when I open a notebook with some class-TEST tags on various markdown and code cells, the following class attributes are set on a markdown cell:

And the following class attributes are set on a code cell:

Having set classes on various elements, we now need to style them. An example of how to do that will (hopefully!) be provided in the next post in this series…

PS Just as an aside, one interesting possibility that occurs to me is if we can intercept a cell run command and swap in our own execution call, we could define “magic-tags” that, if detected on a cell, allow us to grab the cell content and then treat it in a magic way. That is, rather than use a block magic at the start of a cell, magic-tag the cell and treat its contents via some tag powered magic. Various bits of signalling around cell execution scheduling requests might be relevant here, eg in the context of trying to set up a pipeline around the processing of code in a code cell before (and after) its execution.

Notes on the JupyterLab Notebook HTML DOM Model, Part 4: Styling Custom Classes

In the previous posts in this series, I explored the HTML DOM structure of rendered notebook markdown and code cells, and looked at how we might set DOM element class tags based on notebook cell metadata tags. In this post, I’ll see how we might style notebook cells using custom CSS rules.

To simplify testing new CSS rules, I’ll do all the work in the borwser. In my Chrome browser, the developer tools panel (raised from the View > Developer menu) allows you to inspect elements in the web page, view the CSS that applies to them, and edit and create custom CSS rules that are applied without reloading the page:

This means its easy enough to just mess around with rules and see how they work.

To cell cell backgrounds that “join up” across contiguous cells, we can apply a new background rule to the .tag-TAG-markdown-node and .tag-TAG-code-node elements, as shown above. The !important phrase allows us to over-ride the background settings that are applied onto elements further down the DOM branch. Obviously, this is not a theme sensitive solution, but this is my extension, and if it only works in certain themes, then that’s only where it works.

Thinking about how we might style things for a code cell execution status indicator extension, the input area prompt looks sensible:

As to how to get a custom CSS file into an extension, I’ve already got a sketch for that here.

So that’s pretty much it for how to actually add rendered custom style…

In the next post in this series, I’ll look at how we can add some buttons to the browser that will toggle metadata and tag state on the notebook so that we can add tags at the click of a button that can be classed as class attributes for which have have pre-defined style rules. (This is essentially the last step we need for something like an empinken extension.)

Notes on the JupyterLab Notebook HTML DOM Model, Part 5: Setting DOM Classes and Cell Tags From Notebook Toolbar Buttons

Following on from the previous posts in this series, let’s now see how we can toggle cell HTML DOM classes, as well as persistent metadata and tag state, from a notebook toolbar button.

The code for this recipe is taken pretty much wholesale from the Toolbar button example in the jupyterlab/extension-examples repo (one of the two notebook examples I could find.

Given pretty much all the classic notebook extensions were notebook related (that’s all there was), I think this demonstrates how low down the list of interested concerns notebook documents fall in JupyterLab. Maybe there needs to be a RetroLab extensions examples repo to collect exampls of working with notebooks and bringing them back to the fore a bit more?

Once again, I’ll use the JupyterLab Plugin Playground to test out the code.

To begin with, here is a minimal recipe to add a single button to the toolbar that can toggle a metadata tag on a selected cell.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = () => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // If the tag exists...
        if (tagList.includes("TOGGLETAG")) {
            console.log("I see TOGGLETAG")

            // ...then remove it
            const index = tagList.indexOf("TOGGLETAG", 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
        }
        // ...else add it
        else {
            console.log("I don't see TOGGLETAG")
            tagList.push("TOGGLETAG")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);

    };

    const button = new ToolbarButton({
      className: 'my-action-button',
      label: 'Tagger',
      //iconClass: 'fa fa-exclamation-circle' 
      //https://github.com/jupyterlab/jupyterlab/issues/8277
      onClick: toggleTagButtonAction,
      tooltip: 'Toggle a persistent metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleTagAction', button);
    return new DisposableDelegate(() => {
      button.dispose();
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

And here’s how it works:

There also seems to be an option to add an icon for the toolbar button. I’ve previously used FontAwesome icons (e.g. in classic notebook extensions), and whilst it appears that these were once supported off-the-shelf in JupyterLab (pehaps with something like iconClass: 'fa fa-exclamation-circle') on the grounds (I think?) that things might not render in other themes. So in the face of that hostility to half-hour-hack end-user-developers, I will add multiple buttons with obscure single character labels that the user will have to try to guess at to remember what they do, rather than go hunting down how to create and style my own icons.

The classic notebook empinken extension supports four toggle class buttons (the three shown below plus a fourth one with a default green colour):

So let’s extend the extension to add four buttons which add the tags ou-activity, ou-solution, ou-student and ou-commentate.

We might also want to add an updater routine that checks for the original empinken extension style tags and converts those to the new tags.

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            //activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            //activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // The following really needs to be simplified to a loop
    // that iterates over activity, solution, learner and tutor
    
    const activityButton = new ToolbarButton({
      className: 'tagger-activity-button',
      label: 'A',
      // The following construction lets us attach a 
      // parameterised function to the onclick event
      onClick: () => toggleTagButtonAction("activity"),
      tooltip: 'Toggle ou-activity metadata tag on a cell',
    });

    const solutionButton = new ToolbarButton({
      className: 'tagger-solution-button',
      label: 'S',
      onClick: () => toggleTagButtonAction("solution"),
      tooltip: 'Toggle ou-solution metadata tag on a cell',
    });
        
    const learnerButton = new ToolbarButton({
      className: 'tagger-learner-button',
      label: 'L',
      onClick: () => toggleTagButtonAction("learner"),
      tooltip: 'Toggle ou-learner metadata tag on a cell',
    });
        
    const tutorButton = new ToolbarButton({
      className: 'tagger-tutor-button',
      label: 'T',
      onClick: () => toggleTagButtonAction("tutor"),
      tooltip: 'Toggle ou-tutor metadata tag on a cell',
    });

    panel.toolbar.insertItem(10, 'toggleActivityTagButtonAction', activityButton);
    panel.toolbar.insertItem(11, 'toggleSolutionTagButtonAction', solutionButton);
    panel.toolbar.insertItem(12, 'toggleLearnerTagButtonAction', learnerButton);
    panel.toolbar.insertItem(13, 'toggleTutorTagButtonAction', tutorButton); 
    
    return new DisposableDelegate(() => {
      activityButton.dispose();
      solutionButton.dispose();
      learnerButton.dispose();
      tutorButton.dispose();
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

If we define style based on corresponding “generated” HTML DOM tag2class attributes, and ensure that such classes are added in synch with the metadata elements, we pretty much now have all the ingredients in place for a JuptyerLab empinken extension.

For example, we can add a class to the active (selected) node with constructions of the form activeCell.node.classList.add(typ + "-node") and activeCell.node.classList.remove(typ + "-node") and then style on activity-node etc.

We can simplify the code a little by creating a function that will handle the creation of buttons from a simple button config array:

    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }

    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
   

The whole script then becomes:

// Code cribbed from:
// https://github.com/jupyterlab/extension-examples/tree/master/toolbar-button


// Testing this in:
// https://github.com/jupyterlab/jupyterlab-plugin-playground

import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin,
} from '@jupyterlab/application';

import { ToolbarButton } from '@jupyterlab/apputils';

import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';

/**
 * The plugin registration information.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'ouseful-tag-class-demo',
  autoStart: true,
};

/**
 * A notebook widget extension that adds a button to the toolbar to toogle
 cell metadata (cell tags).
 */
export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
        
    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
        
        // Get a single selected cell
        const activeCell = notebook.activeCell;
        
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList){
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
        }
        
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
        
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
        
        /*
        We can also toggle tags...
        
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
        
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
        
        // Set the tag name
        const tagname = "ou-"+typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)

            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
            
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
            
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
        
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
        
    };

    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
    
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
    
    tagButtonSpec['activity'] = {'typ': 'activity',
                                 'label': 'A', 'location': 10 };

    tagButtonSpec['solution'] = {'typ': 'solution',
                                 'label': 'S', 'location': 11 }; 

    tagButtonSpec['learner'] = {'typ': 'learner',
                                'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor',
                              'label': 'T', 'location': 13 };
    
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ou-' + typ + ' metadata tag on a cell',
        })
        
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
    
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
function activate(app: JupyterFrontEnd): void {
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

/**
 * Export the plugin as default.
 */
export default plugin;

Given we now have a set-up array, we might contemplate using an extension settings file (for example, cribbing from the settings example in jupyterlab/extension-examples) to contain the button definitions and perhaps also the tag and class prefix. I don’t know if the Plugin Playground gives us access to settings the extension can use, so let’s save this unil we have an actual extension to hand.

It might also be handy to be able to use a settings file to custom set the colours used to with the tag classes, but I’m not sure how we can propagate values as CSS variable definitions?

In the next post in the series, we’ll pull all the pieces together and see if we can actually build a JupyterLab empinken extension.

Notes on the JupyterLab Notebook HTML DOM Model, Part 6: Pulling an Extension Together

Following on from ..

To set up the project directory, I used the jupyterlab/extension-cookiecutter-ts. As part of the set-up prompt, this aked if I wanted to support settings, so I optimistically said yes and we’ll see if I can figure out how to make use of them!

For guidance on setting up the development environment, see

The cookie cutter creates lots of files, but I’ll try to ignore as many of them as I can for as long as I can and we’ll see how far we get…

There’s a single index.ts file in the src/ directory, so I’ll see if I can get by just using that. The style/base.css looks like the place to pop the style.

Let’s start with the style file. I’m going to create colour backgrounds for four activity types and pop the style into style/base.css:

.iou-activity-node {
    background-color: lightblue !important;
}

.iou-solution-node {
    background-color: lightgreen !important;
}

.iou-learner-node {
    background-color: yellow !important;
}

.iou-tutor-node {
    background-color: lightpink !important;
}

For the code itself, we can reuse building blocks we have already produced. This includes:

  • buttons to toggle cells;
  • notebook loader to class cells based on metadata.

The code will all be in src/index.ts to keep things as simple as possible. Here’s what the cookiecutter gives as a starter for ten, noting there is some new code I’ve not met before that will relate to handling settings files…

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry],
  activate: (app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null) => {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');

    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }
  }
};

export default plugin;

Cribbing from the settings example extension, it looks like we can .get from the settings (as well as setting defaults in the script in advance). I’ve no idea where default config settings go in a settings file? In the example, there’s a schema/settings-example.json file. The cookiecutter created a schema/plugin.json file with a similar structure, and by inspection it looks like we can put default settings into the properties attribute.

Here’s a first attempt at my extension code, based on fragments retrieved from earlier posts in this series, and without any attempt at retrieving any properties:

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { ISettingRegistry } from '@jupyterlab/settingregistry';

// START: TH added:
import { IDisposable, DisposableDelegate } from '@lumino/disposable';

import { ToolbarButton } from '@jupyterlab/apputils';
import { DocumentRegistry } from '@jupyterlab/docregistry';

import {
  NotebookActions,
  NotebookPanel,
  INotebookModel,
} from '@jupyterlab/notebook';
// END: TH added

/**
 * Initialization data for the jupyterlab-empinken extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  activate,
  id: 'jupyterlab-empinken:plugin',
  autoStart: true,
  optional: [ISettingRegistry]
};

export class ButtonExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
  /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable on the added button
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    const toggleTagButtonAction = (typ) => {
        // The button will toggle a specific metadata tag on a cell 
        const notebook = panel.content;
         
        // Get a single selected cell
        const activeCell = notebook.activeCell;
         
        // Get the tags; these are defined on the more general model.metadata
        // Note that we could also set persistent state on model.metadata if required
        // We need to check tags are available...

        console.log("in and metadata is "+activeCell.model.metadata)
        let tagList = activeCell.model.metadata.get('tags') as string[];
        if (!tagList) {
          console.log("setting metadata..")
            activeCell.model.metadata.set('tags', new Array())
            tagList = activeCell.model.metadata.get('tags') as string[];
            console.log(" metadata is now "+activeCell.model.metadata)
        }
        console.log("continuing with metadata  "+activeCell.model.metadata)
         
        // What tags are defined on the cell to start with
        console.log("Cell tags at start are: " + tagList);
         
        // To simply add a tag to a cell on a button click,
        // we can .push() (i.e. append) the tag to the tag list
        // optionally checking first that it's not already there...
        //if !(tagList.includes("TESTTAG"))
        //    tagList.push("TESTTAG")
         
        /*
        We can also toggle tags...
         
        Note that this works at the data level but is not reflected
        with live updates in the cell property inspector if it is displayed.
         
        However, if you click to another cell, then click back, the 
        property inspector will now display the updated state.
        */
         
        // Set the tag name
        const tagname = tag_prefix + typ
        
        // If the tag exists...
        // Should we also take the opportunity to add
        // corresponding class tags here?
        if (tagList.includes(tagname)) {
            console.log("I see "+tagname)
 
            // ...then remove it
            const index = tagList.indexOf(tagname, 0);
            if (index > -1) {
               tagList.splice(index, 1);
            }
             
            // Remove class
            activeCell.node.classList.remove(tagname + "-node")
        }
        // ...else add it
        else {
            console.log("I don't see " + tagname)
            tagList.push(tagname)
             
            // Add class
            activeCell.node.classList.add(tagname + "-node")
        }
         
        // What tags are now set on the cell?
        console.log("Cell tags are now: " + tagList);
         
    };
 
    // If we specify the buttons we want in an array
    // we can then construct them all via the same function
     
    // Create an array to hold button definitions
    var tagButtonSpec = new Array();
     
    tagButtonSpec['activity'] = {'typ': 'activity', 'label': 'A', 'location': 10 };
    tagButtonSpec['solution'] = {'typ': 'solution', 'label': 'S', 'location': 11 }; 
    tagButtonSpec['learner'] = {'typ': 'learner', 'label': 'L', 'location': 12 };
    tagButtonSpec['tutor'] = {'typ': 'tutor', 'label': 'T', 'location': 13 };
     
    // Add a button for each element in the array
    for (const typ in tagButtonSpec) {
        // Create the button
        tagButtonSpec[typ]['button'] = new ToolbarButton({
            className: 'tagger-' + typ + '-button',
            label: tagButtonSpec[typ]['label'],
            onClick: () => toggleTagButtonAction(typ),
            tooltip: 'Toggle ' + tag_prefix + typ + ' metadata tag on a cell',
        })
         
        // Add the button to the toolbar
        panel.toolbar.insertItem(tagButtonSpec[typ]['location'], 'toggle_' + typ + 'TagButtonAction', tagButtonSpec[typ]['button']);
    }
     
    return new DisposableDelegate(() => {
        // Tidy up with destructors for each button
        for (var typ in tagButtonSpec) {
            tagButtonSpec[typ]['button'].dispose();
        }
    });
  }
}

/*
The most relevant docs appear to be:
https://jupyterlab.readthedocs.io/en/stable/api/modules/notebook.notebookactions.html
 
*/
export class ClassDemoExtension
  implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel>
{
    /**
   * Create a new extension for the notebook panel widget.
   *
   * @param panel Notebook panel
   * @param context Notebook context
   * @returns Disposable
   */
  createNew(
    panel: NotebookPanel,
    context: DocumentRegistry.IContext<INotebookModel>
  ): IDisposable {
    const notebook = panel.content;
    
    // Create a tag prefix
    // (this should be pulled from a setting()
    const tag_prefix = 'iou-';

    console.log("Tag prefix is " + tag_prefix);
    
    function check_tags(notebook){
        /*
        Iterate through all the cells
        If we see a cell with a tag that starts with class-
        then add a corresponding tag to differnt elements in the
        notebook HTML DOM
        */

        console.log("Checking to see if there are class tags to set on...")
        for (const cell of notebook.widgets) {
            let tagList = cell.model.metadata.get('tags') as string[];
            if (tagList) {
              for (let i = 0; i < tagList.length; i++) {
                var tag = tagList[i];
                console.log("I see tag " + tag);
                if (tag.startsWith(tag_prefix)) {
                  // class the cell
                  cell.node.classList.add(tag + "-node");
                } // end: if tag starts with prefix...
              } // end: tags iterator
            } //end: if there are tags
        } // end: iterate cells
    } // end: check_tags function definition
     
    notebook.modelChanged.connect((notebook) => {
        console.log("I think we're changed");
        // This doesn't seem to do anything on notebook load
        // iterate cells and toggle DOM classes as needed, e.g.
        //check_tags(notebook);
         
    });
         
    notebook.fullyRendered.connect((notebook) => {
        /*
        I don't think this means fully rendered on a cells becuase it seems
        like we try to add the tags mutliple times on notebook load
        which is really inefficient.
        This may be unstable anyway, eg the following comment:
            https://stackoverflow.com/questions/71736749/accessing-notebook-cell-metadata-and-html-class-attributes-in-jupyterlab-extensi/71744107?noredirect=1#comment126807644_71744107
        I'll with go with in now in the expectation it will be break
        and I will hopefully be able to find another even to fire from.
         
        I get the impression the UI is some some of signal hell and
        the solution is just to keep redoing things on everything
        if anything changes. Who needs efficiency or elegance anyway...!
        After all, this is just an end-user hack-from-a-position-of-ignorance
        and works sort of enough to do something that I couldn't do before...
        */
         
        console.log("I think we're fullyRendered...(?!)");
        // iterate cells and toggle DOM classes as needed, e.g.
        check_tags(notebook);
    });
     
    return new DisposableDelegate(() => {
    });
  }
}

/**
 * Activate the extension.
 *
 * @param app Main application object
 */
 function activate(app: JupyterFrontEnd, settingRegistry: ISettingRegistry | null): void {
    console.log('JupyterLab extension jupyterlab-empinken is activated!');
    console.log("YES WE'RE UPDATED 3")
    if (settingRegistry) {
      settingRegistry
        .load(plugin.id)
        .then(settings => {
          console.log('jupyterlab-empinken settings loaded:', settings.composite);
        })
        .catch(reason => {
          console.error('Failed to load settings for jupyterlab-empinken.', reason);
        });
    }

  app.docRegistry.addWidgetExtension('Notebook', new ClassDemoExtension());
  app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension());
}

export default plugin;

Of course, it failed to compile on first run, in part because of an unfound package, in part because of some compiler errors. Poking around, it looks as if you have to declare required packages in the package.json file under dependencies. These look like they need versioning, and I haven’t a clue. The latest version on npm is yesterday, which is probably not what I’m running, and from past experience I know that JupyterLab extensions can be really brittle at suffering from version conflicts. I feel I am in touching distance of getting an extension together, but even at these final steps there are blockers that just make you want to give and say f**k it. So assuming ^ means “at least version”, I’ll set a low one and hopefully be done with it given that things seem to work in the plugin playground. Which is to say, I added the dependency "@jupyterlab/notebook@jupyterlab/notebook": "^3.1.0" which was a version number in keeping with others…

As far as the other errors were concerned, these in part because of some unusused variables and some implicit any type declarations that the compiler took offence. If @martinRenou hadn’t noticed some of my frustration earlier in the week about the hostility of JupyterLab to have-a-go end-user developers and made a PR onto the cookiecutter extension (Disable noUnusedLocals option), I’d have possibly been a bit stuck at this point, but he did, so I was able to spot that the tsconfig.json file has various settings that set the level of compiler strictness, so I turned them off (e.g. setting "noImplicitAny": false, "noUnusedLocals": false and "strictNullChecks": false). FWIW, the last I saw the PR wasnlt being accepted; if I can get this extension working, and perhaps a cell status indicator extension, I’m going to go back to keeping as far as way as possible from JupyterLab extensions — it’s too hostile an environment for me!;-)

And then (after grief in the rebuild – I done a simple pip install --upgrade . on various rebuilds expecting that to work but of course it doesn’t. The cookie cutter README doesn’t give any adviace, but the jupyterlab/extension-example suggests the following dev process:

# go to the hello world example
cd EXTENSION_PROJECT_DIR

# install the extension in editable mode
python -m pip install -e .

# install your development version of the extension with JupyterLab
jupyter labextension develop . --overwrite

# build the TypeScript source after making changes
jlpm run build

# start JupyterLab
jupyter lab

I’m hoping when I restart my “daily” jupyter server up again the latest version of the extension is installed into it.

When it works, it looks like this (tagged cells highlighted); I need to tune the colours, but enough for now or I’ll miss the fish and chip shop, Friday as it is!

Looking at the code, it’s not that complex, but without @krassowksi answering my Stack Overflow question, and going further in the comments, and @martinRenou picking up on my ire and giving me a crib I needed in PR he added that seemed otherwise to get the thumbs down from folk, I would have walked away from this aagin, again, for another three months; so I am hugely grateful to them and will get a round in anytime our paths cross:-)

Of course, at the same time I am hugely grumpy and p****d off by the myriad tiny blockers that might be “just” minor inconveniences for folk who work with this stuff everyday.

In the next post in this series, I’ll take a look at how to manage user settings in an extension.

Next up, other extension wise, I will try to port something approximating the cell execution status extension, though I really need to figure out settings for that if I try to add things like the audio accessibility elements. And once that’s done, I think that might be enough for our classic notebooks to run with our styling in JupyterLab. And then I will hopefllu be able to walk away from JupyterLab until they break, and look for alternative authoring and execution environments for MyST style documents. Long live Jupyter protocols, may the devil take JupyterLab!;-)

PS Argghh… forgot… I need to add OUr logo, but various issues languishing for years etc; if you know a hack, please share it via the comments; in classic notebook my solution was just to dodge the old logo out of the way with a bit of CSS and add our one in… Here are a couple of possible cribs: adding a custom splash screen to JuyterLab; Jupyterlite logo extension (issue and PR). See also this PR on updating the JupyterLab logo.

PPS more JupyterLab logo cribs: DOM cell ID is jp-MainLogo ; creating a logo widget: https://github.com/jupyterlab/jupyterlab/blob/7f87d89dc88ad86e84fb2245c873139ce60dec3c/packages/application-extension/src/index.tsx#L1034 ; uses import {jupyterIcon} from @jupyterlab/ui-components. Defined at https://github.com/jupyterlab/jupyterlab/blob/95fc6e74a21953c44a17a73c3d6741ac66320979/packages/ui-components/src/icon/iconimports.ts#L146 by way of import jupyterSvgstr from '../../style/icons/jupyter/jupyter.svg'; So it looks like we should crate an extension to add a “CustomLogo: JupyterFrontEndPlugin” based on const JupyterLogo: JupyterFrontEndPlugin<void>, remove the current Logo somehow, and apply the new logo. There’s also an old hack similar to one I used in classic notebook: https://discourse.jupyter.org/t/change-icons-in-theme-extension/3851/8 .