<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>Datafication blog</title>
  <link>https://datafication.co/blog/index.html</link>
  <atom:link href="https://datafication.co/blog/feed.xml" rel="self" type="application/rss+xml" />
  <description>Release notes with context, design decisions, how-to guides, and essays from the people who build the Datafication SDK, Verso, and Motus.</description>
  <language>en-us</language>
  <lastBuildDate>Sun, 06 Sep 2026 22:39:49 +0000</lastBuildDate>
  <item>
    <title>Five layouts, one notebook file, no host changes</title>
    <link>https://datafication.co/blog/2026/five-layouts-one-notebook.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/five-layouts-one-notebook.html</guid>
    <pubDate>Sat, 05 Sep 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>A layout decides how a notebook is arranged and rendered, and it is an extension like any other: five samples on NuGet turn one file into a graph, a deck, a spreadsheet, a dashboard, and an image editor.</description>
    <content:encoded><![CDATA[<p>Verso ships three layouts. Notebook is the linear list of editable cells, Dashboard turns cells into tiles you can move, resize, and run, and Presentation hides the editing chrome to show results. Pick one from the View panel and the notebook redraws beside the list, cells and outputs untouched: a layout decides only how they are arranged.</p>
<p>All three are extensions, written against the interfaces we publish. I have <a href="https://datafication.co/blog/2026/every-feature-is-an-extension.html">said that before</a>, and it is easy to write and hard to believe. So the repository now carries five sample layouts that make the case the only way it can be made: installable from NuGet, on the public interfaces, turning a notebook into something you would not guess it could be.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Extension</th>
<th>What the notebook becomes</th>
<th>Package</th>
<th>Renderer</th>
<th>Bundled JavaScript</th>
</tr>
</thead>
<tbody>
<tr>
<td>DAG Notebook</td>
<td>A reactive graph: cells linked by the variables they share</td>
<td><code>Verso.Showcase.DagNotebook</code></td>
<td>Inline</td>
<td>None</td>
</tr>
<tr>
<td>Slide Studio</td>
<td>A deck editor with a filmstrip, a split view, and a presenter</td>
<td><code>Verso.Showcase.SlideStudio</code></td>
<td>Inline</td>
<td>None</td>
</tr>
<tr>
<td>Grid Studio</td>
<td>An editable spreadsheet bound to a kernel variable</td>
<td><code>Verso.Showcase.GridStudio</code></td>
<td>Isolated</td>
<td>Jspreadsheet CE 4.15.0, jsuites 5.13.3</td>
</tr>
<tr>
<td>Form Studio</td>
<td>A dashboard you build by dragging, driving the notebook's code</td>
<td><code>Verso.Showcase.FormStudio</code></td>
<td>Isolated</td>
<td>Chart.js 4.4.6</td>
</tr>
<tr>
<td>Image Studio</td>
<td>A layered image compositor with a canvas and a layer stack</td>
<td><code>Verso.Showcase.ImageStudio</code></td>
<td>Isolated</td>
<td>None</td>
</tr>
</tbody>
</table></div>
<p>All five published 1.2.2 on 2026-09-04, all MIT, each referencing <code>Verso.Abstractions</code> and nothing else. Installed, they show up in the View panel beside the three built-ins, with nothing marking them as newcomers.</p>
<h2 id="two-ways-a-layout-draws">Two ways a layout draws</h2>
<p>A layout picks one of two renderer modes and stays on it.</p>
<p>An <strong>inline</strong> layout returns HTML from <code>RenderLayoutAsync</code> with a placeholder where each cell goes. The host matches every <code>[data-cell-slot]</code> element to a cell and mounts the real cell component into the slot. That is why the editor in a Slide Studio deck is the same Monaco editor, run button and all.</p>
<p>An <strong>isolated</strong> layout draws its own surface instead. It sets <code>RendererIsolation</code> to <code>Isolated</code> and returns its own bundle from <code>GetRendererPackageAsync</code>, and the host mounts that in a sandboxed iframe with a <code>window.verso</code> bridge as the only channel out.</p>
<figure class="fig">
<svg viewBox="0 0 720 300" role="img" aria-label="Inline layouts emit cell slots the host fills with real cells, while isolated layouts render in a sandboxed frame with a bridge and no network">
  <text class="label b" x="20" y="24">Inline</text>
  <rect class="box" x="20" y="36" width="310" height="212" rx="6"/>
  <text class="muted" x="36" y="58">host page</text>
  <rect class="box on" x="40" y="70" width="270" height="160" rx="4"/>
  <text class="label b" x="175" y="94" text-anchor="middle">layout HTML</text>
  <rect class="soft" x="60" y="112" width="230" height="42" rx="4"/>
  <text class="code" x="175" y="138" text-anchor="middle">data-cell-slot</text>
  <rect class="soft" x="60" y="166" width="230" height="42" rx="4"/>
  <text class="muted" x="175" y="272" text-anchor="middle">one slot per cell, filled by the host</text>
  <text class="label b" x="390" y="24">Isolated</text>
  <rect class="box" x="390" y="36" width="310" height="212" rx="6"/>
  <text class="muted" x="406" y="58">host page</text>
  <rect class="box on" x="410" y="70" width="270" height="160" rx="4"/>
  <text class="label b" x="545" y="94" text-anchor="middle">sandboxed frame</text>
  <rect class="soft" x="430" y="110" width="230" height="44" rx="4"/>
  <text class="code" x="545" y="137" text-anchor="middle">renderer module</text>
  <path class="brand-line" d="M470 154 V190 M620 190 V154" stroke-width="2"/>
  <text class="code" x="545" y="208" text-anchor="middle">window.verso</text>
  <text class="muted" x="545" y="272" text-anchor="middle">no network inside the frame</text>
</svg>
<figcaption>Two renderer modes, one interaction contract: the same handler serves both, whichever mode delivered the event.</figcaption>
</figure>

<p>The frame is sandboxed with scripts allowed and nothing else, and the base policy sets <code>connect-src 'none'</code>, so an isolated layout cannot fetch anything. Data arrives the other way: read from a kernel variable on the C# side, pushed into the live frame, with edits coming back as interactions.</p>
<h2 id="what-each-one-shows">What each one shows</h2>
<p><strong>DAG Notebook</strong> links cells by the variables they share, marks a cell stale when its inputs have run more recently, and with auto-run on re-runs a finished cell's dependents in dependency order. The graph is a heuristic scan of the source rather than a proof: a name written by two cells gets warning chips and no edges, and cycles stay out of the cascade. It makes the biggest claim of the five: a layout can change execution semantics, not just how things look. We walked a notebook driven this way in <a href="https://datafication.co/blog/2026/a-notebook-that-reruns-itself.html">a dial at the top</a>.</p>
<p><strong>Slide Studio</strong> is a deck editor: a filmstrip of cell previews, the selected cell's live editor and rendered output behind a draggable splitter, and a full-screen presenter mode. Three checkboxes per cell decide whether a slide is included and whether it shows source, output, or both, and presenting never re-executes anything. It declares cell editing and execution but not insert, delete, or reorder: a deck is arranged here and restructured in the notebook layout.</p>
<p><strong>Grid Studio</strong> binds a kernel variable to an editable, Excel-like grid. The schema drives the columns, so numbers edit as numbers and booleans as checkboxes, and committing an edit writes a rebuilt <code>DataBlock</code> back to the same variable. A <code>System.Data.DataTable</code>, such as the results a SQL cell shared, is read-only on purpose: a <code>DataTable</code> can be wired to a data adapter, so writing edits back could prime a database update nobody asked for.</p>
<p><strong>Form Studio</strong> is a drag-and-drop dashboard builder. Sliders, dropdowns, toggles, text fields, labels, and charts drop onto a canvas, each input binds to a kernel variable by name, and with auto-run on it re-runs the notebook and pushes fresh data into the charts. The compute cell reads its inputs with <code>Variables.Get&lt;T&gt;("name")</code>, which returns a default before any widget is bound, so the notebook still runs on its own.</p>
<p><strong>Image Studio</strong> is a layered compositor: canvas in the middle, layer stack on the right, tools on the left. Layers can be solids, gradients, patterns, text, or procedural, and a procedural layer defers its drawing to a kernel variable, so re-running a code cell repaints it. While it is active it adds PNG Image and SVG Image to the host's Export menu, absent everywhere else.</p>
<h2 id="the-habits-they-share">The habits they share</h2>
<p>They theme through the host's <code>--verso-*</code> CSS variables rather than asking what the theme is, so they re-color on a switch without knowing a palette by name. A canvas is the exception, so Form Studio reads the tokens into its chart options.</p>
<p>They persist their own state through layout metadata: a dashboard's widgets, a deck's slide flags, a compositor's layer stack, and a grid's binding all travel in the <code>.verso</code> file, with no sidecar files.</p>
<p>And they read data by reflection rather than by reference. Grid Studio and Form Studio work with <code>DataBlock</code> values from Datafication.Core without referencing that package, reading whichever assembly the kernel loaded with <code>#r "nuget: ..."</code>.</p>
<h2 id="opening-one-and-building-one">Opening one, and building one</h2>
<p>Each sample has a notebook beside it naming its package in <code>extensions.required</code>, so the host fetches, loads, and activates the layout before the first render, and an <code>activeLayout</code> entry opens the file straight into it. For a notebook of your own, search <code>Verso.Showcase</code> in the Extensions pane, which finds all five, install one, and trust it when asked, per notebook and pinned to that version.</p>
<p>To write your own, work in the build loop instead. Each sample builds standalone against the local <code>Verso.Abstractions</code> project, then loads with a <code>#!extension</code> line naming the built assembly.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>build<span class="w"> </span>samples/showcase/grid-studio/src/Verso.Showcase.GridStudio
</code></pre></div>

<div class="highlight"><pre><span></span><code>#!extension ./src/Verso.Showcase.GridStudio/bin/Debug/net8.0/Verso.Showcase.GridStudio.dll
</code></pre></div>

<h2 id="why-i-built-five-of-them">Why I built five of them</h2>
<p>The layout system arrived with <a href="https://datafication.co/blog/2026/verso-1-1-custom-layouts-and-a-marketplace.html">1.1</a>, and the test of an extension surface is whether someone outside the team could build something surprising on it. A spreadsheet with write-back, a presenter, and an image editor with its own document model usually need a fork. Here they are five packages, and the host does not know they are special.</p>
<p>They are samples, not products, each small enough to read in an afternoon. Install one, open a notebook you already have in it, and tell me what the layout gets wrong about your file. The <a href="https://www.versonotebooks.com/docs/showcase/overview.html">Showcase Extensions</a> pages cover all five, and the <a href="https://www.versonotebooks.com/docs/extensions/layouts.html">Layout Authoring Guide</a> covers the interfaces underneath them.</p>]]></content:encoded>
  </item>
  <item>
    <title>One table, three languages, one variable</title>
    <link>https://datafication.co/blog/2026/one-table-three-languages.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/one-table-three-languages.html</guid>
    <pubDate>Fri, 04 Sep 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>I parse the Palmer Penguins CSV once in C#, group it in Python, aggregate it in SQL, and render it as a typed DataFrame table in PowerShell, all in one notebook with no export step anywhere in it.</description>
    <content:encoded><![CDATA[<p>Most of the work in a mixed-language analysis isn't analysis. It's moving the table around. You parse a CSV in one language, write a temporary file, read it back somewhere else, then do it again for the next language. The cells that carry a question end up outnumbered by the cells that carry a file.</p>
<p>I wanted to see how small that gets when the languages share memory instead of a folder, so I built a small analysis of the Palmer Penguins dataset inside one Verso notebook. C# parses the table once. Python groups it. SQL aggregates it. PowerShell draws the final table. Three languages do the analysis, a fourth renders it, and none of them exports anything for the next one.</p>
<p>Verso 1.2.2 ships today, and one of the things in it is <code>Verso.DataFrame</code>, a formatter that renders a <code>Microsoft.Data.Analysis.DataFrame</code> as a scrollable table with column names and types. That was the last piece I was missing, so today's the day to write the whole thing down. The formatter and the Palmer Penguins sample notebook it was built against both came from <a href="https://github.com/eosfor">@eosfor</a>, in #94 and #95.</p>
<div class="stats">
<div><div class="big">344</div><span class="mono">rows in the CSV</span></div>
<div><div class="big">8</div><span class="mono">columns</span></div>
<div><div class="big">4</div><span class="mono">kernels, one file</span></div>
<div><div class="big">0</div><span class="mono">export steps</span></div>
</div>

<h2 id="the-dataset">The dataset</h2>
<p>Palmer Penguins is body-measurement data for 344 penguins observed on three islands in the Palmer Archipelago, Antarctica, over three field seasons. The <a href="https://allisonhorst.github.io/palmerpenguins/">palmerpenguins project</a> publishes it as a plain CSV. I like it for the same reason teachers like it: it's small enough to read, it has an obvious grouping, and it has holes in it, which is where real analysis spends its time.</p>
<p>One header row, 344 data rows, and missing values written as the literal text <code>NA</code>.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Column</th>
<th>Values</th>
<th style="text-align: right;">Missing</th>
<th>What is in it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>species</code></td>
<td>text</td>
<td style="text-align: right;">0</td>
<td>Adelie 152, Chinstrap 68, Gentoo 124</td>
</tr>
<tr>
<td><code>island</code></td>
<td>text</td>
<td style="text-align: right;">0</td>
<td>Biscoe 168, Dream 124, Torgersen 52</td>
</tr>
<tr>
<td><code>bill_length_mm</code></td>
<td>number</td>
<td style="text-align: right;">2</td>
<td>32.1 to 59.6</td>
</tr>
<tr>
<td><code>bill_depth_mm</code></td>
<td>number</td>
<td style="text-align: right;">2</td>
<td>13.1 to 21.5</td>
</tr>
<tr>
<td><code>flipper_length_mm</code></td>
<td>integer</td>
<td style="text-align: right;">2</td>
<td>172 to 231</td>
</tr>
<tr>
<td><code>body_mass_g</code></td>
<td>integer</td>
<td style="text-align: right;">2</td>
<td>2700 to 6300</td>
</tr>
<tr>
<td><code>sex</code></td>
<td>text</td>
<td style="text-align: right;">11</td>
<td>male 168, female 165</td>
</tr>
<tr>
<td><code>year</code></td>
<td>integer</td>
<td style="text-align: right;">0</td>
<td>2007, 2008, 2009</td>
</tr>
</tbody>
</table></div>
<p>Eleven rows are missing something. Two of them are missing every measurement and their sex on top of that, and the other nine are missing only sex. That distinction comes back in a few minutes. A count and a mean over the same column can disagree about how many birds there are, and you want to know why before you find it in a chart.</p>
<p>Every cell below reads the file straight from this URL:</p>
<div class="highlight"><pre><span></span><code>https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv
</code></pre></div>

<h2 id="setting-up">Setting up</h2>
<p>Install the command line tool and start the browser editor:</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
verso<span class="w"> </span>serve
</code></pre></div>

<p><code>verso serve</code> opens a notebook editor on port 5050. If you'd rather work in VS Code, everything below runs there too: both hosts run the same engine and the same kernels, and the notebook has no idea which one is in front of it.</p>
<p>Two cells want something extra. The SQL step writes to SQLite, so a C# cell pulls the provider in with <code>#!nuget Microsoft.Data.Sqlite</code>. The last cell uses the <code>DataFrame</code> PowerShell module, which the sample notebook installs from a regular PowerShell terminal before you start Verso:</p>
<div class="highlight"><pre><span></span><code><span class="nb">Install-Module</span> <span class="n">DataFrame</span> <span class="n">-Scope</span> <span class="n">CurrentUser</span>
</code></pre></div>

<p>Nothing else gets installed, and no dataset file gets committed anywhere.</p>
<h2 id="cell-1-fetch-and-parse-in-c">Cell 1: fetch and parse, in C</h2>
<div class="highlight"><pre><span></span><code><span class="k">using</span><span class="w"> </span><span class="nn">System.Globalization</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">System.Net.Http</span><span class="p">;</span>

<span class="k">record</span><span class="w"> </span><span class="nf">Penguin</span><span class="p">(</span>
<span class="w">    </span><span class="kt">string</span><span class="w"> </span><span class="n">Species</span><span class="p">,</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Island</span><span class="p">,</span>
<span class="w">    </span><span class="kt">double?</span><span class="w"> </span><span class="n">BillLengthMm</span><span class="p">,</span><span class="w"> </span><span class="kt">double?</span><span class="w"> </span><span class="n">BillDepthMm</span><span class="p">,</span>
<span class="w">    </span><span class="kt">double?</span><span class="w"> </span><span class="n">FlipperLengthMm</span><span class="p">,</span><span class="w"> </span><span class="kt">double?</span><span class="w"> </span><span class="n">BodyMassG</span><span class="p">,</span>
<span class="w">    </span><span class="kt">string</span><span class="w"> </span><span class="n">Sex</span><span class="p">,</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="n">Year</span><span class="p">);</span>

<span class="kt">double?</span><span class="w"> </span><span class="n">Number</span><span class="p">(</span><span class="kt">string</span><span class="w"> </span><span class="n">field</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">    </span><span class="n">field</span><span class="w"> </span><span class="k">is</span><span class="w"> </span><span class="s">&quot;NA&quot;</span><span class="w"> </span><span class="k">or</span><span class="w"> </span><span class="s">&quot;&quot;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="kt">double</span><span class="p">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">field</span><span class="p">,</span><span class="w"> </span><span class="n">CultureInfo</span><span class="p">.</span><span class="n">InvariantCulture</span><span class="p">);</span>

<span class="kt">var</span><span class="w"> </span><span class="n">url</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv&quot;</span><span class="p">;</span>
<span class="kt">var</span><span class="w"> </span><span class="n">csv</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">HttpClient</span><span class="p">().</span><span class="n">GetStringAsync</span><span class="p">(</span><span class="n">url</span><span class="p">);</span>

<span class="kt">var</span><span class="w"> </span><span class="n">penguins</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">csv</span>
<span class="w">    </span><span class="p">.</span><span class="n">Split</span><span class="p">(</span><span class="sc">&#39;\n&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">StringSplitOptions</span><span class="p">.</span><span class="n">RemoveEmptyEntries</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">Skip</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">line</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">line</span><span class="p">.</span><span class="n">Trim</span><span class="p">().</span><span class="n">Split</span><span class="p">(</span><span class="sc">&#39;,&#39;</span><span class="p">))</span>
<span class="w">    </span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">f</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">Penguin</span><span class="p">(</span>
<span class="w">        </span><span class="n">f</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span><span class="w"> </span><span class="n">f</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span>
<span class="w">        </span><span class="n">Number</span><span class="p">(</span><span class="n">f</span><span class="p">[</span><span class="mi">2</span><span class="p">]),</span><span class="w"> </span><span class="n">Number</span><span class="p">(</span><span class="n">f</span><span class="p">[</span><span class="mi">3</span><span class="p">]),</span><span class="w"> </span><span class="n">Number</span><span class="p">(</span><span class="n">f</span><span class="p">[</span><span class="mi">4</span><span class="p">]),</span><span class="w"> </span><span class="n">Number</span><span class="p">(</span><span class="n">f</span><span class="p">[</span><span class="mi">5</span><span class="p">]),</span>
<span class="w">        </span><span class="n">f</span><span class="p">[</span><span class="mi">6</span><span class="p">]</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="s">&quot;NA&quot;</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">f</span><span class="p">[</span><span class="mi">6</span><span class="p">],</span>
<span class="w">        </span><span class="kt">int</span><span class="p">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">f</span><span class="p">[</span><span class="mi">7</span><span class="p">],</span><span class="w"> </span><span class="n">CultureInfo</span><span class="p">.</span><span class="n">InvariantCulture</span><span class="p">)))</span>
<span class="w">    </span><span class="p">.</span><span class="n">ToList</span><span class="p">();</span>

<span class="n">penguins</span><span class="p">.</span><span class="n">Count</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>344
</code></pre></div>

<p>Look at what isn't in that cell: a sharing step. When a cell finishes, the kernel walks its own state and publishes what it found into the notebook's variable store. The C# kernel reads Roslyn's script state variables, so <code>csv</code> and <code>penguins</code> are in the store the moment the cell returns, under exactly the names they have in the code. You can put something there by hand with <code>Variables.Set("name", value)</code>, but for ordinary top-level variables you never have to.</p>
<p>One store per session, no per-kernel isolation. Every cell that runs after this one, in any language, can see both names.</p>
<figure class="fig">
<svg viewBox="0 0 720 290" role="img" aria-label="A grid showing which variable names exist in the shared store after each of the notebook's seven cells, growing from two names to six">
  <text class="label b" x="10" y="24">Names in the store after each cell</text>

  <text class="label b" x="190" y="46" text-anchor="middle">1</text>
  <text class="muted" x="190" y="62" text-anchor="middle">C#</text>
  <text class="label b" x="270" y="46" text-anchor="middle">2</text>
  <text class="muted" x="270" y="62" text-anchor="middle">Python</text>
  <text class="label b" x="350" y="46" text-anchor="middle">3</text>
  <text class="muted" x="350" y="62" text-anchor="middle">C#</text>
  <text class="label b" x="430" y="46" text-anchor="middle">4</text>
  <text class="muted" x="430" y="62" text-anchor="middle">connect</text>
  <text class="label b" x="510" y="46" text-anchor="middle">5</text>
  <text class="muted" x="510" y="62" text-anchor="middle">SQL</text>
  <text class="label b" x="590" y="46" text-anchor="middle">6</text>
  <text class="muted" x="590" y="62" text-anchor="middle">Python</text>
  <text class="label b" x="670" y="46" text-anchor="middle">7</text>
  <text class="muted" x="670" y="62" text-anchor="middle">PowerShell</text>

  <path class="line" d="M10 74 H710" stroke-width="1"/>

  <text class="code" x="10" y="102">penguins</text>
  <path class="line" d="M10 114 H710" stroke-width="1"/>
  <circle class="brand" cx="190" cy="98" r="5"/>
  <circle class="brand" cx="270" cy="98" r="5"/>
  <circle class="brand" cx="350" cy="98" r="5"/>
  <circle class="brand" cx="430" cy="98" r="5"/>
  <circle class="brand" cx="510" cy="98" r="5"/>
  <circle class="brand" cx="590" cy="98" r="5"/>
  <circle class="brand" cx="670" cy="98" r="5"/>

  <text class="code" x="10" y="138">csv</text>
  <path class="line" d="M10 150 H710" stroke-width="1"/>
  <circle class="brand" cx="190" cy="134" r="5"/>
  <circle class="brand" cx="270" cy="134" r="5"/>
  <circle class="brand" cx="350" cy="134" r="5"/>
  <circle class="brand" cx="430" cy="134" r="5"/>
  <circle class="brand" cx="510" cy="134" r="5"/>
  <circle class="brand" cx="590" cy="134" r="5"/>
  <circle class="brand" cx="670" cy="134" r="5"/>

  <text class="code" x="10" y="174">minMass</text>
  <path class="line" d="M10 186 H710" stroke-width="1"/>
  <circle class="brand" cx="270" cy="170" r="5"/>
  <circle class="brand" cx="350" cy="170" r="5"/>
  <circle class="brand" cx="430" cy="170" r="5"/>
  <circle class="brand" cx="510" cy="170" r="5"/>
  <circle class="brand" cx="590" cy="170" r="5"/>
  <circle class="brand" cx="670" cy="170" r="5"/>

  <text class="code" x="10" y="210">dbPath</text>
  <path class="line" d="M10 222 H710" stroke-width="1"/>
  <circle class="brand" cx="350" cy="206" r="5"/>
  <circle class="brand" cx="430" cy="206" r="5"/>
  <circle class="brand" cx="510" cy="206" r="5"/>
  <circle class="brand" cx="590" cy="206" r="5"/>
  <circle class="brand" cx="670" cy="206" r="5"/>

  <text class="code" x="10" y="246">heavyBirds</text>
  <path class="line" d="M10 258 H710" stroke-width="1"/>
  <circle class="brand" cx="510" cy="242" r="5"/>
  <circle class="brand" cx="590" cy="242" r="5"/>
  <circle class="brand" cx="670" cy="242" r="5"/>

  <text class="code" x="10" y="282">frame</text>
  <circle class="brand" cx="670" cy="278" r="5"/>
</svg>
<figcaption>Six names, made by four languages, and every cell downstream can see all of them. Cell 4 adds no names of its own; it opens the database connection.</figcaption>
</figure>

<h2 id="cell-2-group-it-in-python">Cell 2: group it, in Python</h2>
<p>Python runs out of process, against the CPython you already have on your machine, so the rows reach it as data instead of as .NET objects. A record shows up as a mapping that answers both spellings, <code>p.Species</code> and <code>p["Species"]</code>, so a list of records reads like a list of small objects on the Python side.</p>
<div class="highlight"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">pandas</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">pd</span>

<span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">([</span>
    <span class="p">{</span>
        <span class="s2">&quot;species&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">Species</span><span class="p">,</span>
        <span class="s2">&quot;island&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">Island</span><span class="p">,</span>
        <span class="s2">&quot;bill_length_mm&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">BillLengthMm</span><span class="p">,</span>
        <span class="s2">&quot;flipper_length_mm&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">FlipperLengthMm</span><span class="p">,</span>
        <span class="s2">&quot;body_mass_g&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">BodyMassG</span><span class="p">,</span>
        <span class="s2">&quot;sex&quot;</span><span class="p">:</span> <span class="n">p</span><span class="o">.</span><span class="n">Sex</span><span class="p">,</span>
    <span class="p">}</span>
    <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">penguins</span>
<span class="p">])</span>

<span class="k">for</span> <span class="p">(</span><span class="n">species</span><span class="p">,</span> <span class="n">island</span><span class="p">),</span> <span class="n">group</span> <span class="ow">in</span> <span class="n">df</span><span class="o">.</span><span class="n">groupby</span><span class="p">([</span><span class="s2">&quot;species&quot;</span><span class="p">,</span> <span class="s2">&quot;island&quot;</span><span class="p">]):</span>
    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">species</span><span class="si">:</span><span class="s2">&lt;10</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">island</span><span class="si">:</span><span class="s2">&lt;10</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">group</span><span class="p">)</span><span class="si">:</span><span class="s2">&gt;4</span><span class="si">}</span><span class="s2">  </span><span class="si">{</span><span class="n">group</span><span class="p">[</span><span class="s1">&#39;body_mass_g&#39;</span><span class="p">]</span><span class="o">.</span><span class="n">mean</span><span class="p">()</span><span class="si">:</span><span class="s2">&gt;8.1f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>

<span class="n">minMass</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">df</span><span class="p">[</span><span class="s2">&quot;body_mass_g&quot;</span><span class="p">]</span><span class="o">.</span><span class="n">median</span><span class="p">())</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="se">\n</span><span class="s2">median body mass: </span><span class="si">{</span><span class="n">minMass</span><span class="si">}</span><span class="s2"> g&quot;</span><span class="p">)</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>Adelie     Biscoe       44    3709.7
Adelie     Dream        56    3688.4
Adelie     Torgersen    52    3706.4
Chinstrap  Dream        68    3733.1
Gentoo     Biscoe      124    5076.0

median body mass: 4050 g
</code></pre></div>

<p>Two things fall out of that. The shape of the data is the easy one: Adelie penguins are on all three islands, Chinstrap only on Dream, Gentoo only on Biscoe, and Gentoo outweigh the other two by well over a kilogram.</p>
<p>The second is the null handling, and it's the kind of thing I like catching early. Adelie on Torgersen counts 52 birds and averages 51 masses. One of the two rows with no measurements is a Torgersen Adelie, and pandas leaves it out of the mean without warning you. A C# <code>double?</code> with no value arrives in Python as <code>None</code>, pandas turns a numeric column containing <code>None</code> into <code>float64</code> with <code>NaN</code>, and <code>mean</code> quietly ignores it while <code>len</code> does not. Which of the two you want is your call. Noticing you have a call to make is the part that gets easier here, because the C# cell that produced the nulls is right above this one.</p>
<figure class="fig">
<svg viewBox="0 0 720 290" role="img" aria-label="Horizontal bar chart of mean body mass in grams for each species and island group, with Gentoo on Biscoe far ahead of the other four">
  <text class="label b" x="10" y="24">Mean body mass by species and island, in grams</text>

  <path class="line" d="M180 38 V248" stroke-width="1"/>
  <path class="line" d="M261.5 38 V248" stroke-width="1"/>
  <path class="line" d="M343 38 V248" stroke-width="1"/>
  <path class="line" d="M424.4 38 V248" stroke-width="1"/>
  <path class="line" d="M505.9 38 V248" stroke-width="1"/>
  <path class="line" d="M587.4 38 V248" stroke-width="1"/>

  <text class="label" x="170" y="66" text-anchor="end">Adelie, Biscoe</text>
  <rect class="brand" x="180" y="48" width="302.2" height="26"/>
  <text class="muted" x="490.2" y="66">3,709.7</text>

  <text class="label" x="170" y="108" text-anchor="end">Adelie, Dream</text>
  <rect class="brand" x="180" y="90" width="300.5" height="26"/>
  <text class="muted" x="488.5" y="108">3,688.4</text>

  <text class="label" x="170" y="150" text-anchor="end">Adelie, Torgersen</text>
  <rect class="brand" x="180" y="132" width="302" height="26"/>
  <text class="muted" x="490" y="150">3,706.4</text>

  <text class="label" x="170" y="192" text-anchor="end">Chinstrap, Dream</text>
  <rect class="brand" x="180" y="174" width="304.2" height="26"/>
  <text class="muted" x="492.2" y="192">3,733.1</text>

  <text class="label" x="170" y="234" text-anchor="end">Gentoo, Biscoe</text>
  <rect class="brand" x="180" y="216" width="413.6" height="26"/>
  <text class="muted" x="601.6" y="234">5,076.0</text>

  <path class="line" d="M180 248 H710" stroke-width="1"/>
  <text class="muted" x="180" y="266" text-anchor="middle">0</text>
  <text class="muted" x="261.5" y="266" text-anchor="middle">1,000</text>
  <text class="muted" x="343" y="266" text-anchor="middle">2,000</text>
  <text class="muted" x="424.4" y="266" text-anchor="middle">3,000</text>
  <text class="muted" x="505.9" y="266" text-anchor="middle">4,000</text>
  <text class="muted" x="587.4" y="266" text-anchor="middle">5,000</text>
</svg>
<figcaption>The five groups the Python cell printed. The counts include the rows with no mass recorded. The means do not.</figcaption>
</figure>

<p>The last line assigns <code>minMass</code>. Work you do in Python comes back when you assign a name, so <code>minMass</code> is now in the same store as <code>penguins</code> and <code>csv</code>, ready for the next language. The one thing that doesn't come back is modifying a shared collection in place, so assign your result to a name instead of appending to a list that came from another language.</p>
<h2 id="cell-3-put-the-same-rows-in-a-database-in-c">Cell 3: put the same rows in a database, in C</h2>
<p>The SQL kernel talks to a database over ADO.NET, so it needs a real one. This cell writes the rows that are already in memory into a SQLite file. It's the only cell in the notebook that writes a file, and it does that because a database is the point of the next two cells, not because the table had to be handed over.</p>
<div class="highlight"><pre><span></span><code><span class="err">#</span><span class="o">!</span><span class="n">nuget</span><span class="w"> </span><span class="n">Microsoft</span><span class="p">.</span><span class="n">Data</span><span class="p">.</span><span class="n">Sqlite</span>

<span class="k">using</span><span class="w"> </span><span class="nn">Microsoft.Data.Sqlite</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">System.IO</span><span class="p">;</span>

<span class="kt">var</span><span class="w"> </span><span class="n">dbPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Path</span><span class="p">.</span><span class="n">Combine</span><span class="p">(</span><span class="n">Path</span><span class="p">.</span><span class="n">GetTempPath</span><span class="p">(),</span><span class="w"> </span><span class="s">&quot;penguins.db&quot;</span><span class="p">);</span>
<span class="n">File</span><span class="p">.</span><span class="n">Delete</span><span class="p">(</span><span class="n">dbPath</span><span class="p">);</span>

<span class="kt">var</span><span class="w"> </span><span class="n">db</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">SqliteConnection</span><span class="p">(</span><span class="s">$&quot;Data Source={dbPath}&quot;</span><span class="p">);</span>
<span class="n">db</span><span class="p">.</span><span class="n">Open</span><span class="p">();</span>

<span class="kt">var</span><span class="w"> </span><span class="n">ddl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">db</span><span class="p">.</span><span class="n">CreateCommand</span><span class="p">();</span>
<span class="n">ddl</span><span class="p">.</span><span class="n">CommandText</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">@&quot;CREATE TABLE penguins (</span>
<span class="s">    species TEXT, island TEXT,</span>
<span class="s">    bill_length_mm REAL, bill_depth_mm REAL,</span>
<span class="s">    flipper_length_mm REAL, body_mass_g REAL,</span>
<span class="s">    sex TEXT, year INTEGER)&quot;</span><span class="p">;</span>
<span class="n">ddl</span><span class="p">.</span><span class="n">ExecuteNonQuery</span><span class="p">();</span>

<span class="kt">var</span><span class="w"> </span><span class="n">tx</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">db</span><span class="p">.</span><span class="n">BeginTransaction</span><span class="p">();</span>
<span class="kt">var</span><span class="w"> </span><span class="n">insert</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">db</span><span class="p">.</span><span class="n">CreateCommand</span><span class="p">();</span>
<span class="n">insert</span><span class="p">.</span><span class="n">Transaction</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">tx</span><span class="p">;</span>
<span class="n">insert</span><span class="p">.</span><span class="n">CommandText</span><span class="w"> </span><span class="o">=</span>
<span class="w">    </span><span class="s">&quot;INSERT INTO penguins VALUES (@species, @island, @billLength, @billDepth, @flipper, @mass, @sex, @year)&quot;</span><span class="p">;</span>
<span class="k">foreach</span><span class="w"> </span><span class="p">(</span><span class="kt">var</span><span class="w"> </span><span class="n">name</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="k">new</span><span class="p">[]</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="s">&quot;@species&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@island&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@billLength&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@billDepth&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@flipper&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@mass&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@sex&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;@year&quot;</span><span class="w"> </span><span class="p">})</span>
<span class="w">    </span><span class="n">insert</span><span class="p">.</span><span class="n">Parameters</span><span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">SqliteParameter</span><span class="p">(</span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">DBNull</span><span class="p">.</span><span class="n">Value</span><span class="p">));</span>

<span class="k">foreach</span><span class="w"> </span><span class="p">(</span><span class="kt">var</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">penguins</span><span class="p">)</span>
<span class="p">{</span>
<span class="w">    </span><span class="kt">object</span><span class="p">[]</span><span class="w"> </span><span class="n">values</span><span class="w"> </span><span class="o">=</span>
<span class="w">        </span><span class="p">{</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">Species</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">Island</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">BillLengthMm</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">BillDepthMm</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">FlipperLengthMm</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">BodyMassG</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">Sex</span><span class="p">,</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">Year</span><span class="w"> </span><span class="p">};</span>
<span class="w">    </span><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kt">var</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w"> </span><span class="n">i</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">values</span><span class="p">.</span><span class="n">Length</span><span class="p">;</span><span class="w"> </span><span class="n">i</span><span class="o">++</span><span class="p">)</span>
<span class="w">        </span><span class="n">insert</span><span class="p">.</span><span class="n">Parameters</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">Value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">values</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="w"> </span><span class="o">??</span><span class="w"> </span><span class="n">DBNull</span><span class="p">.</span><span class="n">Value</span><span class="p">;</span>
<span class="w">    </span><span class="n">insert</span><span class="p">.</span><span class="n">ExecuteNonQuery</span><span class="p">();</span>
<span class="p">}</span>

<span class="n">tx</span><span class="p">.</span><span class="n">Commit</span><span class="p">();</span>
<span class="n">db</span><span class="p">.</span><span class="n">Close</span><span class="p">();</span>

<span class="s">$&quot;{penguins.Count} rows written&quot;</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>344 rows written
</code></pre></div>

<p><code>#!nuget</code> resolves the package for the session before the cell body runs, which is why the <code>using</code> on the very next line works in the same cell. The path lands in <code>dbPath</code>, which goes into the store with everything else. That turns out to matter one cell later.</p>
<h2 id="cell-4-connect">Cell 4: connect</h2>
<div class="highlight"><pre><span></span><code>#!sql-connect --name penguins --connection-string &quot;Data Source=$var:dbPath&quot; --provider Microsoft.Data.Sqlite
</code></pre></div>

<p>A <code>$var:</code> token in a connection string is replaced with a variable from the notebook's store, and <code>$env:</code> does the same for an environment variable. So the string doesn't have to repeat a path a cell already computed, and it doesn't have to carry a password in the notebook file either. When it connects, the output names the connection, the provider, and the database, with the connection string redacted.</p>
<p>You can leave <code>--provider</code> off here. A connection string pointing at a <code>.db</code> file is detected as SQLite on its own. I still name it, because sooner or later somebody else reads this.</p>
<h2 id="cell-5-aggregate-in-sql">Cell 5: aggregate, in SQL</h2>
<div class="highlight"><pre><span></span><code><span class="c1">-- --name heavyBirds</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">species</span><span class="p">,</span>
<span class="w">       </span><span class="n">island</span><span class="p">,</span>
<span class="w">       </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w">                   </span><span class="k">AS</span><span class="w"> </span><span class="n">n</span><span class="p">,</span>
<span class="w">       </span><span class="n">ROUND</span><span class="p">(</span><span class="k">AVG</span><span class="p">(</span><span class="n">body_mass_g</span><span class="p">),</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">mean_mass_g</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">penguins</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">body_mass_g</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="o">@</span><span class="n">minMass</span>
<span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">species</span><span class="p">,</span><span class="w"> </span><span class="n">island</span>
<span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">mean_mass_g</span><span class="w"> </span><span class="k">DESC</span>
</code></pre></div>

<div class="table-wrap"><table>
<thead>
<tr>
<th>species</th>
<th>island</th>
<th style="text-align: right;">n</th>
<th style="text-align: right;">mean_mass_g</th>
</tr>
</thead>
<tbody>
<tr>
<td>Gentoo</td>
<td>Biscoe</td>
<td style="text-align: right;">122</td>
<td style="text-align: right;">5085.2</td>
</tr>
<tr>
<td>Adelie</td>
<td>Biscoe</td>
<td style="text-align: right;">9</td>
<td style="text-align: right;">4388.9</td>
</tr>
<tr>
<td>Adelie</td>
<td>Torgersen</td>
<td style="text-align: right;">11</td>
<td style="text-align: right;">4370.5</td>
</tr>
<tr>
<td>Chinstrap</td>
<td>Dream</td>
<td style="text-align: right;">11</td>
<td style="text-align: right;">4345.5</td>
</tr>
<tr>
<td>Adelie</td>
<td>Dream</td>
<td style="text-align: right;">13</td>
<td style="text-align: right;">4340.4</td>
</tr>
</tbody>
</table></div>
<p>Two things in that cell are not SQL. The first line, the comment starting with <code>--</code>, is read as cell directives. <code>--name heavyBirds</code> puts the result in the store under that name instead of the default <code>lastSqlResult</code>. The other flags you can put on that line are <code>--connection</code> to target a named connection, <code>--page-size</code>, <code>--no-display</code>, and <code>--timeout</code>.</p>
<p>The second is <code>@minMass</code>, which you never declared as a SQL parameter anywhere. SQL cells resolve <code>@name</code> bindings from the same variable store, matching case-insensitively, and the kernel maps the .NET type to a <code>DbType</code> for you. So <code>minMass</code> was computed by pandas three cells ago and it's now the filter in a SQLite query. If you want the whole argument of this post in one line, that's it.</p>
<p>Use the bind syntax your database uses. <code>@name</code> is right for SQL Server, PostgreSQL, MySQL, and SQLite, and Oracle uses <code>:name</code>. A query copied out of another client runs unchanged.</p>
<h2 id="cell-6-the-result-comes-back-to-python">Cell 6: the result comes back to Python</h2>
<div class="highlight"><pre><span></span><code><span class="n">heavy</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">DataFrame</span><span class="p">(</span><span class="n">heavyBirds</span><span class="p">)</span>
<span class="n">above</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">heavy</span><span class="p">[</span><span class="s2">&quot;n&quot;</span><span class="p">]</span><span class="o">.</span><span class="n">sum</span><span class="p">())</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">above</span><span class="si">}</span><span class="s2"> of </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">df</span><span class="p">)</span><span class="si">}</span><span class="s2"> birds are above the median (</span><span class="si">{</span><span class="n">above</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nb">len</span><span class="p">(</span><span class="n">df</span><span class="p">)</span><span class="si">:</span><span class="s2">.1%</span><span class="si">}</span><span class="s2">)&quot;</span><span class="p">)</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>166 of 344 birds are above the median (48.3%)
</code></pre></div>

<p>On the .NET side a SQL result is a <code>System.Data.DataTable</code>. In Python it arrives as a list of row mappings, which is exactly the shape <code>pd.DataFrame</code> builds a frame from, so there is no conversion step to write. Date columns arrive as real <code>datetime</code> values, and a <code>NULL</code> arrives as <code>None</code>.</p>
<p>Notice <code>df</code> is still around. This is the same Python session as cell 2, so the frame built up there is still in memory and I can compare the two results without rebuilding either one.</p>
<h2 id="cell-7-render-it-in-powershell">Cell 7: render it, in PowerShell</h2>
<p>The last cell goes back to <code>csv</code>, the raw string the C# cell downloaded, and loads it into a <code>Microsoft.Data.Analysis.DataFrame</code> with explicit column types. PowerShell sees shared variables as ordinary variables, so <code>$csv</code> is just there.</p>
<div class="highlight"><pre><span></span><code><span class="nb">Import-Module</span> <span class="n">DataFrame</span>

<span class="nv">$columnTypes</span> <span class="p">=</span> <span class="p">@(</span>
    <span class="no">[string]</span><span class="p">,</span>   <span class="c"># species</span>
    <span class="no">[string]</span><span class="p">,</span>   <span class="c"># island</span>
    <span class="no">[double]</span><span class="p">,</span>   <span class="c"># bill_length_mm</span>
    <span class="no">[double]</span><span class="p">,</span>   <span class="c"># bill_depth_mm</span>
    <span class="no">[double]</span><span class="p">,</span>   <span class="c"># flipper_length_mm</span>
    <span class="no">[double]</span><span class="p">,</span>   <span class="c"># body_mass_g</span>
    <span class="no">[string]</span><span class="p">,</span>   <span class="c"># sex</span>
    <span class="no">[int]</span>       <span class="c"># year</span>
<span class="p">)</span>

<span class="nv">$frame</span> <span class="p">=</span> <span class="nb">Import-DataFrame</span> <span class="n">-String</span> <span class="p">(</span><span class="nv">$csv</span> <span class="o">-replace</span> <span class="s1">&#39;(?m)(^|,)NA(?=,|$)&#39;</span><span class="p">,</span> <span class="s1">&#39;$1&#39;</span><span class="p">)</span> <span class="n">-ColumnType</span> <span class="nv">$columnTypes</span>
<span class="nv">$frame</span>
</code></pre></div>

<p><code>Microsoft.Data.Analysis</code> reads an empty numeric field as a null, so the <code>NA</code> markers get blanked out before the import. The regular expression only touches a field that is exactly <code>NA</code>, which is why it's anchored on a comma or a line boundary at both ends.</p>
<p>The last line returns the frame, and as of today it renders as a table.</p>
<figure class="fig">
<svg viewBox="0 0 720 300" role="img" aria-label="A rendered DataFrame table with column names and their types in the header, five data rows including one row of nulls, and a footer reading Showing 100 of 344 rows">
  <defs><clipPath id="dfframe"><rect x="10" y="12" width="700" height="268"/></clipPath></defs>
  <rect class="box" x="10" y="12" width="700" height="268"/>
  <g clip-path="url(#dfframe)">
    <rect class="soft" x="10" y="12" width="700" height="46"/>
    <text class="label b" x="22" y="34">species</text>
    <text class="muted" x="22" y="50">String</text>
    <text class="label b" x="138" y="34">island</text>
    <text class="muted" x="138" y="50">String</text>
    <text class="label b" x="254" y="34">bill_length_mm</text>
    <text class="muted" x="254" y="50">Double</text>
    <text class="label b" x="384" y="34">bill_depth_mm</text>
    <text class="muted" x="384" y="50">Double</text>
    <text class="label b" x="514" y="34">flipper_length_mm</text>
    <text class="muted" x="514" y="50">Double</text>
    <text class="label b" x="664" y="34">body_mass_g</text>
    <text class="muted" x="664" y="50">Double</text>

    <path class="line" d="M10 58 H710" stroke-width="1"/>
    <text class="code" x="22" y="81">Adelie</text>
    <text class="code" x="138" y="81">Torgersen</text>
    <text class="code" x="254" y="81">39.1</text>
    <text class="code" x="384" y="81">18.7</text>
    <text class="code" x="514" y="81">181</text>
    <text class="code" x="664" y="81">3750</text>

    <path class="line" d="M10 94 H710" stroke-width="1"/>
    <text class="code" x="22" y="117">Adelie</text>
    <text class="code" x="138" y="117">Torgersen</text>
    <text class="code" x="254" y="117">39.5</text>
    <text class="code" x="384" y="117">17.4</text>
    <text class="code" x="514" y="117">186</text>
    <text class="code" x="664" y="117">3800</text>

    <path class="line" d="M10 130 H710" stroke-width="1"/>
    <text class="code" x="22" y="153">Adelie</text>
    <text class="code" x="138" y="153">Torgersen</text>
    <text class="code" x="254" y="153">40.3</text>
    <text class="code" x="384" y="153">18</text>
    <text class="code" x="514" y="153">195</text>
    <text class="code" x="664" y="153">3250</text>

    <path class="line" d="M10 166 H710" stroke-width="1"/>
    <text class="code" x="22" y="189">Adelie</text>
    <text class="code" x="138" y="189">Torgersen</text>
    <text class="muted" x="254" y="189">null</text>
    <text class="muted" x="384" y="189">null</text>
    <text class="muted" x="514" y="189">null</text>
    <text class="muted" x="664" y="189">null</text>

    <path class="line" d="M10 202 H710" stroke-width="1"/>
    <text class="code" x="22" y="225">Adelie</text>
    <text class="code" x="138" y="225">Torgersen</text>
    <text class="code" x="254" y="225">36.7</text>
    <text class="code" x="384" y="225">19.3</text>
    <text class="code" x="514" y="225">193</text>
    <text class="code" x="664" y="225">3450</text>

    <path class="line" d="M10 238 H710" stroke-width="1"/>
    <text class="muted" x="22" y="262">Showing 100 of 344 rows</text>

    <path class="line" d="M130 12 V238" stroke-width="1"/>
    <path class="line" d="M246 12 V238" stroke-width="1"/>
    <path class="line" d="M376 12 V238" stroke-width="1"/>
    <path class="line" d="M506 12 V238" stroke-width="1"/>
    <path class="line" d="M656 12 V238" stroke-width="1"/>
  </g>
</svg>
<figcaption>The fourth data row of the CSV has no measurements at all, and the table says so instead of showing you blanks. Two more columns sit off to the right of the scroll boundary.</figcaption>
</figure>

<p>The header carries the column name and the column's type, it stays put while the body scrolls, and a null renders as the word <code>null</code> rather than an empty cell. The table is a bounded preview on purpose: the first 100 rows and the first 50 columns, the frame's real totals in the footer, and any single value longer than 200 characters shortened. A wide or long frame can't swamp your output, and the footer always tells you what you're looking at.</p>
<p>The formatter reads the frame through reflection and takes no dependency on <code>Microsoft.Data.Analysis</code>, so the PowerShell module that created the frame keeps ownership of that assembly and there's no second copy to argue with. It's built into the VS Code extension, <code>verso serve</code>, and <code>verso run</code>, and it's published as <code>Verso.DataFrame</code> on NuGet if you're embedding the engine yourself. An explicit <code>Display $frame</code> renders the same way. And if you've already written your own DataFrame formatter, give it a priority above 50 and it stays in front of this one.</p>
<h2 id="what-crosses-and-what-doesnt">What crosses, and what doesn't</h2>
<p>Python is a separate process, so values reach it as data. Most of that you never notice, and the parts you do are worth knowing before you lean on them.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Value in .NET</th>
<th>What a Python cell sees</th>
</tr>
</thead>
<tbody>
<tr>
<td>A record or anonymous type</td>
<td>A mapping answering both <code>p.Species</code> and <code>p["Species"]</code></td>
</tr>
<tr>
<td><code>DateTime</code></td>
<td>A real <code>datetime</code>, which also answers <code>.Year</code> and <code>.Millisecond</code></td>
</tr>
<tr>
<td><code>decimal</code></td>
<td>An exact <code>Decimal</code>, not a float</td>
</tr>
<tr>
<td><code>Guid</code>, <code>TimeSpan</code>, <code>byte[]</code></td>
<td><code>uuid.UUID</code>, <code>timedelta</code>, <code>bytes</code></td>
</tr>
<tr>
<td>A SQL result set</td>
<td>A list of row mappings, which <code>pd.DataFrame</code> accepts directly</td>
</tr>
<tr>
<td>A delegate, a task in flight, an open connection</td>
<td>The name is defined, but printing it explains that it did not cross</td>
</tr>
</tbody>
</table></div>
<p>That last row is a decision, not an accident. A value with no meaning outside the process that made it doesn't silently vanish. The name is still bound, and printing it tells you what happened:</p>
<div class="highlight"><pre><span></span><code>&lt;callback was not shared with Python: a function, which cannot be called from another process&gt;
</code></pre></div>

<p>The value stays usable in the language that produced it. Two smaller rules go with that. A name beginning with a double underscore never reaches a Python cell, because the interpreter owns that prefix. And going the other way, <code>datetime</code>, <code>Decimal</code>, <code>UUID</code>, <code>bytes</code>, NumPy arrays, and pandas frames and series all convert back.</p>
<p>There's more on all of this in <a href="https://datafication.co/blog/2026/one-variable-store-eight-languages.html">One variable store, eight languages, no hand-off</a>, and on how the Python side is hosted in <a href="https://datafication.co/blog/2026/python-on-your-own-interpreter.html">Python cells that run the Python you already have</a>.</p>
<h2 id="two-ways-to-finish">Two ways to finish</h2>
<p>That's seven cells, and it stands on its own. Two things I'd do with it next.</p>
<p>The first is to turn it into a job. Declared parameters are coerced to CLR types and injected into the same variable store before any cell runs, so a parameter is just another name all four kernels can read. Add a <code>species</code> parameter, filter on it, and the notebook runs unattended:</p>
<div class="highlight"><pre><span></span><code>verso<span class="w"> </span>run<span class="w"> </span>penguins.verso<span class="w"> </span>--param<span class="w"> </span><span class="nv">species</span><span class="o">=</span>Gentoo<span class="w"> </span>--output<span class="w"> </span>json<span class="w"> </span>--output-file<span class="w"> </span>result.json
</code></pre></div>

<p>The exit codes are deterministic, so a pipeline can act on them: <code>0</code> for success, <code>1</code> for a cell failure, <code>2</code> for a timeout, and <code>5</code> for missing required parameters. I covered that end to end in <a href="https://datafication.co/blog/2026/notebooks-in-ci-with-parameters.html">Run the same notebook in CI that you run at your desk</a>.</p>
<p>The second is to hand the result to somebody. Layout is a property of the file, so switching to the Presentation or Dashboard layout changes what a reader sees without touching a single cell.</p>
<h2 id="why-its-worth-the-trouble">Why it's worth the trouble</h2>
<p>Here's how the neighbors do this. A Jupyter notebook binds one kernel, and the notebook's <code>kernelspec</code> metadata names one kernel specification for the document. In Polyglot Notebooks, several languages do live in one notebook, and each value that moves between them is named in a <code>#!share</code> or <code>#!set</code> command. The copy is made through the <code>application/json</code> MIME type by default, and for .NET-based kernels the serialization is done with <code>System.Text.Json</code>. Reference sharing is available under stated conditions: both kernels in the same process, both CLR-based, and <code>--byref</code> when using <code>#!set</code>.</p>
<p>Our answer is one <code>VariableStore</code> per session with no per-kernel isolation, which the kernels publish into and read from on their own. You saw the cost back in cell 2: a value that crosses a process boundary crosses as data, and Verso tells you plainly when something cannot make the trip. What you get for it is a notebook where the interesting line is <code>WHERE body_mass_g &gt; @minMass</code> instead of the twenty lines it would otherwise take to get <code>minMass</code> from pandas into SQLite.</p>
<h2 id="get-it">Get it</h2>
<p>Verso 1.2.2 is on NuGet today, and the notebook editor is on the Visual Studio Marketplace.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
code<span class="w"> </span>--install-extension<span class="w"> </span>Datafication.verso-notebook
</code></pre></div>

<p>Build the seven cells yourself, or run your own table down the same path. If a value crosses a language boundary and comes out wrong, that's the report I most want to see.</p>
<p>The DataFrame formatter and the Palmer Penguins sample notebook it grew out of were contributed by <a href="https://github.com/eosfor">@eosfor</a> in #94 and #95. The dataset itself comes from the <a href="https://allisonhorst.github.io/palmerpenguins/">palmerpenguins project</a>. The guides for everything used above are at <a href="https://www.versonotebooks.com/docs/">versonotebooks.com/docs</a>, and Verso itself is MIT-licensed at <a href="https://github.com/DataficationSDK/Verso">github.com/DataficationSDK/Verso</a>.</p>]]></content:encoded>
  </item>
  <item>
    <title>One suite, four shards, and everything it left on disk</title>
    <link>https://datafication.co/blog/2026/a-suite-that-tells-you-what-happened.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/a-suite-that-tells-you-what-happened.html</guid>
    <pubDate>Wed, 02 Sep 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>I built a full Motus suite against two public pages, gated it on accessibility and Core Web Vitals, recorded a trace, a HAR and a video, then split it across four agents and merged the results back into one report.</description>
    <content:encoded><![CDATA[<p>A browser suite that fails in CI at three in the morning gives you one line of text and a stack trace taken after the page had moved on. You reproduce it locally, it passes, and you move on too. I have done that more times than I would like to admit.</p>
<p>Everything I needed to stop doing that shipped in <a href="https://datafication.co/blog/2026/motus-1-0-15.html">Motus 1.0.15</a>, so I built a suite around it. The one rule I gave myself was that a run has to leave enough on disk to answer what happened without rerunning anything. Here's what that looks like.</p>
<div class="stats">
<div><div class="big">9</div><span class="mono">tests</span></div>
<div><div class="big">4</div><span class="mono">shards</span></div>
<div><div class="big">8</div><span class="mono">artifact kinds</span></div>
<div><div class="big">1</div><span class="mono">merged report</span></div>
</div>

<h2 id="what-i-pointed-it-at">What I pointed it at</h2>
<p>Two public pages the Motus samples already drive: <code>https://example.com</code> and <code>https://www.iana.org/domains/reserved</code>. Neither of them belongs to me, and that turned out to be the useful part of the exercise.</p>
<p>When you test a page you control, you can be sloppy about the difference between a finding that should stop a build and a finding you only want written down, because you can always go fix the page. When the page is somebody else's, you have to decide up front. That decision shows up in almost every section below.</p>
<h2 id="getting-the-project-set-up">Getting the project set up</h2>
<p>Start with the tool and a browser.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
</code></pre></div>

<p><code>motus install</code> downloads a Chrome for Testing build into <code>~/.motus/browsers</code>. After that, the project is ordinary MSTest.</p>
<div class="highlight"><pre><span></span><code><span class="nt">&lt;Project</span><span class="w"> </span><span class="na">Sdk=</span><span class="s">&quot;Microsoft.NET.Sdk&quot;</span><span class="nt">&gt;</span>
<span class="w">  </span><span class="nt">&lt;PropertyGroup&gt;</span>
<span class="w">    </span><span class="nt">&lt;TargetFramework&gt;</span>net8.0<span class="nt">&lt;/TargetFramework&gt;</span>
<span class="w">    </span><span class="nt">&lt;Nullable&gt;</span>enable<span class="nt">&lt;/Nullable&gt;</span>
<span class="w">    </span><span class="nt">&lt;ImplicitUsings&gt;</span>enable<span class="nt">&lt;/ImplicitUsings&gt;</span>
<span class="w">    </span><span class="nt">&lt;IsTestProject&gt;</span>true<span class="nt">&lt;/IsTestProject&gt;</span>
<span class="w">  </span><span class="nt">&lt;/PropertyGroup&gt;</span>
<span class="w">  </span><span class="nt">&lt;ItemGroup&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;Microsoft.NET.Test.Sdk&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;17.11.0&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;MSTest.TestAdapter&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;3.5.2&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;MSTest.TestFramework&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;3.5.2&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;Motus&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;1.0.15&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;Motus.Testing.MSTest&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;1.0.15&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">    </span><span class="nt">&lt;PackageReference</span><span class="w"> </span><span class="na">Include=</span><span class="s">&quot;Motus.Analyzers&quot;</span><span class="w"> </span><span class="na">Version=</span><span class="s">&quot;1.0.15&quot;</span><span class="w"> </span><span class="nt">/&gt;</span>
<span class="w">  </span><span class="nt">&lt;/ItemGroup&gt;</span>
<span class="nt">&lt;/Project&gt;</span>
</code></pre></div>

<p><code>Motus.Analyzers</code> is optional and I would take it every time. It's seven Roslyn diagnostics that catch an unawaited call, an undisposed browser and a navigation with no wait after it, at compile time instead of at three in the morning. One heads-up when you go looking for it: an unrelated motion-planning library shares the name on nuget.org, so use the <a href="https://www.nuget.org/packages/Motus">package page</a> link and you'll land on the right one.</p>
<p><code>Usings.cs</code>:</p>
<div class="highlight"><pre><span></span><code><span class="n">global</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">Microsoft.VisualStudio.TestTools.UnitTesting</span><span class="p">;</span>
<span class="n">global</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>
<span class="n">global</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">Motus.Assertions</span><span class="p">;</span>
<span class="n">global</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">Motus.Testing.MSTest</span><span class="p">;</span>
</code></pre></div>

<p><code>AssemblySetup.cs</code> launches one browser for the whole assembly. Each test still gets its own context and page, created and closed by the base class.</p>
<div class="highlight"><pre><span></span><code><span class="k">namespace</span><span class="w"> </span><span class="nn">PublicPages.Tests</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">AssemblySetup</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[AssemblyInitialize]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">InitializeAsync</span><span class="p">(</span><span class="n">TestContext</span><span class="w"> </span><span class="n">_</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">LaunchBrowserAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">LaunchOptions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Headless</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="p">});</span>

<span class="w">    </span><span class="na">[AssemblyCleanup]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">CleanupAsync</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">CloseBrowserAsync</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>

<p>You'll notice the accessibility, performance and coverage options are not set anywhere in that code. That's on purpose. Leaving them unset is what lets the config file and the <code>MOTUS_*</code> variables fill them in. Configuration layers file, then environment, then code, so whatever you hardcode wins and whatever you leave alone stays steerable from CI.</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;motus&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;launch&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;headless&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;timeout&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">30000</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;context&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;viewport&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1280</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">720</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;assertions&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;timeout&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">10000</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;accessibility&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;auditAfterNavigation&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;includeWarnings&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;skipRules&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;a11y-missing-landmark&quot;</span><span class="p">]</span>
<span class="w">  </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;flaky&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;retryPolicy&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;flake&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;retries&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;historyPath&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;artifacts/flake-history.json&quot;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>I spelled out <code>includeWarnings</code> even though its default is already <code>true</code>, because that default is the one people trip over: a warning, not just an error, is enough to fail an audit assertion. Both of these pages trip <code>a11y-missing-landmark</code>, so the file skips it for the hook and the assertion below skips it for itself.</p>
<h2 id="the-tests">The tests</h2>
<p>Four tests on the example domain, four on the reserved-domains page, and one that exists only to capture evidence.</p>
<div class="highlight"><pre><span></span><code><span class="k">namespace</span><span class="w"> </span><span class="nn">PublicPages.Tests</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="na">[PerformanceBudget(Lcp = 4000, Fcp = 3000, Ttfb = 2000, Cls = 0.25)]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">ExampleDomainTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">private</span><span class="w"> </span><span class="k">const</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Url</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;https://example.com&quot;</span><span class="p">;</span>

<span class="w">    </span><span class="k">private</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="n">Url</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">WaitForLoadStateAsync</span><span class="p">(</span><span class="n">LoadState</span><span class="p">.</span><span class="n">Load</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_HasItsTitleAndHeading</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToHaveTitleAsync</span><span class="p">(</span><span class="s">&quot;Example Domain&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;heading&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Example Domain&quot;</span><span class="p">)).</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_LinksToTheIanaRecord</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="n">link</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;link&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Learn more&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">link</span><span class="p">).</span><span class="n">ToBeAttachedAsync</span><span class="p">();</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">link</span><span class="p">).</span><span class="n">ToHaveAttributeAsync</span><span class="p">(</span><span class="s">&quot;href&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;https://iana.org/domains/example&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_MeetsItsBudget</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToMeetPerformanceBudgetAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_PassesTheRulesWeGateOn</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToPassAccessibilityAuditAsync</span><span class="p">(</span>
<span class="w">            </span><span class="n">o</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">o</span><span class="p">.</span><span class="n">SkipRules</span><span class="p">(</span><span class="s">&quot;a11y-missing-landmark&quot;</span><span class="p">));</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>The <code>ToBeAttachedAsync</code> on the link is not decoration, and if you take one thing from this post I would like it to be this one. A locator assertion polls for state once the element is attached. It does not wait for one to appear. So <code>ToBeVisibleAsync</code> on a selector matching nothing fails almost at once rather than after its timeout. Fast failures are usually good news, but not that one. <code>ToBeAttachedAsync</code>, <code>ToBeDetachedAsync</code> and <code>ToHaveCountAsync</code> are the three that are safe before the element exists, so one of them goes first.</p>
<p>The second class covers the response, the content, a network failure and a test I do not trust.</p>
<div class="highlight"><pre><span></span><code><span class="k">namespace</span><span class="w"> </span><span class="nn">PublicPages.Tests</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">ReservedDomainsTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">private</span><span class="w"> </span><span class="k">const</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Url</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;https://www.iana.org/domains/reserved&quot;</span><span class="p">;</span>

<span class="w">    </span><span class="k">private</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="o">&lt;</span><span class="n">IResponse</span><span class="o">?&gt;</span><span class="w"> </span><span class="n">OpenAsync</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="n">response</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="n">Url</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">WaitForLoadStateAsync</span><span class="p">(</span><span class="n">LoadState</span><span class="p">.</span><span class="n">Load</span><span class="p">);</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">response</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_RespondsWithTwoHundred</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="n">response</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="n">Assert</span><span class="p">.</span><span class="n">IsNotNull</span><span class="p">(</span><span class="n">response</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">response</span><span class="p">).</span><span class="n">ToHaveStatusAsync</span><span class="p">(</span><span class="mi">200</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_HasAnExampleDomainsSection</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;heading&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Example domains&quot;</span><span class="p">)).</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_RendersItsHeadingWithoutItsStylesheet</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">RouteAsync</span><span class="p">(</span><span class="s">&quot;**/*.css&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">route</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">route</span><span class="p">.</span><span class="n">AbortAsync</span><span class="p">());</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;heading&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;IANA-managed Reserved Domains&quot;</span><span class="p">))</span>
<span class="w">            </span><span class="p">.</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="na">[Quarantine(Reason = &quot;Section headings follow IANA policy, not our code&quot;)]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Page_ListsTheTestIdnTopLevelDomains</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">OpenAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;heading&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Test IDN top-level domains&quot;</span><span class="p">))</span>
<span class="w">            </span><span class="p">.</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><code>RouteAsync</code> with <code>AbortAsync</code> is the cheapest way I know to test a failure you cannot otherwise arrange. I want to know the heading still renders when the stylesheet never shows up, and there is no way for me to make that happen on a server I do not run.</p>
<p>The last one is where <code>[Quarantine]</code> earns itself. That test's subject is somebody else's editorial decision, so it will change when IANA decides it should and not when I do. Quarantine keeps the test rather than deleting it: it runs, and it reports, in its own bucket, without gating the run. If you would rather not put the attribute in the source, a <code>--quarantine</code> list file does the same job from outside.</p>
<h2 id="what-actually-gates-the-build">What actually gates the build</h2>
<p>Two of the gates are the assertions you just read. The rest divide the same way all the way through: the flag decides whether a collector runs at all, and the file or the attribute supplies the numbers.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Gate</th>
<th>Where the setting lives</th>
<th>What makes the run fail</th>
</tr>
</thead>
<tbody>
<tr>
<td>Accessibility</td>
<td><code>--a11y warn</code> or <code>--a11y enforce</code>; skips in <code>accessibility.skipRules</code></td>
<td><code>enforce</code> plus an error-severity violation, or a failed <code>ToPassAccessibilityAuditAsync</code></td>
</tr>
<tr>
<td>Performance</td>
<td><code>--perf-budget</code> enables enforcement; thresholds from <code>[PerformanceBudget]</code> or <code>performance</code></td>
<td>A metric over its threshold when <code>ToMeetPerformanceBudgetAsync</code> runs</td>
</tr>
<tr>
<td>Coverage</td>
<td><code>--coverage console</code> selects the format; <code>coverage.js.lines</code> and <code>coverage.css.rules</code> hold thresholds</td>
<td>Aggregate coverage under a threshold</td>
</tr>
<tr>
<td>Flakiness</td>
<td><code>--retries 2 --retry-policy flake</code></td>
<td>Only with <code>--fail-on-flaky</code>; otherwise a flaky test passes</td>
</tr>
<tr>
<td>A missing shard</td>
<td><code>--expect &lt;n&gt;</code> on <code>motus shard merge</code></td>
<td>A shard absent, or one index present twice</td>
</tr>
</tbody>
</table></div>
<p><code>--perf-budget</code> sets no numbers of its own, and that catches people out. With no attribute and no <code>performance</code> section there is nothing to enforce, and <code>ToMeetPerformanceBudgetAsync</code> throws rather than passing on an empty budget. I would rather it throw. A green test that measured nothing is worse than a red one.</p>
<p>Be clear-eyed about the accessibility side too. The nine rules are Motus's own, read from the browser's accessibility tree over the protocol, and they are not an axe-core replacement. Both gates are covered in more detail in <a href="https://datafication.co/blog/2026/audits-in-the-box.html">Accessibility and performance are assertions</a>.</p>
<p>Running it locally:</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>build<span class="w"> </span>-c<span class="w"> </span>Release
motus<span class="w"> </span>run<span class="w"> </span>bin/Release/net8.0/PublicPages.Tests.dll<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--a11y<span class="w"> </span>warn<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--perf-budget<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--coverage<span class="w"> </span>console<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--reporter<span class="w"> </span>console<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--reporter<span class="w"> </span>html:artifacts/report.html
</code></pre></div>

<p><code>--a11y warn</code> audits after every navigation and prints what it finds without failing anything, which is the right setting for pages you do not own. On this suite it reports the missing main landmark on both pages, and a missing document language attribute on the reserved-domains page. Neither one gates the build. They are real findings on markup I did not write, and I would rather see them and decide for myself than have a build tell me what to do about them.</p>
<h2 id="what-one-test-leaves-behind">What one test leaves behind</h2>
<p>This is the test I am a little proud of. It records the same short scenario three ways and stops each recorder in a <code>finally</code>, so the artifacts exist on disk whether the assertion held or not. That second part matters more than the recording does. The run you want to look at afterward is the one that failed.</p>
<div class="highlight"><pre><span></span><code><span class="k">namespace</span><span class="w"> </span><span class="nn">PublicPages.Tests</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">EvidenceTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">FollowingTheLink_LeavesATraceAHarAndAVideo</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="n">Directory</span><span class="p">.</span><span class="n">CreateDirectory</span><span class="p">(</span><span class="s">&quot;artifacts&quot;</span><span class="p">);</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Context</span><span class="p">.</span><span class="n">Tracing</span><span class="p">.</span><span class="n">StartAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">TracingStartOptions</span>
<span class="w">        </span><span class="p">{</span>
<span class="w">            </span><span class="n">Screenshots</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">            </span><span class="n">Snapshots</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">        </span><span class="p">});</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">StartHarRecordingAsync</span><span class="p">();</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">StartVideoRecordingAsync</span><span class="p">(</span><span class="s">&quot;artifacts/follow-the-link.avi&quot;</span><span class="p">);</span>

<span class="w">        </span><span class="k">try</span>
<span class="w">        </span><span class="p">{</span>
<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://example.com&quot;</span><span class="p">);</span>
<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">WaitForLoadStateAsync</span><span class="p">(</span><span class="n">LoadState</span><span class="p">.</span><span class="n">Load</span><span class="p">);</span>

<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;link&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Learn more&quot;</span><span class="p">).</span><span class="n">ClickAsync</span><span class="p">();</span>

<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToHaveTitleAsync</span><span class="p">(</span><span class="s">&quot;Example Domains&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">        </span><span class="k">finally</span>
<span class="w">        </span><span class="p">{</span>
<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">StopVideoRecordingAsync</span><span class="p">();</span>
<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">StopHarRecordingAsync</span><span class="p">(</span><span class="s">&quot;artifacts/follow-the-link.har&quot;</span><span class="p">);</span>
<span class="w">            </span><span class="k">await</span><span class="w"> </span><span class="n">Context</span><span class="p">.</span><span class="n">Tracing</span><span class="p">.</span><span class="n">StopAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">TracingStopOptions</span>
<span class="w">            </span><span class="p">{</span>
<span class="w">                </span><span class="n">Path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;artifacts/follow-the-link.zip&quot;</span><span class="p">,</span>
<span class="w">            </span><span class="p">});</span>
<span class="w">        </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<figure class="fig">
<svg viewBox="0 0 720 236" role="img" aria-label="One test fanning out into four artifact files through a finally block">
  <rect class="box on" x="16" y="80" width="150" height="72" rx="3"/>
  <text class="label b" x="91" y="110" text-anchor="middle">one test</text>
  <text class="muted" x="91" y="130" text-anchor="middle">3 RECORDERS</text>
  <path class="brand-line" d="M166 116 H210" stroke-width="1.4"/>
  <rect class="soft" x="212" y="86" width="132" height="60" rx="3"/>
  <text class="code" x="278" y="120" text-anchor="middle">finally { stop }</text>
  <path class="brand-line" d="M344 116 H372 V38 H418" stroke-width="1.4"/>
  <path class="brand-line" d="M344 116 H372 V90 H418" stroke-width="1.4"/>
  <path class="brand-line" d="M344 116 H372 V142 H418" stroke-width="1.4"/>
  <path class="brand-line" d="M344 116 H372 V194 H418" stroke-width="1.4"/>
  <rect class="box" x="424" y="18" width="280" height="40" rx="3"/>
  <rect class="box" x="424" y="70" width="280" height="40" rx="3"/>
  <rect class="box" x="424" y="122" width="280" height="40" rx="3"/>
  <rect class="box" x="424" y="174" width="280" height="40" rx="3"/>
  <text class="code" x="438" y="36">follow-the-link.zip</text>
  <text class="muted" x="438" y="50">SCREENSHOTS</text>
  <text class="code" x="438" y="88">follow-the-link.har</text>
  <text class="muted" x="438" y="102">HAR 1.2</text>
  <text class="code" x="438" y="140">follow-the-link.avi</text>
  <text class="muted" x="438" y="154">MJPEG IN AVI, NO CURSOR</text>
  <text class="code" x="438" y="192">results.shard-3.xml</text>
  <text class="muted" x="438" y="206">JUNIT</text>
</svg>
<figcaption>Three recorders started before the scenario and stopped in a finally block, plus the runner's result file.</figcaption>
</figure>

<p>Three details there are easy to get wrong, so here they are together. Tracing is on the context and browser-wide underneath, so concurrent starts serialize behind a gate, and one tracing test per suite is the comfortable number. HAR recording is per page, and it is the stop call that takes the path. Video is MJPEG frames in an AVI container, at viewport size, with no pointer drawn and no transcoding, so reach for ffmpeg when you need another format.</p>
<figure class="fig">
<svg viewBox="0 0 720 214" role="img" aria-label="The contents of a trace zip feeding the motus trace show viewer">
  <rect class="box on" x="16" y="20" width="300" height="176" rx="3"/>
  <text class="muted" x="32" y="42">FOLLOW-THE-LINK.ZIP</text>
  <rect class="soft" x="32" y="56" width="268" height="36" rx="2"/>
  <text class="code" x="44" y="72">trace.json</text>
  <text class="muted" x="44" y="86">CDP TRACE EVENTS</text>
  <rect class="soft" x="32" y="98" width="268" height="36" rx="2"/>
  <text class="code" x="44" y="114">har.json</text>
  <text class="muted" x="44" y="128">IF HAR RECORDED</text>
  <rect class="soft" x="32" y="140" width="268" height="42" rx="2"/>
  <text class="code" x="44" y="156">resources/screenshots/000000.jpeg</text>
  <text class="muted" x="44" y="172">IF SCREENSHOTS ON</text>
  <path class="brand-line" d="M316 108 H372" stroke-width="1.4"/>
  <path class="brand" d="M372 102 L380 108 L372 114 Z"/>
  <rect class="box on" x="386" y="66" width="318" height="84" rx="3"/>
  <text class="label b" x="545" y="98" text-anchor="middle">motus trace show</text>
  <text class="code" x="545" y="118" text-anchor="middle">--port 5200</text>
  <text class="muted" x="545" y="136" text-anchor="middle">TIMELINE, SCREENSHOTS, NETWORK</text>
</svg>
<figcaption>The archive layout Motus writes, and the one viewer that reads it.</figcaption>
</figure>

<p>Open it with the CLI:</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>trace<span class="w"> </span>show<span class="w"> </span>artifacts/follow-the-link.zip<span class="w"> </span>--port<span class="w"> </span><span class="m">5200</span>
</code></pre></div>

<p>That starts the visual runner in trace mode and opens your default browser. The archive is Motus's own layout, and this is its viewer.</p>
<figure class="fig">
<svg viewBox="0 0 720 202" role="img" aria-label="Three recorders spanning one scenario timeline with four events marked below it">
  <text class="muted" x="16" y="26">TRACE</text>
  <text class="muted" x="16" y="52">HAR</text>
  <text class="muted" x="16" y="78">VIDEO</text>
  <rect class="soft" x="76" y="14" width="616" height="16" rx="2"/>
  <rect class="soft" x="76" y="40" width="616" height="16" rx="2"/>
  <rect class="soft" x="76" y="66" width="616" height="16" rx="2"/>
  <path class="brand-line" d="M112 14 V30 M188 14 V30 M300 14 V30 M368 14 V30 M452 14 V30 M596 14 V30" stroke-width="1.2"/>
  <path class="line" d="M76 128 H692" stroke-width="1"/>
  <path class="brand-line" d="M112 118 V138 M300 118 V138 M392 118 V138 M596 118 V138" stroke-width="1.6"/>
  <text class="code" x="112" y="156" text-anchor="middle">goto</text>
  <text class="muted" x="112" y="170" text-anchor="middle">EXAMPLE.COM</text>
  <text class="code" x="300" y="156" text-anchor="middle">click</text>
  <text class="muted" x="300" y="170" text-anchor="middle">LEARN MORE</text>
  <text class="code" x="392" y="156" text-anchor="middle">301</text>
  <text class="muted" x="392" y="170" text-anchor="middle">REDIRECT</text>
  <text class="code" x="596" y="156" text-anchor="middle">assert</text>
  <text class="muted" x="596" y="170" text-anchor="middle">TITLE</text>
</svg>
<figcaption>The same scenario seen three ways. Ticks on the trace lane are screenshots.</figcaption>
</figure>

<p>That redirect is the reason I record all three instead of picking one. The click reads as a single action in the trace. The video shows a page that changes. Only the HAR shows the <code>href</code> returning a 301 before the browser reached the page the assertion checks. Three views of the same short scenario, and each one knows something the other two do not.</p>
<h2 id="coverage">Coverage</h2>
<p>Coverage is per test. It's collected over the protocol when a page closes, and remapped through source maps when a script has one.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>run<span class="w"> </span>bin/Release/net8.0/PublicPages.Tests.dll<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--coverage<span class="w"> </span>console<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--coverage<span class="w"> </span>html:artifacts/coverage
</code></pre></div>

<p>The console reporter prints a per-file table and an overall line. The HTML reporter writes <code>index.html</code> plus a page per file. Thresholds are deliberately not a command-line option: they live in <code>coverage.js.lines</code> and <code>coverage.css.rules</code>, and a run below one exits non-zero.</p>
<p>This suite sets neither, and I want to be honest about why. The code being measured is somebody else's, so a threshold here would be a number about their scripts, not about my tests. On your own app it's the other way around, and that's where I'd set one.</p>
<h2 id="splitting-it-four-ways">Splitting it four ways</h2>
<p><code>motus run --shard &lt;index&gt;/&lt;total&gt;</code> sorts the discovered tests by assembly path and fully qualified name, then deals them out round-robin. That's the whole algorithm, and it works because every agent computes the same partition without talking to any other one. The sort makes it reproducible across machines. The round-robin stops one slow class landing on one agent.</p>
<div class="highlight"><pre><span></span><code><span class="nt">jobs</span><span class="p">:</span>
<span class="w">  </span><span class="nt">test</span><span class="p">:</span>
<span class="w">    </span><span class="nt">strategy</span><span class="p">:</span>
<span class="w">      </span><span class="nt">fail-fast</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">false</span>
<span class="w">      </span><span class="nt">matrix</span><span class="p">:</span>
<span class="w">        </span><span class="nt">shard</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">[</span><span class="nv">1</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">2</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">3</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">4</span><span class="p p-Indicator">]</span>
<span class="w">    </span><span class="nt">steps</span><span class="p">:</span>
<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">|</span>
<span class="w">          </span><span class="no">motus run bin/Release/net8.0/PublicPages.Tests.dll \</span>
<span class="w">            </span><span class="no">--shard ${{ matrix.shard }}/4 \</span>
<span class="w">            </span><span class="no">--a11y warn --perf-budget \</span>
<span class="w">            </span><span class="no">--retries 2 --retry-policy flake \</span>
<span class="w">            </span><span class="no">--flaky-history artifacts/flake-history.json \</span>
<span class="w">            </span><span class="no">--reporter junit:results.shard-${{ matrix.shard }}.xml</span>
<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">actions/upload-artifact@v4</span>
<span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">always()</span>
<span class="w">        </span><span class="nt">with</span><span class="p">:</span>
<span class="w">          </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">artifacts-${{ matrix.shard }}</span>
<span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">|</span>
<span class="w">            </span><span class="no">results.shard-*.xml</span>
<span class="w">            </span><span class="no">artifacts/</span>

<span class="w">  </span><span class="nt">merge</span><span class="p">:</span>
<span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">test</span>
<span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">always()</span>
<span class="w">    </span><span class="nt">steps</span><span class="p">:</span>
<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">actions/download-artifact@v4</span>
<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">motus shard merge **/results.shard-*.xml --output junit:results.xml --expect 4</span>
</code></pre></div>

<p><code>fail-fast: false</code> lets every shard finish after one of them fails, and <code>if: always()</code> runs the merge even when a shard job failed. Those two go together. The run where a shard blew up is exactly the run where you want the combined report.</p>
<p><code>--expect 4</code> guards the failure that sharding introduces, and it is the flag I would not leave off. Each shard stamps <code>motus.shard.index</code> and <code>motus.shard.total</code> into its result file. The merge reads them back and fails when one is missing or when an index turns up twice. Without it, an agent that died before writing anything produces a smaller report that is entirely green and looks fine, and you will believe it, because there is nothing in it to disbelieve.</p>
<p>Two limits are worth knowing before you add agents. Shards balance by count, not by duration, so a suite dominated by one slow test gains nothing from more of them. And the index is 1-based, so <code>--shard 0/4</code> is rejected rather than guessed at.</p>
<h2 id="flaky-quarantined-or-broken">Flaky, quarantined, or broken</h2>
<p>Live pages over a shared network are a classic source of a failure that means nothing at all. <code>--retry-policy</code> decides which failures earn another attempt, and the two policies are further apart than the names suggest.</p>
<p><code>transient</code> is the default and re-runs only a lost browser, which cannot mask a bug: a browser that vanished never told you anything about your code in the first place. <code>flake</code> re-runs any failure, assertion failures included, and labels a test that then passes as flaky rather than green. That label is the half that matters. Every attempt rebuilds the test instance and its context, and data from a failed attempt is discarded.</p>
<p>The console reporter shows you the outcome by name:</p>
<div class="highlight"><pre><span></span><code>  [PASS] PublicPages.Tests.ExampleDomainTests.Page_HasItsTitleAndHeading
  [FLAKY] PublicPages.Tests.ExampleDomainTests.Page_MeetsItsBudget (after 2 attempts)
  [QUARANTINE] PublicPages.Tests.ReservedDomainsTests.Page_ListsTheTestIdnTopLevelDomains

Results: 8 passed, 0 failed, 1 flaky, 1 quarantined, 9 total
</code></pre></div>

<p>That run exits zero. <code>--fail-on-flaky</code> makes it exit non-zero, and it is what stops a suite drifting into needing retries everywhere while still reporting success. I would not switch it on the first day, though. Let <code>--flaky-history</code> accumulate per-test counters first:</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;PublicPages.Tests.ExampleDomainTests.Page_MeetsItsBudget&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;runs&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">128</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;failures&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;flakyPasses&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">17</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;lastSeenUtc&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;2026-08-30T14:02:11Z&quot;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>A flake rate is <code>(failures + flakyPasses) / runs</code>, and that's the number to sort by when you're deciding what to fix first. Persist the file between runs, as a CI cache or a committed artifact. If you don't, every run starts from nothing and you have a file that only ever describes the last few minutes.</p>
<h2 id="pinning-the-browser">Pinning the browser</h2>
<p>A run that passed last week and fails this week with no commit behind it is usually a browser that moved. <code>--channel</code> takes whatever is installed. Pinning replaces that with an exact binary:</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>install<span class="w"> </span>--channel<span class="w"> </span>chromium<span class="w"> </span>--revision<span class="w"> </span><span class="m">149</span>.0.7827.156
<span class="nb">export</span><span class="w"> </span><span class="nv">MOTUS_EXECUTABLE_PATH</span><span class="o">=</span><span class="s2">&quot;</span><span class="k">$(</span>cat<span class="w"> </span>~/.motus/browsers/.installed.chromium<span class="k">)</span><span class="s2">&quot;</span>
</code></pre></div>

<p><code>--revision</code> takes a Chrome for Testing version in dotted form. The marker file the installer writes holds the resolved executable path, so the install step can export it and nothing later in the job has to go hunting for where it landed. A path set in code still wins over the variable, which is the same layering from earlier showing up again.</p>
<h2 id="where-this-one-falls-short">Where this one falls short</h2>
<p>It cannot run on Firefox as written, and I would rather say that here than have you find out from a failing job. Tracing, <code>RouteAsync</code> interception and code coverage all go through Chrome DevTools Protocol domains that a Firefox session driven over WebDriver BiDi does not have. The capability guard says so by name rather than failing obscurely, which is the best I can do about it right now. Locators, actions, assertions and script evaluation all work there. Those three do not.</p>
<h2 id="every-artifact-and-what-it-answers">Every artifact, and what it answers</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>File</th>
<th>Produced by</th>
<th>What it answers</th>
<th>How to open it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>results.shard-N.xml</code></td>
<td><code>--reporter junit:&lt;path&gt;</code></td>
<td>Which tests that shard ran, and how each ended</td>
<td>Any JUnit reader</td>
</tr>
<tr>
<td><code>results.xml</code></td>
<td><code>motus shard merge --output junit:</code></td>
<td>The whole run, flaky and quarantined counts summed</td>
<td>Any JUnit reader</td>
</tr>
<tr>
<td><code>artifacts/report.html</code></td>
<td><code>--reporter html:&lt;path&gt;</code></td>
<td>Per-test detail, with audit violations and metrics</td>
<td>A browser</td>
</tr>
<tr>
<td><code>artifacts/follow-the-link.zip</code></td>
<td><code>Tracing.StopAsync</code></td>
<td>What the page looked like at each step</td>
<td><code>motus trace show</code></td>
</tr>
<tr>
<td><code>artifacts/follow-the-link.har</code></td>
<td><code>StopHarRecordingAsync</code></td>
<td>Every request and response of the scenario</td>
<td>Any HAR viewer</td>
</tr>
<tr>
<td><code>artifacts/follow-the-link.avi</code></td>
<td><code>StopVideoRecordingAsync</code></td>
<td>What the screen did, at viewport size</td>
<td>Any MJPEG in AVI player</td>
</tr>
<tr>
<td><code>artifacts/coverage/index.html</code></td>
<td><code>--coverage html:&lt;dir&gt;</code></td>
<td>Which script and stylesheet lines the run touched</td>
<td>A browser</td>
</tr>
<tr>
<td><code>artifacts/flake-history.json</code></td>
<td><code>--flaky-history &lt;path&gt;</code></td>
<td>How often each test has needed a retry</td>
<td>A text editor</td>
</tr>
</tbody>
</table></div>
<p>None of that is a separate tool, a second browser session or another package. It is one suite, one browser, and a run you can ask what it saw.</p>
<p>Every flag and key above is documented at <a href="https://motustesting.com/docs/">motustesting.com/docs</a>, nearest the <a href="https://motustesting.com/docs/guides/sharding.html">sharding</a>, <a href="https://motustesting.com/docs/guides/flaky-tests-and-quarantine.html">flaky tests</a> and <a href="https://motustesting.com/docs/reference/cli.html">CLI</a> pages. Motus is MIT licensed and the source is on <a href="https://github.com/DataficationSDK/Motus">GitHub</a>. Build something like this, kill a shard on purpose, and tell me whether the merged report told you what happened.</p>]]></content:encoded>
  </item>
  <item>
    <title>One slider at the top, and the rest of the notebook follows</title>
    <link>https://datafication.co/blog/2026/a-notebook-that-reruns-itself.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/a-notebook-that-reruns-itself.html</guid>
    <pubDate>Thu, 20 Aug 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>I built a weather notebook where one drag on a Python slider re-runs the two C# cells that depend on it, and nothing else.</description>
    <content:encoded><![CDATA[<p>You change a number near the top of a notebook, and then the chore starts. Find every cell below that used it, work out which of those feed each other, and run them in that order without missing one. Most of us just run everything from the top, which is fine until one cell takes four minutes.</p>
<p>The DAG Notebook layout does that part for you: it reads the notebook, works out which cells feed which, and when a cell finishes running it runs that cell's dependents, in dependency order. It shipped with <a href="https://datafication.co/blog/2026/verso-1-2-live-widgets-panels-and-five-languages.html">Verso 1.2</a> as <code>Verso.Showcase.DagNotebook</code>, an ordinary package on NuGet.</p>
<p>What I like about it is where it lives. Reactive execution is normally a property of the notebook tool you picked. Here it is a layout extension sitting on the same public interfaces as everything else, which is what <a href="https://datafication.co/blog/2026/every-feature-is-an-extension.html">treating every feature as an extension</a> buys you.</p>
<p>So let me build one in front of you: a week of forecast from a public API, an <code>ipywidgets</code> slider, one <code>#!bind</code> line, and two C# cells that follow the slider on their own.</p>
<div class="stats">
<div><div class="big">5</div><span class="mono">cells in the notebook</span></div>
<div><div class="big">2</div><span class="mono">cells a drag re-runs</span></div>
<div><div class="big">450 ms</div><span class="mono">one gesture, one cascade</span></div>
<div><div class="big">1 MB</div><span class="mono">ceiling on a projected value</span></div>
</div>

<h2 id="getting-the-layout-in-front-of-you">Getting the layout in front of you</h2>
<p>This one is an inline layout, so your cells stay the real, editable cells and the layout supplies the frame around them. Install the command line tool and open a notebook.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
verso<span class="w"> </span>serve<span class="w"> </span>weather.verso
</code></pre></div>

<p>A notebook can also ask for the extension itself. Two entries in its <code>metadata</code> block name the package and pin the layout, so the host installs it from NuGet and opens straight into it.</p>
<div class="highlight"><pre><span></span><code><span class="nt">&quot;activeLayout&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;extensionId&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;com.verso.showcase.dag-notebook&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;layoutId&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;dag-notebook&quot;</span><span class="w"> </span><span class="p">},</span>
<span class="nt">&quot;extensions&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;Verso.Showcase.DagNotebook&quot;</span><span class="p">]</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>

<p>Otherwise install it from the Extensions pane and pick <strong>DAG Notebook</strong> from the layout picker. Approving an extension is per notebook and pinned to the version you approved. Everything below works the same in VS Code and in the browser host <code>verso serve</code> starts.</p>
<h2 id="the-data">The data</h2>
<p>The forecast comes from the Open-Meteo forecast API, which needs no key. This is the request the weather briefing notebook in the Verso gallery makes.</p>
<div class="highlight"><pre><span></span><code><span class="k">using</span><span class="w"> </span><span class="nn">System.Net.Http</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">System.Text.Json</span><span class="p">;</span>

<span class="kt">var</span><span class="w"> </span><span class="n">url</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;https://api.open-meteo.com/v1/forecast&quot;</span>
<span class="w">        </span><span class="o">+</span><span class="w"> </span><span class="s">&quot;?latitude=47.6062&amp;longitude=-122.3321&quot;</span>
<span class="w">        </span><span class="o">+</span><span class="w"> </span><span class="s">&quot;&amp;daily=temperature_2m_max,temperature_2m_min&quot;</span>
<span class="w">        </span><span class="o">+</span><span class="w"> </span><span class="s">&quot;&amp;temperature_unit=fahrenheit&amp;timezone=America%2FLos_Angeles&amp;forecast_days=7&quot;</span><span class="p">;</span>

<span class="kt">var</span><span class="w"> </span><span class="n">json</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">HttpClient</span><span class="p">().</span><span class="n">GetStringAsync</span><span class="p">(</span><span class="n">url</span><span class="p">);</span>
<span class="kt">var</span><span class="w"> </span><span class="n">daily</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">JsonDocument</span><span class="p">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">json</span><span class="p">).</span><span class="n">RootElement</span><span class="p">.</span><span class="n">GetProperty</span><span class="p">(</span><span class="s">&quot;daily&quot;</span><span class="p">);</span>

<span class="kt">var</span><span class="w"> </span><span class="n">days</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">daily</span><span class="p">.</span><span class="n">GetProperty</span><span class="p">(</span><span class="s">&quot;time&quot;</span><span class="p">).</span><span class="n">EnumerateArray</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">d</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">DateTime</span><span class="p">.</span><span class="n">Parse</span><span class="p">(</span><span class="n">d</span><span class="p">.</span><span class="n">GetString</span><span class="p">()</span><span class="o">!</span><span class="p">).</span><span class="n">ToString</span><span class="p">(</span><span class="s">&quot;ddd&quot;</span><span class="p">)).</span><span class="n">ToArray</span><span class="p">();</span>
<span class="kt">var</span><span class="w"> </span><span class="n">highs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">daily</span><span class="p">.</span><span class="n">GetProperty</span><span class="p">(</span><span class="s">&quot;temperature_2m_max&quot;</span><span class="p">).</span><span class="n">EnumerateArray</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">t</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">GetDouble</span><span class="p">()).</span><span class="n">ToArray</span><span class="p">();</span>
<span class="kt">var</span><span class="w"> </span><span class="n">lows</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">daily</span><span class="p">.</span><span class="n">GetProperty</span><span class="p">(</span><span class="s">&quot;temperature_2m_min&quot;</span><span class="p">).</span><span class="n">EnumerateArray</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">t</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">GetDouble</span><span class="p">()).</span><span class="n">ToArray</span><span class="p">();</span>

<span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="s">$&quot;{days.Length} days for Seattle, highs {highs.Min():F1} to {highs.Max():F1}°F.&quot;</span><span class="p">);</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>7 days for Seattle, highs 67.8 to 85.2°F.
</code></pre></div>

<p>A forecast is live data, so your run gives a different week. Every number here comes from one response, saved inside that gallery notebook from a call made on 2026-07-04, so the figures are checkable rather than asserted.</p>
<div class="table-wrap"><table class="num">
<thead>
<tr>
<th>Day</th>
<th>Date</th>
<th>High °F</th>
<th>Low °F</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sat</td>
<td>2026-07-04</td>
<td>67.8</td>
<td>55.5</td>
</tr>
<tr>
<td>Sun</td>
<td>2026-07-05</td>
<td>71.9</td>
<td>52.5</td>
</tr>
<tr>
<td>Mon</td>
<td>2026-07-06</td>
<td>80.2</td>
<td>52.5</td>
</tr>
<tr>
<td>Tue</td>
<td>2026-07-07</td>
<td>85.2</td>
<td>51.9</td>
</tr>
<tr>
<td>Wed</td>
<td>2026-07-08</td>
<td>68.6</td>
<td>57.8</td>
</tr>
<tr>
<td>Thu</td>
<td>2026-07-09</td>
<td>75.3</td>
<td>54.1</td>
</tr>
<tr>
<td>Fri</td>
<td>2026-07-10</td>
<td>80.0</td>
<td>50.2</td>
</tr>
</tbody>
</table></div>

<p>An HTTP cell would fetch the same thing in two lines of <code>.http</code> syntax, and I did not use one. The dependency scan reads C#, Python, and F# source, and a variable an HTTP cell publishes is not an assignment anywhere in that source, so the scan cannot see it. Fetching in C# keeps the whole notebook inside the graph.</p>
<h2 id="the-slider">The slider</h2>
<p>Two Python cells. The first builds the slider, the second shares it.</p>
<div class="highlight"><pre><span></span><code><span class="ch">#!pip ipywidgets</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">ipywidgets</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">widgets</span>

<span class="n">slider</span> <span class="o">=</span> <span class="n">widgets</span><span class="o">.</span><span class="n">IntSlider</span><span class="p">(</span><span class="n">value</span><span class="o">=</span><span class="mi">70</span><span class="p">,</span> <span class="nb">min</span><span class="o">=</span><span class="mi">40</span><span class="p">,</span> <span class="nb">max</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span>
                           <span class="n">description</span><span class="o">=</span><span class="s2">&quot;Threshold&quot;</span><span class="p">,</span> <span class="n">continuous_update</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
<span class="n">slider</span>
</code></pre></div>

<p>The <code>#!pip</code> line is there for a notebook that has to run anywhere. In the editor you can leave it out, because the default install policy offers to install what a cell imports and cannot find. On the command line it does real work: <code>verso run</code> never installs on import, and <code>--auto-install</code> covers only the distributions Verso keeps a mapping for.</p>
<p><code>continuous_update=False</code> holds the value until you let go of the handle. You do not need it, because even with continuous updates the cascade runs once per gesture.</p>
<p>The next cell is one line, and it is the line that turns the notebook on.</p>
<div class="highlight"><pre><span></span><code>#!bind slider.value as threshold
</code></pre></div>

<div class="highlight"><pre><span></span><code>&#39;threshold&#39; now follows slider.value. Any kernel can read it, and writing it moves the widget.
</code></pre></div>

<p>From here <code>threshold</code> is an ordinary entry in the <a href="https://datafication.co/blog/2026/one-variable-store-eight-languages.html">shared variable store</a>, which every kernel can read from and write to.</p>
<div class="admonition warning">
<p class="admonition-title">The object has to exist already</p>
<p>A magic command runs before the rest of its own cell, so <code>#!bind</code> has to name a widget an earlier cell built. Bind in the same cell that creates the widget and it finds nothing, and tells you so. That is why this is two cells and not one.</p>
</div>
<p>The trait is an <code>Int</code>, so other kernels see a <code>long</code>. A <code>Float</code> arrives as a <code>double</code>, a <code>Unicode</code> as a <code>string</code>, and a <code>Dict</code> as a dictionary keyed by string. A trait holding another widget is refused, and so is a value over the megabyte a projected value may occupy, because a projection crosses on every change and a dragged control changes many times a second.</p>
<h2 id="the-two-cells-that-read-it">The two cells that read it</h2>
<p>Two C# cells, written the way you would write any cell that reads a variable. Neither knows a widget exists.</p>
<div class="highlight"><pre><span></span><code><span class="kt">var</span><span class="w"> </span><span class="n">cutoff</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Variables</span><span class="p">.</span><span class="n">Get</span><span class="o">&lt;</span><span class="kt">long</span><span class="o">&gt;</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">);</span>
<span class="kt">var</span><span class="w"> </span><span class="n">warmDays</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">days</span><span class="p">.</span><span class="n">Zip</span><span class="p">(</span><span class="n">highs</span><span class="p">,</span><span class="w"> </span><span class="p">(</span><span class="n">d</span><span class="p">,</span><span class="w"> </span><span class="n">h</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">(</span><span class="n">Day</span><span class="p">:</span><span class="w"> </span><span class="n">d</span><span class="p">,</span><span class="w"> </span><span class="n">High</span><span class="p">:</span><span class="w"> </span><span class="n">h</span><span class="p">))</span>
<span class="w">                   </span><span class="p">.</span><span class="n">Where</span><span class="p">(</span><span class="n">x</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">x</span><span class="p">.</span><span class="n">High</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="n">cutoff</span><span class="p">)</span>
<span class="w">                   </span><span class="p">.</span><span class="n">ToArray</span><span class="p">();</span>

<span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="s">$&quot;{warmDays.Length} of {days.Length} days at or above {cutoff}°F.&quot;</span><span class="p">);</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>5 of 7 days at or above 70°F.
</code></pre></div>

<div class="highlight"><pre><span></span><code><span class="kt">var</span><span class="w"> </span><span class="n">atOrAbove</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Variables</span><span class="p">.</span><span class="n">Get</span><span class="o">&lt;</span><span class="kt">long</span><span class="o">&gt;</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">);</span>
<span class="kt">var</span><span class="w"> </span><span class="n">listed</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">warmDays</span><span class="p">.</span><span class="n">Length</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="s">&quot;none&quot;</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="kt">string</span><span class="p">.</span><span class="n">Join</span><span class="p">(</span><span class="s">&quot;, &quot;</span><span class="p">,</span><span class="w"> </span><span class="n">warmDays</span><span class="p">.</span><span class="n">Select</span><span class="p">(</span><span class="n">w</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">w</span><span class="p">.</span><span class="n">Day</span><span class="p">));</span>

<span class="s">$&quot;{atOrAbove}°F and above: {listed}.&quot;</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>70°F and above: Sun, Mon, Tue, Thu, Fri.
</code></pre></div>

<p>That is the whole notebook: five code cells, no prose cells in between, so the numbers the layout draws are the numbers you just saw.</p>
<h2 id="what-the-layout-drew">What the layout drew</h2>
<p>Every linked cell gets a row of chips above it. <code>↑ 1 days</code> means this cell reads <code>days</code> from cell 1. <code>↓ 5 warmDays</code> means cell 5 reads <code>warmDays</code> from this one. <code>↻ threshold</code> marks a variable that follows a control instead of a computation. Chips are numbered by document position but tracked by cell identity, so they survive a reorder, and clicking one scrolls you to the other end of the link.</p>
<figure class="fig">
<svg viewBox="0 0 760 210" role="img" aria-label="The chip row above cell four, with three inbound chips and one outbound chip, and a legend row for the chip that marks a variable following a control">
<rect class="soft" x="30" y="20" width="96" height="28" rx="14"/>
<text class="code" x="78" y="39" text-anchor="middle">↑ 1 days</text>
<rect class="soft" x="136" y="20" width="102" height="28" rx="14"/>
<text class="code" x="187" y="39" text-anchor="middle">↑ 1 highs</text>
<rect class="soft" x="248" y="20" width="136" height="28" rx="14"/>
<text class="code" x="316" y="39" text-anchor="middle">↑ 3 threshold</text>
<rect class="soft" x="394" y="20" width="146" height="28" rx="14"/>
<text class="code" x="467" y="39" text-anchor="middle">↓ 5 warmDays</text>
<rect class="box on" x="30" y="64" width="700" height="90"/>
<text class="code" x="48" y="90">var cutoff = Variables.Get&lt;long&gt;("threshold");</text>
<text class="code" x="48" y="110">var warmDays = days.Zip(highs, ...).Where(x =&gt; x.High &gt;= cutoff)</text>
<text class="muted" x="48" y="138">5 OF 7 DAYS AT OR ABOVE 70°F.</text>
<rect class="soft" x="30" y="172" width="96" height="26" rx="13"/>
<text class="code" x="78" y="190" text-anchor="middle">↻ name</text>
<text class="label" x="140" y="190">cell 3 carries this one: threshold follows the slider</text>
</svg>
<figcaption>Cell 4 reads three names and feeds one. There are only three symbols to learn.</figcaption>
</figure>

<p>Put every chip together and you have the graph. Cell 1 feeds cell 4 twice, once for <code>days</code> and once for <code>highs</code>. Cell 2 feeds cell 3, because the bind directive names the object it binds. Cell 3 produces <code>threshold</code>, which both C# cells read. Cell 4 feeds cell 5. <code>lows</code> gets parsed and read by nobody, so it gets no chip.</p>
<figure class="fig">
<svg viewBox="0 0 760 380" role="img" aria-label="A dependency graph of five notebook cells, with the bound variable threshold drawn as a rounded node feeding the two C sharp cells, which carry badges numbering the order a drag re-runs them">
<rect class="box on" x="30" y="30" width="180" height="64"/>
<text class="label b" x="46" y="58">1  fetch (C#)</text>
<text class="muted" x="46" y="80">DAYS, HIGHS, LOWS</text>
<rect class="box on" x="290" y="30" width="180" height="64"/>
<text class="label b" x="306" y="58">4  warm days (C#)</text>
<text class="muted" x="306" y="80">WARMDAYS</text>
<circle class="soft" cx="452" cy="48" r="13"/>
<text class="label b" x="452" y="53" text-anchor="middle">1</text>
<rect class="box on" x="550" y="30" width="180" height="64"/>
<text class="label b" x="566" y="58">5  summary (C#)</text>
<circle class="soft" cx="712" cy="48" r="13"/>
<text class="label b" x="712" y="53" text-anchor="middle">2</text>
<rect class="box" x="30" y="300" width="180" height="64"/>
<text class="label b" x="46" y="328">2  slider (Python)</text>
<rect class="box" x="250" y="300" width="170" height="64"/>
<text class="label b" x="266" y="328">3  #!bind (Python)</text>
<path class="brand-line" d="M210 62 H276" stroke-width="2"/>
<path class="brand" d="M288 62 l-12 -6 v12 z"/>
<path class="brand-line" d="M470 62 H536" stroke-width="2"/>
<path class="brand" d="M548 62 l-12 -6 v12 z"/>
<path class="line" d="M210 332 H236" stroke-width="2"/>
<path class="fill" d="M248 332 l-12 -6 v12 z"/>
<path class="line" d="M420 332 H476" stroke-width="2"/>
<path class="fill" d="M488 332 l-12 -6 v12 z"/>
<ellipse class="soft" cx="580" cy="332" rx="90" ry="34"/>
<ellipse class="brand-line" cx="580" cy="332" rx="90" ry="34" stroke-width="2"/>
<text class="label b" x="580" y="337" text-anchor="middle">threshold</text>
<path class="brand-line" d="M540 306 C 480 250, 430 190, 390 118" stroke-width="2"/>
<path class="brand" d="M384 106 l10 8 h-12 z"/>
<path class="brand-line" d="M622 300 C 650 230, 646 160, 640 118" stroke-width="2"/>
<path class="brand" d="M638 106 l7 10 h-13 z"/>
</svg>
<figcaption>Five cells, two languages, one set of edges. The rounded node follows the slider, and the badges are the order one drag re-runs them.</figcaption>
</figure>

<h2 id="moving-the-dial">Moving the dial</h2>
<p>Press <strong>Run DAG</strong> once. That button lives in the layout's own header and runs every cell in dependency order rather than document order. The host's Run All is untouched and keeps its document-order behavior, and the two coexist on purpose.</p>
<p>Then drag the slider and let go. Cells 4 and 5 re-run on their own, in that order, with nothing clicked. Cell 4 prints its count, cell 5 names the days.</p>
<div class="table-wrap"><table class="num">
<thead>
<tr>
<th>Threshold</th>
<th>Warm days</th>
<th>Cell 5</th>
</tr>
</thead>
<tbody>
<tr>
<td>60</td>
<td>7</td>
<td>Sat, Sun, Mon, Tue, Wed, Thu, Fri</td>
</tr>
<tr>
<td>70</td>
<td>5</td>
<td>Sun, Mon, Tue, Thu, Fri</td>
</tr>
<tr>
<td>80</td>
<td>3</td>
<td>Mon, Tue, Fri</td>
</tr>
<tr>
<td>85</td>
<td>1</td>
<td>Tue</td>
</tr>
<tr>
<td>90</td>
<td>0</td>
<td>none</td>
</tr>
</tbody>
</table></div>

<p>Cell 1 never runs again. It does not depend on <code>threshold</code>, so no forecast is fetched a second time, which is the difference between this and Run All.</p>
<p>The trigger is a completed run, not a keystroke. When a cell finishes successfully, the layout runs that cell's transitive dependents one at a time, in topological order, through the same notebook operations you would drive by hand. Strictly sequential is the point: a dependent must not start before its producer has finished.</p>
<p>A drag is one run, not one per step. The cascade waits before it starts, and a change that arrives inside that window replaces the one before it. The sample's own constants are 300 milliseconds after a completed run and 450 after a moved control. The longer one is deliberate, because a dragged slider writes its variable many times a second and each write cancels the trigger the last one scheduled. That window also keeps a batch run honest: any cell starting inside it cancels the pending trigger, so a document-order Run All finishes on its own terms.</p>
<p>Turn <strong>Auto-run dependents</strong> off and drag again: cells 4 and 5 draw a dashed border and wait. The mark shows up the moment a producer starts running and clears when the cell catches up. The toggle round-trips through the layout's metadata, so a notebook saved with it off stays off.</p>
<h2 id="how-the-graph-is-built">How the graph is built</h2>
<p>This is the part I would read twice, because it holds the honest limits.</p>
<p>On every render the layout scans each code cell for the variables it defines, meaning assignments, functions, and type declarations, and the names it references, including names inside interpolated strings. A variable with exactly one defining cell links that producer to every cell that reads it. The scan covers C#, Python, and F#. The sample's own README calls it a lightweight heuristic pass and a strong hint rather than a proof, which is the right way to hold it: it reads source text, it does not compile it.</p>
<p>Two of the ways a cell writes a variable are not assignments at all. <code>#!bind</code> shares a widget's trait, and <code>Variables.Get("name")</code> and <code>Variables.Set("name", ...)</code> reach the shared store by a name that lives inside a string literal. Both get read before comments and string literals are stripped away, and that one decision is what lets a Python control link to a C# cell instead of the graph stopping dead at each language boundary.</p>
<p>Cell 3 holds nothing but <code>#!bind slider.value as threshold</code>. To Python that line is a comment. To the scan it is a cell that reads <code>slider</code> and writes <code>threshold</code>, which is the pair of edges the graph needs.</p>
<h2 id="when-it-refuses-to-cascade">When it refuses to cascade</h2>
<p>When the layout is not sure it says so rather than guessing, and that matters more to me than the happy path.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Action</th>
<th>Auto-run on</th>
<th>Auto-run off</th>
</tr>
</thead>
<tbody>
<tr>
<td>Run a producer</td>
<td>its transitive dependents run one at a time, in dependency order</td>
<td>they draw a dashed border and wait</td>
</tr>
<tr>
<td>Drag the slider, let go</td>
<td>the same, from the cell that bound the trait</td>
<td>the same border, nothing runs</td>
</tr>
<tr>
<td>A cascaded cell fails</td>
<td>the cascade stops there, later cells keep their stale mark, the failed cell gets an error border</td>
<td>nothing was running</td>
</tr>
<tr>
<td>Two cells assign one name</td>
<td>a warning chip on every writer and no edges at all</td>
<td>the same chips, the same missing edges</td>
</tr>
<tr>
<td>Cells form a cycle</td>
<td>flagged, and the cycle's own edges excluded, so a cascade cannot loop</td>
<td>the same flags</td>
</tr>
</tbody>
</table></div>
<p>The failure row is the one to plan around. A cascade is not a transaction: if cell 4 throws, cell 5 keeps its stale marker and its old output, and the notebook is left honestly half updated.</p>
<p>The multi-writer row is easy to trip over. Give a variable the same name in two cells and both get a warning chip and the links vanish. The pricing sample that ships with the layout assigns <code>counter</code> twice on purpose so you can watch it happen.</p>
<h2 id="the-link-runs-both-ways">The link runs both ways</h2>
<p>Writing the variable from a cell moves the slider on the page.</p>
<div class="highlight"><pre><span></span><code><span class="n">Variables</span><span class="p">.</span><span class="n">Set</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">,</span><span class="w"> </span><span class="mi">80L</span><span class="p">);</span>
</code></pre></div>

<p>The control moves, the author's <code>observe</code> callbacks fire, and every other kernel sees 80.</p>
<figure class="fig">
<svg viewBox="0 0 720 210" role="img" aria-label="A Python trait and a C sharp cell either side of the shared variable store, with arrows running in both directions between each pair">
<rect class="box on" x="20" y="40" width="200" height="110"/>
<text class="label b" x="38" y="70">Python interpreter</text>
<text class="code" x="38" y="96">slider.value = 70</text>
<text class="muted" x="38" y="124">OBSERVE CALLBACKS FIRE</text>
<rect class="soft" x="270" y="50" width="180" height="90" rx="16"/>
<rect class="brand-line" x="270" y="50" width="180" height="90" rx="16" stroke-width="2"/>
<text class="muted" x="360" y="80" text-anchor="middle">SHARED VARIABLE STORE</text>
<text class="label b" x="360" y="106" text-anchor="middle">threshold</text>
<text class="code" x="360" y="126" text-anchor="middle">70 (long)</text>
<rect class="box on" x="500" y="40" width="200" height="110"/>
<text class="label b" x="518" y="70">C# cell 4</text>
<text class="code" x="518" y="96">Variables.Get&lt;long&gt;</text>
<text class="code" x="518" y="116">("threshold")</text>
<path class="brand-line" d="M224 76 H254" stroke-width="2"/>
<path class="brand" d="M266 76 l-12 -6 v12 z"/>
<path class="brand-line" d="M446 114 H416" stroke-width="2"/>
<path class="brand" d="M404 114 l12 -6 v12 z"/>
<path class="brand-line" d="M454 76 H484" stroke-width="2"/>
<path class="brand" d="M496 76 l-12 -6 v12 z"/>
<path class="brand-line" d="M266 114 H236" stroke-width="2"/>
<path class="brand" d="M224 114 l12 -6 v12 z"/>
<text class="muted" x="20" y="188">A VALUE SETTLES AFTER ONE ROUND TRIP, IN WHICHEVER DIRECTION IT STARTED.</text>
</svg>
<figcaption>It works the same in both directions. One round trip, then it stops.</figcaption>
</figure>

<p>That symmetry is what makes a cell writing the variable it reads safe. A control driving a cell that writes the same name back is a loop the scan cannot see, because the write happens at run time and not in the source. What settles it is that a fresh reading of every bound variable is taken each time a cell completes.</p>
<p><code>#!bind --list</code> tells you what is currently shared:</p>
<div class="highlight"><pre><span></span><code>Widget traits shared as variables:
  threshold  &lt;-  slider.value
</code></pre></div>

<p><code>#!bind --remove threshold</code> stops the two following each other without deleting anything, so a cell reading the name keeps working on its last value. A kernel restart does the same, and re-running the <code>#!bind</code> line reconnects it.</p>
<h2 id="where-the-limits-are">Where the limits are</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Limit</th>
<th>Value</th>
<th>What happens</th>
</tr>
</thead>
<tbody>
<tr>
<td>One widget's saved page</td>
<td>4 MB</td>
<td>the cell shows a message instead of the widget</td>
</tr>
<tr>
<td>One message between a widget and its kernel</td>
<td>8 MB</td>
<td>refused with a diagnostic naming the widget, and the session continues</td>
</tr>
<tr>
<td>One projected value</td>
<td>1 MB</td>
<td>the bind is refused, or a later change keeps the value that crossed before it</td>
</tr>
</tbody>
</table></div>
<p>A widget's state travels in the notebook file, but the JavaScript that draws it comes from a public CDN when the widget is shown, so a machine with no network draws an empty frame. A saved file holds the state the reader last saw, not the state the cell drew: drag the slider to 98, save, and the file says 98.</p>
<p>Widgets are live in the editor, whether you served the notebook with <code>verso serve</code> or opened it in VS Code. A notebook run by <code>verso run</code> has no view to talk to, so there the slider draws from its saved state and the C# cells read whatever that state holds. That is right for a scheduled job, and it means one file is both an instrument you play at your desk and a script that produces one answer in a pipeline. If the pipeline should pick the number, use notebook parameters rather than the slider.</p>
<h2 id="what-i-take-from-this">What I take from this</h2>
<p>Five cells, two languages, one variable, and a package. The reactive behavior is not in the engine, not in the file format, and not in the editor. It is in a layout extension that references <code>Verso.Abstractions</code> and nothing else, bundles no third-party libraries, and is MIT licensed like everything around it. If reactive execution can arrive as a package, so can whatever else you were going to ask us to build in.</p>
<p>Both samples, <code>dag-notebook.verso</code> and <code>dag-notebook-widget.verso</code>, sit in the <a href="https://github.com/DataficationSDK/Verso/tree/main/samples/showcase/dag-notebook">sample folder on GitHub</a> with the layout's source. The first seeds a small pricing model, where two base inputs feed revenue, revenue feeds profit, and a summary cell reads all three. The <a href="https://www.versonotebooks.com/docs/guides/interactive-widgets.html">Interactive Widgets guide</a> covers <code>#!bind</code> and the projected types, and the <a href="https://www.versonotebooks.com/docs/extensions/layouts.html">layout authoring guide</a> is where to start on one of your own. If you build one, I would like to hear where it got awkward.</p>]]></content:encoded>
  </item>
  <item>
    <title>Moving a Playwright for .NET suite to Motus</title>
    <link>https://datafication.co/blog/2026/migrating-from-playwright-for-dotnet.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/migrating-from-playwright-for-dotnet.html</guid>
    <pubDate>Tue, 18 Aug 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Most of it is search and replace, so here are the steps in the order you would do them, the four places the APIs really differ, and the parts that do not come across at all.</description>
    <content:encoded><![CDATA[<p>If you already have a Playwright for .NET suite, most of it moves to Motus with search and replace. The locator vocabulary is the same, the assertion names are the same, and the actions are the same. What actually changes is what sits between your process and the browser, plus four API shapes that need a real edit.</p>
<p>The steps below are in the order you would do them. There is a section near the end that says what does not come across, because a migration guide that hides the losses is not worth following.</p>
<h2 id="before-you-start">Before you start</h2>
<p>You need the .NET 8 SDK or later, a suite that builds today, and a browser. Two questions decide whether the rest of this is for you.</p>
<p>Does any part of your suite run outside Chromium and Firefox? Motus drives Chromium, including Chrome and Edge, over the Chrome DevTools Protocol, and Firefox over WebDriver BiDi. That is the whole list.</p>
<p>Do you live in Playwright's trace viewer? Motus writes its own trace zip and it opens in <code>motus trace show</code>, nowhere else. The two formats do not interoperate, and they were never meant to.</p>
<p>If both answers are no, everything below is mechanical.</p>
<h2 id="what-sits-between-you-and-the-browser">What sits between you and the browser</h2>
<p>Here is the starting position, in the migration guide's own words: Playwright for .NET "ships a bundled Node.js process (<code>playwright.ps1</code> / <code>playwright</code>) that acts as the automation server; your .NET process talks to it over a named-pipe IPC channel". Motus has no sidecar. Your process holds the browser's WebSocket and speaks the protocol itself.</p>
<figure class="fig">
<svg viewBox="0 0 720 230" role="img" aria-label="Two process chains compared, one with a Node.js automation server between the .NET process and the browser and one without">
  <text class="muted" x="8" y="22">Playwright for .NET</text>
  <rect class="box" x="8" y="32" width="180" height="56"/>
  <text class="label" x="98" y="65" text-anchor="middle">Your .NET process</text>
  <path class="line" d="M188 60 H268" stroke-width="2"/>
  <text class="muted" x="228" y="52" text-anchor="middle">IPC</text>
  <rect class="box" x="268" y="32" width="180" height="56"/>
  <text class="label" x="358" y="65" text-anchor="middle">Node.js server</text>
  <path class="line" d="M448 60 H528" stroke-width="2"/>
  <rect class="box" x="528" y="32" width="184" height="56"/>
  <text class="label" x="620" y="65" text-anchor="middle">Browser</text>

  <text class="muted" x="8" y="152">Motus</text>
  <rect class="box on" x="8" y="162" width="180" height="56"/>
  <text class="label b" x="98" y="195" text-anchor="middle">Your .NET process</text>
  <path class="brand-line" d="M188 190 H528" stroke-width="2"/>
  <text class="muted" x="358" y="182" text-anchor="middle">WebSocket, CDP or BiDi</text>
  <rect class="box on" x="528" y="162" width="184" height="56"/>
  <text class="label b" x="620" y="195" text-anchor="middle">Browser</text>
</svg>
<figcaption>The top chain is the migration guide's own description of Playwright for .NET. The bottom one is what takes its place.</figcaption>
</figure>

<p>That takes the <code>playwright install</code> step out of your build and the Node.js runtime off your machine image. It also means the wire is something you can read: every command is a CDP or BiDi message, not a translation of one.</p>
<h2 id="step-1-swap-the-packages">Step 1: swap the packages</h2>
<p>Nothing clever here.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>remove<span class="w"> </span>package<span class="w"> </span>Microsoft.Playwright
dotnet<span class="w"> </span>remove<span class="w"> </span>package<span class="w"> </span>Microsoft.Playwright.MSTest
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus.Testing.MSTest
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus.Analyzers
</code></pre></div>

<p>Then delete any <code>playwright install</code> or <code>playwright.ps1 install</code> line from your build scripts, and install a browser the Motus way, once per machine.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
</code></pre></div>

<p><code>motus install</code> pulls a Chrome for Testing build into <code>~/.motus/browsers</code>. In CI, pin it with a dotted version, <code>motus install --revision 149.0.7827.156</code>, so a moving stable channel cannot quietly change what your suite ran against.</p>
<h2 id="step-2-fix-the-using-directives">Step 2: fix the using directives</h2>
<p>Four namespaces where you had two.</p>
<div class="highlight"><pre><span></span><code><span class="c1">// Before</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Microsoft.Playwright</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Microsoft.Playwright.MSTest</span><span class="p">;</span>

<span class="c1">// After</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Assertions</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Testing.MSTest</span><span class="p">;</span>
</code></pre></div>

<h2 id="step-3-replace-the-launch-call">Step 3: replace the launch call</h2>
<p>There is no object sitting between startup and the browser, so <code>Playwright.CreateAsync()</code> has nothing to map onto. It just goes away.</p>
<div class="highlight"><pre><span></span><code><span class="c1">// Before</span>
<span class="kt">var</span><span class="w"> </span><span class="n">playwright</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">Playwright</span><span class="p">.</span><span class="n">CreateAsync</span><span class="p">();</span>
<span class="kt">var</span><span class="w"> </span><span class="n">browser</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">playwright</span><span class="p">.</span><span class="n">Chromium</span><span class="p">.</span><span class="n">LaunchAsync</span><span class="p">(</span><span class="k">new</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Headless</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="p">});</span>

<span class="c1">// After</span>
<span class="kt">var</span><span class="w"> </span><span class="n">browser</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">MotusLauncher</span><span class="p">.</span><span class="n">LaunchAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">LaunchOptions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Headless</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="p">});</span>
</code></pre></div>

<p>Firefox is a channel rather than its own entry point: <code>new LaunchOptions { Channel = BrowserChannel.Firefox }</code>.</p>
<h2 id="step-4-the-assertions">Step 4: the assertions</h2>
<p>This is the one project-wide replacement, <code>Expect(</code> to <code>Expect.That(</code>. The method names and what they mean are unchanged, so nothing else on an assertion line moves.</p>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">page</span><span class="p">).</span><span class="n">ToHaveTitleAsync</span><span class="p">(</span><span class="s">&quot;Example Domain&quot;</span><span class="p">);</span>
<span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;button&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;Submit&quot;</span><span class="p">)).</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
</code></pre></div>

<h2 id="step-5-the-test-base-class">Step 5: the test base class</h2>
<p>Swap <code>PageTest</code> for <code>MotusTestBase</code>, then add the assembly hooks that launch the shared browser.</p>
<div class="highlight"><pre><span></span><code><span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">AssemblySetup</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[AssemblyInitialize]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Init</span><span class="p">(</span><span class="n">TestContext</span><span class="w"> </span><span class="n">_</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">LaunchBrowserAsync</span><span class="p">();</span>

<span class="w">    </span><span class="na">[AssemblyCleanup]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Cleanup</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">CloseBrowserAsync</span><span class="p">();</span>
<span class="p">}</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">HomePageTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">TitleIsCorrectAsync</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://example.com&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToHaveTitleAsync</span><span class="p">(</span><span class="s">&quot;Example Domain&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Motus shares one browser process across the whole test assembly and hands each test its own context and page, so you can leave <code>[Parallelize]</code> alone. NUnit uses the same <code>MotusTestBase</code>, with one <code>BrowserFixture</code> per fixture class. xUnit is put together differently: decorate the class with <code>[Collection(nameof(MotusCollection))]</code> and take <code>SharedBrowserFixture</code> through the constructor.</p>
<h2 id="the-mapping-table">The mapping table</h2>
<p>These rows come from the <a href="https://motustesting.com/docs/migration/from-playwright.html">migration guide</a>, which maps every surface in full. These are the ones you will hit most.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Playwright for .NET</th>
<th>Motus</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>var playwright = await Playwright.CreateAsync()</code></td>
<td>(not needed, no sidecar to create)</td>
</tr>
<tr>
<td><code>playwright.Chromium.LaunchAsync(options)</code></td>
<td><code>MotusLauncher.LaunchAsync(options)</code></td>
</tr>
<tr>
<td><code>playwright.Chromium.ConnectOverCDPAsync(endpoint)</code></td>
<td><code>MotusLauncher.ConnectAsync(endpoint)</code></td>
</tr>
<tr>
<td><code>browser.NewContextAsync(options)</code></td>
<td><code>browser.NewContextAsync(options)</code></td>
</tr>
<tr>
<td><code>context.RouteAsync(pattern, handler)</code></td>
<td><code>context.RouteAsync(pattern, handler)</code></td>
</tr>
<tr>
<td><code>page.GotoAsync(url, options)</code></td>
<td><code>page.GotoAsync(url, options)</code></td>
</tr>
<tr>
<td><code>page.GetByRole(role, new() { Name = name })</code></td>
<td><code>page.GetByRole(role, name)</code></td>
</tr>
<tr>
<td><code>page.GetByText(text, new() { Exact = exact })</code></td>
<td><code>page.GetByText(text, exact)</code></td>
</tr>
<tr>
<td><code>page.GetByTestId(testId)</code></td>
<td><code>page.GetByTestId(testId)</code></td>
</tr>
<tr>
<td><code>page.FrameLocator(selector).Locator(inner)</code></td>
<td><code>frame.Locator(inner)</code>, where <code>frame</code> came from <code>page.Frames</code></td>
</tr>
<tr>
<td><code>page.SetViewportSizeAsync(width, height)</code></td>
<td><code>page.SetViewportSizeAsync(viewportSize)</code></td>
</tr>
<tr>
<td><code>locator.ClickAsync(options)</code></td>
<td><code>locator.ClickAsync(timeout)</code></td>
</tr>
<tr>
<td><code>locator.FillAsync(value, options)</code></td>
<td><code>locator.FillAsync(value, timeout)</code></td>
</tr>
<tr>
<td><code>locator.WaitForAsync(options)</code></td>
<td><code>locator.WaitForAsync(state, timeout)</code></td>
</tr>
<tr>
<td><code>locator.Filter(options)</code></td>
<td><code>locator.Filter(options)</code></td>
</tr>
<tr>
<td>(no equivalent)</td>
<td><code>context.GetPluginContext()</code></td>
</tr>
</tbody>
</table></div>
<p>Four of those need a hand edit rather than a rename. Options objects flatten into positional parameters, so <code>new() { Name = name }</code> becomes a second argument. <code>AriaRole</code> enum values become plain role strings. <code>FrameLocator</code> has no Motus type at all, so take an <code>IFrame</code> out of <code>page.Frames</code> and call the same locator factories on it. And Playwright's two connect methods collapse into one <code>ConnectAsync</code> that accepts either a WebSocket URL or an HTTP debugging endpoint.</p>
<h2 id="step-6-configuration">Step 6: configuration</h2>
<p><code>playwright.config.ts</code> becomes <code>motus.config.json</code>, read from the working directory or from the path in <code>MOTUS_CONFIG</code>. It is sectioned rather than flat.</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;launch&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;headless&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;timeout&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">30000</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;context&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;viewport&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1280</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">720</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;assertions&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;timeout&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5000</span><span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Settings layer file first, then <code>MOTUS_*</code> environment variables, then code. <code>LaunchOptions</code> and <code>ContextOptions</code> passed at a call site always win, which is the rule to remember the day a value refuses to change.</p>
<h2 id="what-has-no-equivalent">What has no equivalent</h2>
<p>Better you read this now than find it in week three.</p>
<p><strong>Browser engines beyond Chromium and Firefox.</strong> There is no third transport. If part of your matrix runs somewhere else, that part is staying where it is.</p>
<p><strong>The Playwright trace format and its viewer.</strong> Motus traces are their own zip layout, opened by <code>motus trace show</code>. Tooling built on Playwright's trace files will not read them.</p>
<p><strong>Firefox parity with Chromium.</strong> Tracing, network interception, emulation overrides, security overrides, target multiplexing, the accessibility tree and code coverage are CDP-only. On Firefox those calls throw a <code>NotSupportedException</code> that names the feature and the transport, so you find out loudly rather than quietly. Locators, actions, assertions and script evaluation all work there.</p>
<p><strong>Attaching to a Firefox endpoint.</strong> <code>ConnectAsync</code> builds a CDP transport unconditionally, so attaching is a Chromium-only path.</p>
<p><strong>Packages built on Playwright.</strong> Anything in that ecosystem stops applying, and accessibility auditing is the one that stings: the guide notes that "Playwright delegates accessibility auditing to the separate <code>axe-playwright</code> ecosystem package". Motus has <a href="https://datafication.co/blog/2026/audits-in-the-box.html">nine WCAG rules in the box</a> and no extra dependency. Nine rules are not a replacement for a full audit library, and I am not going to pretend they are.</p>
<p>What you get in exchange: those audits and Core Web Vitals budgets without a second package, seven compile-time diagnostics from <code>Motus.Analyzers</code>, an extension model written in .NET instead of injected JavaScript ("Playwright's extensibility is limited to JavaScript-side selector engines and browser context options; Motus hooks are pure .NET"), and <a href="https://datafication.co/blog/2026/motus-1-0-15.html">attaching to a browser you did not start</a>.</p>
<h2 id="step-7-build-run-triage">Step 7: build, run, triage</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>build
motus<span class="w"> </span>run<span class="w"> </span>bin/Release/net8.0/MyTests.dll<span class="w"> </span>--reporter<span class="w"> </span>console
</code></pre></div>

<p>With <code>Motus.Analyzers</code> referenced, the build itself catches the common leftovers: a call you forgot to await, a browser not disposed with <code>await using</code>, a navigation with nothing waiting after it. Three of the seven diagnostics ship a code fix with Fix All.</p>
<div class="admonition warning">
<p class="admonition-title">The one failure that will surprise you</p>
<p>A Motus locator assertion needs its element to be present. <code>ToBeVisibleAsync</code> and its neighbors retry state once the element is attached, but they fail at once when the locator matches nothing, rather than waiting for it to render. Only <code>ToBeAttachedAsync</code>, <code>ToBeDetachedAsync</code> and <code>ToHaveCountAsync</code> are safe before the element exists.</p>
</div>
<p>So where a test asserts against something that renders later, wait for it first.</p>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;dialog&quot;</span><span class="p">)).</span><span class="n">ToBeAttachedAsync</span><span class="p">();</span>
<span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;dialog&quot;</span><span class="p">)).</span><span class="n">ToBeVisibleAsync</span><span class="p">();</span>
</code></pre></div>

<p>Then check the selectors themselves, which is the other thing a port tends to disturb.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>check-selectors<span class="w"> </span><span class="s2">&quot;tests/**/*.cs&quot;</span><span class="w"> </span>--manifest<span class="w"> </span>tests.selectors.json<span class="w"> </span>--ci
</code></pre></div>

<p>The full mapping lives in the <a href="https://motustesting.com/docs/migration/from-playwright.html">migration guide</a>, the fixture shapes for all three frameworks in <a href="https://motustesting.com/docs/guides/testing-frameworks.html">testing frameworks</a>, and the whole config schema in <a href="https://motustesting.com/docs/guides/configuration.html">configuration</a>. If you hit a surface the mapping table does not cover, that is the issue I want to see.</p>]]></content:encoded>
  </item>
  <item>
    <title>Swap the host in the URL and your notebook is a page</title>
    <link>https://datafication.co/blog/2026/share-a-notebook-by-changing-the-host.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/share-a-notebook-by-changing-the-host.html</guid>
    <pubDate>Fri, 14 Aug 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Change github.com to www.versonotebooks.com/share/github in a notebook&#x27;s address and the file reads as a page with its saved outputs, with nothing uploaded, nothing executed, and no account to create.</description>
    <content:encoded><![CDATA[<p>A notebook has two halves of a life. In the first you run it. In the second somebody reads it, usually on a machine with no .NET on it and no intention of getting any.</p>
<p>The reading half is where notebook workflows pick up a service: somewhere to upload to, an export step, a copy that drifts from the file the moment you commit again. Our answer is an edit to the address bar.</p>
<p>Take the address of a notebook in a public repository:</p>
<div class="highlight"><pre><span></span><code>https://github.com/DataficationSDK/Verso/blob/main/samples/showcase/slide-studio/slide-studio.verso
</code></pre></div>

<p>Change the host to <code>www.versonotebooks.com/share/github</code>:</p>
<div class="highlight"><pre><span></span><code>https://www.versonotebooks.com/share/github/DataficationSDK/Verso/blob/main/samples/showcase/slide-studio/slide-studio.verso
</code></pre></div>

<p>That link opens the notebook as a page: its prose, its code, and the outputs the file was saved with, which here is a table of regional numbers, a bar chart, and a printed summary line. Everything after the host is untouched, so you can make the change in the address bar without looking anything up. If you would rather paste than edit, <a href="https://www.versonotebooks.com/share/">the share page</a> has a box that does the swap.</p>
<figure class="fig">
<svg viewBox="0 0 720 210" role="img" aria-label="A notebook's GitHub address above the same address with only the host segment replaced by the Verso share host, the rest of the path unchanged">
  <text class="label" x="90" y="28">The file in your repository</text>
  <text class="code" x="90" y="63">https://</text>
  <rect class="box" x="148" y="44" width="240" height="28" rx="4"/>
  <text class="code" x="268" y="63" text-anchor="middle">github.com</text>
  <text class="code" x="398" y="63">/owner/repo/blob/main/analysis.verso</text>
  <path class="brand-line" d="M268 82 V116" stroke-width="2" fill="none"/>
  <path class="brand-line" d="M261 108 L268 118 L275 108" stroke-width="2" fill="none"/>
  <text class="muted" x="284" y="105">change only this</text>
  <text class="label" x="90" y="154">The same file as a page</text>
  <text class="code" x="90" y="189">https://</text>
  <rect class="box on" x="148" y="170" width="240" height="28" rx="4"/>
  <text class="code" x="268" y="189" text-anchor="middle">www.versonotebooks.com/share/github</text>
  <text class="code" x="398" y="189">/owner/repo/blob/main/analysis.verso</text>
</svg>
<figcaption>A share link is the file's own link with the host segment replaced. The path, branch, and file name stay as the forge wrote them.</figcaption>
</figure>

<p>GitLab and gists follow the same shape:</p>
<div class="highlight"><pre><span></span><code>https://gitlab.com/group/project/-/blob/main/analysis.verso
https://www.versonotebooks.com/share/gitlab/group/project/blob/main/analysis.verso

https://gist.github.com/user/2b1f0c9e4a
https://www.versonotebooks.com/share/gist/user/2b1f0c9e4a/analysis.verso
</code></pre></div>

<p>GitLab projects nested several groups deep work as written. A gist needs the file name on the end, because it can hold more than one file.</p>
<h2 id="what-the-page-does-and-what-it-will-not-do">What the page does, and what it will not do</h2>
<p>Nothing is uploaded. There is no copy of your notebook on our side to go stale, because the page is built from the file already in your repository. Commit a change and the link shows it.</p>
<p>Nothing is executed, on the page or anywhere else. The notebook renders as a document, in a sandbox with no access to the site around it. A cell that expects a live kernel shows only what it last saved, and a reader who wants to run it downloads the file.</p>
<p>Which means the page shows whatever the file already contains. A notebook saved with its outputs shows its charts, tables, images, and diagrams. One committed after clearing outputs shows the code alone. Worth knowing before you paste a link into a review: strip outputs before committing and the reader gets source and nothing else.</p>
<figure class="fig">
<svg viewBox="0 0 720 180" role="img" aria-label="The notebook file stays in the repository, the share page renders it as a document, and the reader sees the page, with no kernel involved">
  <rect class="box" x="20" y="40" width="180" height="64" rx="6"/>
  <text class="label b" x="110" y="68" text-anchor="middle">Your repository</text>
  <text class="muted" x="110" y="88" text-anchor="middle">analysis.verso, public</text>
  <path class="brand-line" d="M200 72 H262" stroke-width="2" fill="none"/>
  <path class="brand-line" d="M254 66 L264 72 L254 78" stroke-width="2" fill="none"/>
  <rect class="box on" x="264" y="40" width="192" height="64" rx="6"/>
  <text class="label b" x="360" y="68" text-anchor="middle">Share page</text>
  <text class="muted" x="360" y="88" text-anchor="middle">renders the saved file</text>
  <path class="brand-line" d="M456 72 H518" stroke-width="2" fill="none"/>
  <path class="brand-line" d="M510 66 L520 72 L510 78" stroke-width="2" fill="none"/>
  <rect class="box" x="520" y="40" width="180" height="64" rx="6"/>
  <text class="label b" x="610" y="68" text-anchor="middle">Reader</text>
  <text class="muted" x="610" y="88" text-anchor="middle">a link, nothing installed</text>
  <text class="muted" x="360" y="148" text-anchor="middle">No upload step. No kernel. No account. The file never leaves your repository.</text>
</svg>
<figcaption>The file stays the source of truth and the page is a view of it.</figcaption>
</figure>

<h2 id="three-formats-no-conversion-step">Three formats, no conversion step</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Format</th>
<th>How the page reads it</th>
<th>Outputs shown</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>.verso</code></td>
<td>The native format, read as written</td>
<td>Whatever the file was saved with</td>
</tr>
<tr>
<td><code>.ipynb</code></td>
<td>Read directly, including older notebooks</td>
<td>Whatever the file was saved with</td>
</tr>
<tr>
<td><code>.md</code></td>
<td>Split into cells the way the editor splits one</td>
<td>None, the format does not persist outputs</td>
</tr>
</tbody>
</table></div>
<p>The Jupyter row is the one that surprises people. There is no convert-then-share step: point a share link at an <code>.ipynb</code> in a public repository and it reads.</p>
<p>The Markdown rule is the one the editor uses. Prose stays prose, and a top-level fenced block tagged with a language Verso knows becomes a cell of that language, so a shared <code>.md</code> is cut into the same cells you see in the editor. A bare fence, or one tagged with a language Verso does not run, stays prose.</p>
<h2 id="relative-links-and-images-keep-working">Relative links and images keep working</h2>
<p>A markdown cell that says <code>![](figs/genome.png)</code> is asking for the file beside it in your repository, and that is where the page fetches it from, so a notebook that leans on a folder of figures reads the same here as at home. Relative links work the same way: one pointing at another notebook, or at a Markdown or Jupyter file, opens as a page here rather than on the forge, so you can read a folder of notebooks straight through. Everything else opens on the forge, and a full address is left exactly as you wrote it. Every page carries a View source button.</p>
<p>The catch is the obvious one. Those files have to be public too. A figure in a private repository is no more reachable than the notebook would be.</p>
<h2 id="a-link-that-keeps-showing-what-it-showed">A link that keeps showing what it showed</h2>
<p>A link to a branch shows whatever that branch holds today, which is what you want in a README. A link that has to keep showing the same thing, in a paper or an issue thread, should name a commit instead:</p>
<div class="highlight"><pre><span></span><code>https://www.versonotebooks.com/share/github/owner/repo/blob/9f2c1ab.../analysis.verso
</code></pre></div>

<p>GitHub writes that link for you: press <code>y</code> while viewing the file and the address bar swaps the branch name for the commit it points at. Copy it then, and the link keeps showing what it showed the day you shared it.</p>
<h2 id="a-badge-and-a-link-preview">A badge, and a link preview</h2>
<p>Every shared page offers the markdown for a badge, ready to paste into a README beside the notebook:</p>
<div class="highlight"><pre><span></span><code>[<span class="nt">![Open in Verso</span>](<span class="na">https://www.versonotebooks.com/share/assets/open-in-verso.svg</span>)](https://www.versonotebooks.com/share/github/owner/repo/blob/main/analysis.verso)
</code></pre></div>

<p>Share links also carry the notebook's title and opening paragraph as page metadata, so pasting one into Slack, Discord, or a social post produces a card describing the notebook. The title comes from the notebook's own when it has one, otherwise from the file name.</p>
<div class="admonition warning">
<p class="admonition-title">The limits, stated up front</p>
<p>Files have to be public and under 5 MB. Private repositories are not reachable by design: there is no place to put a token, so there is nothing to leak. Notebook pages also ask search engines not to index them, because they reproduce files that belong to other people. A share link is meant to be handed to someone.</p>
</div>
<h2 id="why-we-built-it-this-way">Why we built it this way</h2>
<p>The alternatives all involve a copy. Export to HTML and the copy is stale the next time you run the notebook. Upload to a service and the copy lives somewhere else, with its own permissions and its own account to keep.</p>
<p>A URL that names the file in your repository makes nothing. It is a view, in the database sense: nothing to keep in sync, nothing to revoke, nothing to migrate when the repository moves. The link breaks when the file moves, which is what you want from a pointer.</p>
<p>The quickest way to find out whether it suits your repository is to open a notebook on GitHub, edit the host, and press Enter. The <a href="https://www.versonotebooks.com/docs/guides/sharing-a-notebook.html">Sharing a Notebook guide</a> covers every URL form, and it went out alongside the <a href="https://datafication.co/blog/2026/verso-1-2-live-widgets-panels-and-five-languages.html">1.2 releases</a>.</p>]]></content:encoded>
  </item>
  <item>
    <title>Verso 1.2: live widgets, panels, and five languages</title>
    <link>https://datafication.co/blog/2026/verso-1-2-live-widgets-panels-and-five-languages.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/verso-1-2-live-widgets-panels-and-five-languages.html</guid>
    <pubDate>Sat, 08 Aug 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>A rendered widget keeps talking to the interpreter that drew it, a widget&#x27;s value can become a shared variable any kernel reads, extensions can add panels beside the notebook, and the interface now speaks five languages.</description>
    <content:encoded><![CDATA[<p>Verso 1.2.1 is out. The thread running through it is connection: a widget that stays attached to its kernel, a widget value the other languages can read, and a panel an extension can put beside the notebook on any host.</p>
<h2 id="what-changed">What changed</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Change</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td>Live Python widgets</td>
<td>A rendered <code>ipywidgets</code> or <code>anywidget</code> control stays connected to the interpreter that made it</td>
</tr>
<tr>
<td><code>#!bind</code></td>
<td>Projects a widget's trait into the shared variable store under a name you choose, both ways</td>
</tr>
<tr>
<td>DAG Notebook</td>
<td>A showcase layout that reads which cells feed which and re-runs a cell's dependents</td>
</tr>
<tr>
<td>Notebook panels</td>
<td><code>INotebookPanel</code> and <code>IPanelInteractionHandler</code>, so an extension can add a panel beside the built-in five</td>
</tr>
<tr>
<td>Compare panel</td>
<td>Steps through a notebook's changes one at a time while the notebook stays visible</td>
</tr>
<tr>
<td>Interface language</td>
<td>German, Spanish, Japanese, and Simplified Chinese alongside English</td>
</tr>
<tr>
<td>Formatter extensions</td>
<td><code>IExecutionContext.TryFormatAsync</code> lets a package teach a kernel to render its own types</td>
</tr>
<tr>
<td>Native library resolution</td>
<td>A package's own natives are preferred over a same-named library elsewhere on the machine</td>
</tr>
</tbody>
</table></div>
<h2 id="widgets-that-are-still-connected">Widgets that are still connected</h2>
<p>Drag a slider in a Python cell and the Python that made it hears about it, runs whatever the author wired to it, and can send something back, within a cell and across cells. That holds for <code>ipywidgets</code> and for anything built with <code>anywidget</code>, because Python cells <a href="https://datafication.co/blog/2026/python-on-your-own-interpreter.html">run in a separate host process</a> that is still there after the cell finishes.</p>
<p>A widget with no interpreter behind it, because you just opened the file or restarted its kernel, draws visibly inert with a note to run the cell. Saving asks each live widget for its state, so the file records what you were looking at rather than what the cell first drew.</p>
<p>Three ceilings apply, and each refuses with a diagnostic rather than going quiet: 4 MB for a widget's saved page, 8 MB for one message between a widget and its kernel, 1 MB for one projected value. The state travels in the notebook, but the JavaScript that draws it comes from a public CDN, so a machine with no network draws an empty frame.</p>
<h2 id="a-control-in-front-of-another-language">A control in front of another language</h2>
<p><code>#!bind</code> takes a trait on any object in the Python interpreter and makes it an ordinary shared variable.</p>
<div class="highlight"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">ipywidgets</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">widgets</span>

<span class="n">slider</span> <span class="o">=</span> <span class="n">widgets</span><span class="o">.</span><span class="n">IntSlider</span><span class="p">(</span><span class="n">value</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="nb">min</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="nb">max</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s2">&quot;Threshold&quot;</span><span class="p">)</span>
<span class="n">slider</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>#!bind slider.value as threshold
</code></pre></div>

<div class="highlight"><pre><span></span><code><span class="kt">var</span><span class="w"> </span><span class="n">cutoff</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Variables</span><span class="p">.</span><span class="n">Get</span><span class="o">&lt;</span><span class="kt">long</span><span class="o">&gt;</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">);</span>
<span class="n">readings</span><span class="p">.</span><span class="n">Where</span><span class="p">(</span><span class="n">r</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">r</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="n">cutoff</span><span class="p">)</span>
</code></pre></div>

<figure class="fig">
<svg viewBox="0 0 720 220" role="img" aria-label="A Python cell's widget trait, the notebook's shared variables, and a C# cell, joined by arrows running in both directions">
  <text class="muted" x="360" y="26" text-anchor="middle">moving the slider writes the variable</text>
  <rect class="box on" x="20" y="44" width="190" height="90"/>
  <text class="label b" x="115" y="76" text-anchor="middle">Python cell</text>
  <text class="code" x="115" y="104" text-anchor="middle">slider.value</text>
  <rect class="box soft" x="265" y="44" width="190" height="90"/>
  <text class="label b" x="360" y="76" text-anchor="middle">shared variables</text>
  <text class="code" x="360" y="104" text-anchor="middle">threshold</text>
  <rect class="box" x="510" y="44" width="190" height="90"/>
  <text class="label b" x="605" y="76" text-anchor="middle">C# cell</text>
  <text class="code" x="605" y="104" text-anchor="middle">Variables.Get</text>
  <path class="brand-line" d="M212 70 H249" stroke-width="2"/>
  <path class="fill" d="M263 70 L249 64 L249 76 Z"/>
  <path class="brand-line" d="M457 70 H494" stroke-width="2"/>
  <path class="fill" d="M508 70 L494 64 L494 76 Z"/>
  <path class="brand-line-2" d="M508 110 H471" stroke-width="2"/>
  <path class="fill" d="M457 110 L471 104 L471 116 Z"/>
  <path class="brand-line-2" d="M263 110 H226" stroke-width="2"/>
  <path class="fill" d="M212 110 L226 104 L226 116 Z"/>
  <text class="muted" x="360" y="170" text-anchor="middle">writing the variable moves the slider</text>
  <text class="muted" x="360" y="196" text-anchor="middle">a value settles after one round trip in whichever direction it started</text>
</svg>
<figcaption>A bound trait is an ordinary shared variable that happens to follow a control, and the control follows it back.</figcaption>
</figure>

<p><code>Variables.Set("threshold", 70L)</code> from a C# cell moves the slider and fires the author's <code>observe</code> callbacks. <code>#!bind --list</code> shows every trait currently projected, and <code>#!bind --remove &lt;name&gt;</code> stops one following its widget without deleting the variable.</p>
<p>Two limits. A trait holding another widget is refused, <code>layout</code> and <code>style</code> being the ones people try first, so bind one of that widget's own traits instead. And a projection lasts as long as the interpreter: restart Python and the name keeps the value it last had, until you re-run the <code>#!bind</code> line.</p>
<h2 id="reactive-execution-as-a-package">Reactive execution, as a package</h2>
<p>The new DAG Notebook showcase layout, published as <code>Verso.Showcase.DagNotebook</code>, scans each code cell for the variables it defines and the names it reads, draws chips linking producers to consumers, and runs a finished cell's dependents in dependency order. A bound control at the top of the graph cascades a run with nothing else clicked.</p>
<p>The scan is a heuristic rather than a proof, and the layout is honest about it: a variable written by more than one cell gets a warning chip and no edges, and cycle edges are excluded, so a cascade cannot loop. What I like most is that it is a package. Reactive execution is usually a property of the notebook tool you picked, and here it arrives as an extension.</p>
<h2 id="panels-beside-the-notebook">Panels beside the notebook</h2>
<p>An extension can now contribute a panel beside the notebook body by implementing <code>INotebookPanel</code>, toggled from the toolbar next to Verso's own Metadata, Extensions, Variables, Settings, and Cell Properties. A panel describes its content rather than drawing it, so it works on every host, including one that never touches a browser.</p>
<h2 id="five-languages">Five languages</h2>
<p>The interface ships in German, Spanish, Japanese, and Simplified Chinese alongside English, covering the notebook UI, the toolbar and panels, the kernel messages that land in cell output, and the command line tool. Choose one with <code>--language</code> or the <code>verso.language</code> setting. With neither, Verso follows <code>VERSO_LANGUAGE</code>, then the operating system's language, then English. <code>verso serve</code> negotiates per browser from <code>Accept-Language</code>.</p>
<p>Numbers, dates, and currency keep the machine's own formatting whichever language you choose, because translating those would change what a cell computes.</p>
<h2 id="from-the-community">From the community</h2>
<p>A kernel can now hand a value to registered formatter extensions before its own rendering, through a new <code>IExecutionContext.TryFormatAsync</code>. PowerShell is the first kernel wired to it, contributed by <a href="https://github.com/eosfor">@eosfor</a> (#93), who also sent two PowerShell samples that build an automatic differentiation engine from scratch and run it on handwritten digits (#92).</p>
<p>Native libraries now resolve to a package's own copy in preference to a same-named library elsewhere on the system, and against the running process's architecture and C library rather than the machine's. That fixes a class of failure where a plotting library died on a version mismatch it had nothing to do with, found and diagnosed by <a href="https://github.com/oxygen-dioxide">@oxygen-dioxide</a> (#91).</p>
<h2 id="upgrade-notes">Upgrade notes</h2>
<p>Code written after <code>#!import</code> in the same cell now runs. Everything below the directive used to be discarded silently, so a cell with boilerplate under an import will execute it for the first time on this release.</p>
<p>An extension built against Verso.Abstractions 1.2 needs a 1.2 or newer host. A host accepts an extension whose referenced minor version is at or below its own, and patch versions are never compared, which is why extensions kept loading across the 1.1 series.</p>
<p>Formatter extensions should check <code>context.MimeType</code> in <code>CanFormat</code>. Both new interface members have defaults, so nothing fails to compile, but a formatter that claims a value it cannot render in the requested MIME type is now stepped over.</p>
<h2 id="getting-it">Getting it</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>update<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
code<span class="w"> </span>--install-extension<span class="w"> </span>Datafication.verso-notebook
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso
</code></pre></div>

<p>The new <a href="https://www.versonotebooks.com/docs/guides/interactive-widgets.html">Interactive Widgets</a> guide covers <code>#!bind</code> and what separates a live widget from a static one, and <a href="https://www.versonotebooks.com/docs/extensions/panels.html">Panels</a> covers writing one. If a widget you rely on does not come back to life the way you expect, that is the report I want.</p>]]></content:encoded>
  </item>
  <item>
    <title>Python cells that run the Python you already have</title>
    <link>https://datafication.co/blog/2026/python-on-your-own-interpreter.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/python-on-your-own-interpreter.html</guid>
    <pubDate>Mon, 03 Aug 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Verso runs your Python cells in a separate process against the CPython already on your machine, which is what makes an interrupt work, a crash survivable, a restart real, and your virtual environment&#x27;s packages simply there.</description>
    <content:encoded><![CDATA[<p>A notebook has to answer one question before it can answer anything else: which Python? If you have ever watched a cell fail on an import and then found that package sitting right there in your virtual environment, you know why it matters.</p>
<p>Verso's answer, since 1.1.3, is the one you already have. Python cells run against a CPython installation on your machine, 3.8 or newer, in a separate process that Verso starts and supervises. We do not embed a Python of our own and we do not bundle one, so a cell sees the interpreter, virtual environment, and packages you would get from a terminal.</p>
<p>That sounds like a packaging detail, and it is the reason for everything else here. Running Python outside the notebook process is what lets you interrupt a cell, survive a native crash, and get a restart that genuinely clears imported module state.</p>
<h2 id="a-separate-process-on-purpose">A separate process, on purpose</h2>
<p>Verso starts the host and talks to it over a loopback connection. Completions and hover are computed in that same process, so the interpreter that runs your code is the one that answers questions about it.</p>
<figure class="fig">
<svg viewBox="0 0 720 310" role="img" aria-label="The notebook process on the left connected over a loopback socket to a supervised Python host process on the right, with three notes below covering interrupt, crash and restart">
  <rect class="box" x="12" y="16" width="266" height="118"/>
  <text class="label b" x="145" y="44" text-anchor="middle">Notebook process</text>
  <text class="muted" x="145" y="70" text-anchor="middle">C#, F# and other in-process kernels</text>
  <text class="muted" x="145" y="92" text-anchor="middle">shared variable store</text>
  <text class="muted" x="145" y="114" text-anchor="middle">Python host supervisor</text>

  <rect class="box on" x="442" y="16" width="266" height="118"/>
  <text class="label b" x="575" y="44" text-anchor="middle">Python host process</text>
  <text class="muted" x="575" y="70" text-anchor="middle">your CPython, 3.8 or newer</text>
  <text class="muted" x="575" y="92" text-anchor="middle">imports and session state</text>
  <text class="muted" x="575" y="114" text-anchor="middle">completions, hover, diagnostics</text>

  <path class="brand-line" d="M278 58 H430" stroke-width="2"/>
  <path class="fill" d="M442 58 L428 52 L428 64 Z"/>
  <text class="muted" x="360" y="48" text-anchor="middle">token handshake</text>

  <path class="brand-line-2" d="M442 100 H290" stroke-width="2"/>
  <path class="fill" d="M278 100 L292 94 L292 106 Z"/>
  <text class="muted" x="360" y="124" text-anchor="middle">NDJSON over loopback</text>

  <rect class="box soft" x="12" y="176" width="220" height="118"/>
  <text class="label b" x="122" y="204" text-anchor="middle">Interrupt</text>
  <text class="muted" x="122" y="230" text-anchor="middle">SIGINT on Unix,</text>
  <text class="muted" x="122" y="250" text-anchor="middle">CTRL_BREAK on Windows,</text>
  <text class="muted" x="122" y="270" text-anchor="middle">console ownership respected</text>

  <rect class="box soft" x="250" y="176" width="220" height="118"/>
  <text class="label b" x="360" y="204" text-anchor="middle">Crash</text>
  <text class="muted" x="360" y="230" text-anchor="middle">the connection recovers,</text>
  <text class="muted" x="360" y="250" text-anchor="middle">a peer reset is end of stream,</text>
  <text class="muted" x="360" y="270" text-anchor="middle">the notebook stays up</text>

  <rect class="box soft" x="488" y="176" width="220" height="118"/>
  <text class="label b" x="598" y="204" text-anchor="middle">Restart</text>
  <text class="muted" x="598" y="230" text-anchor="middle">a new process, so imported</text>
  <text class="muted" x="598" y="250" text-anchor="middle">modules are gone and shared</text>
  <text class="muted" x="598" y="270" text-anchor="middle">variables are untouched</text>
</svg>
<figcaption>The Python kernel is a supervised process on the other end of a loopback connection, and the three notes underneath are what that buys you.</figcaption>
</figure>

<p>A restart clears the variables from earlier Python cells, but not values other languages shared into the notebook's variable store, because the store lives on the notebook's side of that boundary. It also takes with it anything the interpreter started, so a background shell command ends there rather than outliving it.</p>
<h2 id="which-interpreter-you-get">Which interpreter you get</h2>
<p>Verso goes looking the first time a Python cell runs, and takes the first candidate that validates.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Order</th>
<th>Source</th>
<th>Where it comes from</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Explicit setting</td>
<td><code>verso.python.interpreterPath</code> in VS Code, or <code>PythonExecutable</code> when embedding the engine</td>
</tr>
<tr>
<td>2</td>
<td>Environment override</td>
<td>The <code>VERSO_PYTHON</code> environment variable</td>
</tr>
<tr>
<td>3</td>
<td>Session selection</td>
<td><code>#!python &lt;path&gt;</code> run earlier in this session</td>
</tr>
<tr>
<td>4</td>
<td>Active virtual environment</td>
<td><code>VIRTUAL_ENV</code></td>
</tr>
<tr>
<td>5</td>
<td>Active conda environment</td>
<td><code>CONDA_PREFIX</code></td>
</tr>
<tr>
<td>6</td>
<td>Workspace environment</td>
<td>A <code>.venv</code> or <code>venv</code> directory beside the notebook</td>
</tr>
<tr>
<td>7</td>
<td>Search path</td>
<td><code>python3</code> or <code>python</code> on <code>PATH</code></td>
</tr>
<tr>
<td>8</td>
<td>Well-known locations</td>
<td>Standard install directories for the platform</td>
</tr>
</tbody>
</table></div>
<p>A candidate has to answer a version probe and report CPython 3.8 or newer, so a broken entry near the top of the list does not stop a working one further down from being found, and if nothing validates the cell tells you what it searched. Activation is read from the environment, so activating a virtual environment before you launch your editor is usually the whole configuration step.</p>
<p><code>#!python</code> reports the interpreter in use, its version, what kind of environment it is, and which source selected it. <code>#!python --list</code> shows every interpreter that was discovered and where each was found. <code>#!python /usr/local/bin/python3.13</code> selects one and restarts the kernel.</p>
<p>A selection like that lasts for the session and is never written into the notebook file, because an absolute path is only true on the machine that produced it. To pin an interpreter for a run instead, <code>verso run</code>, <code>verso serve</code>, and <code>verso repl</code> all take <code>--python &lt;path&gt;</code>.</p>
<h2 id="packages-land-in-that-same-interpreter">Packages land in that same interpreter</h2>
<p>Because the cell runs in your interpreter, an install lands there too, and the import on the next line finds it without a restart.</p>
<div class="highlight"><pre><span></span><code><span class="ch">#!pip requests pandas&gt;=2</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">requests</span>
</code></pre></div>

<p><code>#!pip</code> runs before the rest of the cell and takes the same package specifiers and options pip does. If <code>uv</code> is on your <code>PATH</code>, Verso uses it. Both installers list every dependency they touch, so the cell reports a summary instead and saves that with the notebook. A failed install always prints in full, since that output is the only account of why it failed.</p>
<p>The second route is the import itself. A cell that imports something the environment does not have raises a "Package Install Required" dialog naming the distribution, the import that asked for it, and the interpreter it would go into. The distribution name is often not the import name, so <code>import cv2</code> offers <code>opencv-python</code>, <code>import PIL</code> offers <code>Pillow</code>, and <code>import sklearn</code> offers <code>scikit-learn</code>. Decline and the cell runs anyway, failing at the import as it would have without the offer. An import wrapped in <code>try</code> is never installed: that cell has already said it can run without the module.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th><code>verso.python.autoInstall</code></th>
<th>What happens</th>
<th>Where it applies</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>prompt</code></td>
<td>Asks first, listing the exact distributions and the environment. The default.</td>
<td>The editor</td>
</tr>
<tr>
<td><code>auto</code></td>
<td>Installs recognized distributions without asking, and reports a name it only guessed at rather than installing it.</td>
<td><code>verso run --auto-install</code></td>
</tr>
<tr>
<td><code>off</code></td>
<td>Never scans a cell's imports and never installs.</td>
<td><code>verso run</code></td>
</tr>
</tbody>
</table></div>
<p>Guessing means falling back to the import name as the distribution name, and that is the case to be careful about: a typo like <code>import pandsa</code> produces a plausible package name somebody may well have published. It is also why the command line differs from the editor, which matters if you <a href="https://datafication.co/blog/2026/notebooks-in-ci-with-parameters.html">run the same notebook in CI that you run at your desk</a>.</p>
<p>The third route is declaring requirements once, either in the Python kernel's Dependencies setting or inline at the top of the first cell you run, using the standard script metadata format. Those travel with the file, so a declared requirement may only name a package. An installer option such as <code>--index-url</code>, or a location to install from such as a URL or a filesystem path, is reported rather than installed. Where code gets fetched from is a decision for whoever runs the notebook, not for the file they opened.</p>
<p>Some interpreters, particularly ones from a Linux distribution's package manager, mark themselves externally managed, and pip refuses to install into them. Verso detects that and derives a small environment from the interpreter with access to its existing packages. It never does that to a virtual environment, which is yours to install into.</p>
<h2 id="widgets-draw">Widgets draw</h2>
<p>Libraries that render only as ipywidgets models hand the notebook a reference rather than a picture, which is why a cell ending in <code>plot.display()</code> used to print a model description. Those now draw: k3d, ipyleaflet, pythreejs, bqplot, and ipyvolume among them, as does anything built with <code>anywidget</code>.</p>
<div class="highlight"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">k3d</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">numpy</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">np</span>

<span class="n">plot</span> <span class="o">=</span> <span class="n">k3d</span><span class="o">.</span><span class="n">plot</span><span class="p">()</span>
<span class="n">plot</span> <span class="o">+=</span> <span class="n">k3d</span><span class="o">.</span><span class="n">points</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="mi">5000</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">),</span> <span class="n">point_size</span><span class="o">=</span><span class="mf">0.03</span><span class="p">)</span>
<span class="n">plot</span><span class="o">.</span><span class="n">display</span><span class="p">()</span>
</code></pre></div>

<p>The widget gets an auto-resized frame of its own, with theme tokens injected into it so it follows the host theme. Its state is saved with the notebook, so reopening the file draws it again without running the cell.</p>
<p>Two things to watch for. The state travels in the notebook, but the JavaScript that draws it comes from a public CDN, so a machine with no network draws an empty frame. And state has a size: a five thousand point scatter adds roughly 90 KB each time the cell is run and saved.</p>
<h2 id="next-to-a-jupyter-kernel">Next to a Jupyter kernel</h2>
<p>Jupyter's own documentation describes a model worth putting beside this one: kernels there are "programming language specific processes that run independently", and an open notebook "has exactly one interactive session connected to a kernel". The <code>nbformat</code> specification records that choice in the file, as a single <code>kernelspec</code> in notebook metadata.</p>
<figure class="fig">
<svg viewBox="0 0 720 250" role="img" aria-label="On the left, one notebook document connected to one kernel process. On the right, one Verso document over a shared variable store connected to in-process kernels and a separate Python host process">
  <text class="label b" x="30" y="26">One document, one kernel session</text>
  <rect class="box" x="30" y="44" width="280" height="52"/>
  <text class="label" x="170" y="76" text-anchor="middle">notebook document</text>
  <text class="muted" x="170" y="118" text-anchor="middle">kernelspec recorded in the file</text>
  <path class="brand-line" d="M170 130 V158" stroke-width="2"/>
  <path class="fill" d="M170 172 L164 158 L176 158 Z"/>
  <rect class="box on" x="30" y="174" width="280" height="52"/>
  <text class="label" x="170" y="206" text-anchor="middle">one kernel process, one language</text>

  <path class="line" d="M360 20 V236" stroke-width="1"/>

  <text class="label b" x="410" y="26">One document, several kernels</text>
  <rect class="box" x="410" y="44" width="280" height="52"/>
  <text class="label" x="550" y="76" text-anchor="middle">notebook document</text>
  <text class="muted" x="550" y="118" text-anchor="middle">one shared variable store</text>
  <path class="brand-line" d="M480 130 V158" stroke-width="2"/>
  <path class="fill" d="M480 172 L474 158 L486 158 Z"/>
  <path class="brand-line" d="M620 130 V158" stroke-width="2"/>
  <path class="fill" d="M620 172 L614 158 L626 158 Z"/>
  <rect class="box" x="410" y="174" width="130" height="52"/>
  <text class="muted" x="475" y="198" text-anchor="middle">C#, F# and others</text>
  <text class="muted" x="475" y="216" text-anchor="middle">in the engine process</text>
  <rect class="box on" x="560" y="174" width="130" height="52"/>
  <text class="muted" x="625" y="198" text-anchor="middle">Python host</text>
  <text class="muted" x="625" y="216" text-anchor="middle">process</text>
</svg>
<figcaption>Left, the model Jupyter's documentation describes. Right, Verso: Python is a separate process here too, but it is one kernel among several behind a single document.</figcaption>
</figure>

<p>The difference is where the choices live. Verso's Python process is one of eight kernels reading and writing <a href="https://datafication.co/blog/2026/one-variable-store-eight-languages.html">one variable store</a>, so a value set in C# is readable in Python with no hand-off. And the interpreter is picked per session and per machine, never saved into the file.</p>
<h2 id="two-notes-if-you-are-upgrading">Two notes if you are upgrading</h2>
<p>Python cells now need CPython 3.8 or newer installed on the machine. Where none is found, a Python cell reports what it searched instead of failing silently.</p>
<p><code>PythonKernelOptions.PythonDll</code> is ignored. The property remains, so nothing fails to compile, and a session that sets it says once that it has no effect.</p>
<p>Point a cell at the environment you already have and see whether it picks the interpreter you expected. If it does not, <code>#!python --list</code> will show you why. The rest is in the <a href="https://www.versonotebooks.com/docs/guides/python-interpreters.html">Python Interpreters</a> and <a href="https://www.versonotebooks.com/docs/guides/python-packages.html">Python Packages</a> guides.</p>]]></content:encoded>
  </item>
  <item>
    <title>Motus 1.0.15: attach to a browser you did not start</title>
    <link>https://datafication.co/blog/2026/motus-1-0-15.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/motus-1-0-15.html</guid>
    <pubDate>Wed, 29 Jul 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Point Motus at a browser that is already running, treat a cross-origin frame like any other frame, split a suite across agents, and tell a flaky test from a broken one.</description>
    <content:encoded><![CDATA[<p>Motus 1.0.15 is on NuGet. Most of what went into it came out of the same complaint: your test needs a browser session it does not fully own. Somebody else started the browser. The browser put an iframe in a process of its own. The suite is spread across four agents that never talk to each other. Or the run is just flaky, and saying so is more useful than a red X.</p>
<h2 id="what-changed">What changed</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Area</th>
<th>What shipped</th>
</tr>
</thead>
<tbody>
<tr>
<td>Attach</td>
<td><code>MotusLauncher.ConnectAsync</code>, over a WebSocket URL or an HTTP debugging endpoint</td>
</tr>
<tr>
<td>Ownership</td>
<td><code>OwnsProcess</code>, <code>DisconnectAsync</code>, a <code>CloseAsync</code> that spares a browser it did not start</td>
</tr>
<tr>
<td>Frames</td>
<td>A frame in its own renderer process is an ordinary <code>IFrame</code>, at any depth</td>
</tr>
<tr>
<td>Execution worlds</td>
<td><code>EvaluateOptions { World = ExecutionWorld.Isolated }</code> on <code>IFrame.EvaluateAsync</code></td>
</tr>
<tr>
<td>Frame events</td>
<td><code>FrameAttached</code>, <code>FrameNavigated</code>, <code>FrameDetached</code>, <code>IsDetached</code></td>
</tr>
<tr>
<td>Sharding</td>
<td><code>motus run --shard &lt;i&gt;/&lt;n&gt;</code> and <code>motus shard merge --expect &lt;n&gt;</code></td>
</tr>
<tr>
<td>Flaky and quarantine</td>
<td><code>--retries</code>, <code>--retry-policy</code>, <code>--fail-on-flaky</code>, <code>--quarantine</code>, <code>--flaky-history</code></td>
</tr>
<tr>
<td>Crash recovery</td>
<td>A dead browser is replaced and its cached pages invalidated</td>
</tr>
<tr>
<td>Pinning</td>
<td><code>motus install --revision</code>, <code>launch.executablePath</code>, <code>MOTUS_EXECUTABLE_PATH</code></td>
</tr>
<tr>
<td>Cursor</td>
<td><code>ContextOptions.ShowCursor</code> and <code>NaturalMouseMotion</code></td>
</tr>
<tr>
<td>MCP server</td>
<td><code>--connect</code>, <code>browser_attach</code>, <code>browser_status</code>, <code>frame_list</code>, <code>frame_select</code></td>
</tr>
<tr>
<td>Documentation</td>
<td>XML docs in every package, four guides, a CLI reference</td>
</tr>
</tbody>
</table></div>
<h2 id="a-browser-you-already-have-running">A browser you already have running</h2>
<p>Sometimes the browser you want to drive is already open. You signed into a profile by hand, or CI keeps a warm browser between runs, or the thing you are testing is an application built on an embedded Chromium runtime that exposes a debugging port. Until now Motus had to start its own.</p>
<p><code>ConnectAsync</code> takes the CDP WebSocket URL or the HTTP debugging endpoint, and comes back once it has adopted the contexts and pages that were already open.</p>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">IBrowser</span><span class="w"> </span><span class="n">browser</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">MotusLauncher</span><span class="p">.</span><span class="n">ConnectAsync</span><span class="p">(</span><span class="s">&quot;http://127.0.0.1:9222&quot;</span><span class="p">);</span>

<span class="k">foreach</span><span class="w"> </span><span class="p">(</span><span class="n">IBrowserContext</span><span class="w"> </span><span class="n">context</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">browser</span><span class="p">.</span><span class="n">Contexts</span><span class="p">)</span>
<span class="k">foreach</span><span class="w"> </span><span class="p">(</span><span class="n">IPage</span><span class="w"> </span><span class="n">page</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">context</span><span class="p">.</span><span class="n">Pages</span><span class="p">)</span>
<span class="w">    </span><span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="s">$&quot;{await page.TitleAsync()} - {page.Url}&quot;</span><span class="p">);</span>
</code></pre></div>

<p>Anything that opens or closes after that gets tracked too, so a long session keeps up instead of describing the moment you connected. <code>ConnectOptions</code> carries <code>AdoptExistingTargets</code>, <code>SlowMo</code> and a <code>Timeout</code> that bounds the connect itself.</p>
<figure class="fig">
<svg viewBox="0 0 720 180" role="img" aria-label="A .NET process attached over a WebSocket to the debugging port of a running browser">
  <rect class="box on" x="8" y="46" width="180" height="84"/>
  <text class="label b" x="98" y="80" text-anchor="middle">Your .NET process</text>
  <text class="muted" x="98" y="104" text-anchor="middle">ConnectAsync</text>
  <path class="brand-line" d="M188 88 H288" stroke-width="2"/>
  <text class="muted" x="238" y="78" text-anchor="middle">WebSocket</text>
  <rect class="soft" x="288" y="46" width="126" height="84"/>
  <text class="label b" x="351" y="82" text-anchor="middle">Debugging port</text>
  <text class="muted" x="351" y="106" text-anchor="middle">127.0.0.1:9222</text>
  <path class="brand-line" d="M414 88 H498" stroke-width="2"/>
  <rect class="box" x="498" y="16" width="214" height="144"/>
  <text class="label b" x="605" y="42" text-anchor="middle">Running browser</text>
  <rect class="box on" x="518" y="70" width="174" height="36"/>
  <text class="label" x="605" y="93" text-anchor="middle">Contexts already open</text>
</svg>
<figcaption>Attach mode. The debugging port is the only seam, and the contexts outlive the connection.</figcaption>
</figure>

<p>One limit worth knowing up front: <code>ConnectAsync</code> speaks the Chrome DevTools Protocol, so the endpoint has to be a Chromium one.</p>
<p>Ownership is something you can check. On a browser you attached to, <code>CloseAsync</code> closes the contexts Motus created and disconnects, and leaves the process running. <code>OwnsProcess</code> tells you which case you are in. The <a href="https://motustesting.com/docs/guides/attaching-to-a-running-browser.html">attach guide</a> is blunt about the security side: attaching is the same as sitting down at the machine, and there is no narrower permission to hand out.</p>
<h2 id="frames-the-browser-puts-in-their-own-process">Frames the browser puts in their own process</h2>
<p>If you have ever fought a cross-origin iframe, you know the browser renders it in a separate process, which gives it its own protocol target, session and execution contexts. Motus now handles that for you. A frame like that is an ordinary <code>IFrame</code> in <code>page.Frames</code>, at any depth, and locators, clicks, evaluation and navigation all go to the session that owns it.</p>
<p><code>IFrame.EvaluateAsync</code> also picked up an overload that chooses the frame's main world or an isolated one. The main world is still the default, and the trap here runs the other way from what you would expect: an isolated world shares the document and nothing else, so reading a framework handle off <code>window</code> there comes back <code>undefined</code>, which looks exactly like an application that never loaded.</p>
<h2 id="splitting-a-suite-and-putting-it-back-together">Splitting a suite and putting it back together</h2>
<p>If a suite takes too long on one agent, you want it on four. Splitting it is the easy half. Trusting the merged result is the hard half.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>run<span class="w"> </span>bin/Release/net8.0/MyTests.dll<span class="w"> </span>--shard<span class="w"> </span><span class="m">1</span>/4<span class="w"> </span>--reporter<span class="w"> </span>junit:results.shard-1.xml
motus<span class="w"> </span>shard<span class="w"> </span>merge<span class="w"> </span>results.shard-*.xml<span class="w"> </span>--output<span class="w"> </span>junit:results.xml<span class="w"> </span>--expect<span class="w"> </span><span class="m">4</span>
</code></pre></div>

<p>Each shard works out its own slice from the sorted test set and its shard coordinates, so no agent has to coordinate with any other. Two things to watch. Shards balance by count, not by how long a test takes, so a suite dominated by one slow test is not rescued by adding agents. And a shard that never ran leaves you a smaller report that is entirely green, which is the failure <code>--expect</code> exists to catch: every shard stamps its coordinates into its result file, and the merge fails when an index is missing or shows up twice.</p>
<h2 id="a-flaky-test-is-not-a-broken-one">A flaky test is not a broken one</h2>
<p>Retries have a bad reputation because they usually hide something. These try not to.</p>
<p><code>--retry-policy transient</code> is the default, and it only re-runs a failure caused by the browser disconnecting. <code>flake</code> re-runs any failure, and a test that passes on the second try is reported as flaky rather than green. A flaky test still passes the run with a warning unless you say <code>--fail-on-flaky</code>. For the ones you already know about, <code>[Quarantine]</code> keeps a test running and reported without gating the build, <code>--quarantine &lt;file&gt;</code> does the same from a list of names, and <code>--flaky-history &lt;file&gt;</code> turns one bad morning into a trend you can look at.</p>
<h2 id="behavior-changes">Behavior changes</h2>
<p>Seven of them, and each is here because it would otherwise read as a regression.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Change</th>
<th>What to do</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>CloseAsync</code> spares a browser Motus did not start</td>
<td>End the process yourself. <code>DisposeAsync</code> will not, and never did</td>
</tr>
<tr>
<td>A locator from <code>IFrame</code> resolves inside that frame</td>
<td>Use <code>page.Locator(...)</code> where you meant the main frame</td>
</tr>
<tr>
<td><code>BoundingBoxAsync</code> answers in page coordinates</td>
<td>Stop correcting for the frame's offset by hand</td>
</tr>
<tr>
<td><code>ScreenshotAsync</code> honors <code>ScreenshotOptions.Clip</code></td>
<td>A clip that was dropped now captures the region</td>
</tr>
<tr>
<td>A lost browser is reported, not a verdict</td>
<td>Catch <code>MotusTargetClosedException</code> as well</td>
</tr>
<tr>
<td>MCP <code>evaluate</code> returns its value under <code>result</code></td>
<td>Read <code>result</code>. Hand-wrapped expressions can stop</td>
</tr>
<tr>
<td>MCP nullable parameters are no longer required</td>
<td>Nothing. Explicit nulls still work</td>
</tr>
</tbody>
</table></div>
<h2 id="getting-it">Getting it</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus
dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
</code></pre></div>

<p>All ten packages are at 1.0.15 on <a href="https://www.nuget.org/packages/Motus">nuget.org</a>. There are new guides for <a href="https://motustesting.com/docs/guides/attaching-to-a-running-browser.html">attaching</a>, <a href="https://motustesting.com/docs/guides/frames-and-iframes.html">frames</a>, <a href="https://motustesting.com/docs/guides/sharding.html">sharding</a> and <a href="https://motustesting.com/docs/guides/flaky-tests-and-quarantine.html">flaky tests</a>, plus a <a href="https://motustesting.com/docs/reference/cli.html">CLI reference</a>. If you drive Motus from an agent, <code>motus mcp --connect</code> starts the server already attached, and <a href="https://datafication.co/blog/2026/hand-the-browser-to-an-agent.html">the MCP walk-through</a> covers the rest. Try attach mode against something you already have running, and tell me what surprises you.</p>]]></content:encoded>
  </item>
  <item>
    <title>Put a notebook runtime inside your own .NET app</title>
    <link>https://datafication.co/blog/2026/embedding-the-verso-engine.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/embedding-the-verso-engine.html</guid>
    <pubDate>Tue, 14 Jul 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>One package, a few lines of setup, and your own application gets multi-language execution, a shared variable store, an extension host, and a layout manager.</description>
    <content:encoded><![CDATA[<p>This one is for the case where you want the part of Verso that runs the code, without the notebook wrapped around it. The engine is a headless .NET library. It has no UI dependencies, no Blazor, and no knowledge of VS Code, and the editor, the command line tool, and the VS Code host are all consumers of the same public API. That means anything they do is available to your own application: running C# or Python from a string, sharing state between languages, loading an extension somebody else wrote.</p>
<p>Fair warning, this is an advanced scenario. If what you want is a notebook, use the notebook. This guide is for the case where a notebook runtime belongs inside something else: a rules engine that has to evaluate user-authored expressions, an internal tool that reports on data using code its users write, a test harness that executes documented examples.</p>
<h2 id="before-you-start">Before you start</h2>
<p>You need .NET 8 or later and one package:</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso
</code></pre></div>

<p>That is the engine, and it is not the editor. The Razor components live in a separate assembly and none of them come along for the ride.</p>
<h2 id="the-parts">The parts</h2>
<figure class="fig">
<svg viewBox="0 0 720 300" role="img" aria-label="Your application creates a Scaffold, which owns the kernel registry, the execution pipeline, the variable store, and the extension host. A separate dashed box shows what the visual hosts and the CLI add on top.">
  <rect class="box on" x="250" y="10" width="220" height="40" rx="2" stroke-width="1"/>
  <text class="label b" x="360" y="35" text-anchor="middle">Your .NET application</text>
  <path class="brand-line" d="M360 50 V72" stroke-width="1.5"/>
  <path class="brand" d="M360 80 L355 70 L365 70 Z"/>

  <rect class="box on" x="60" y="82" width="600" height="130" rx="2" stroke-width="1"/>
  <text class="label b" x="76" y="104">Scaffold</text>

  <rect class="soft" x="76" y="116" width="124" height="76" rx="2"/>
  <text class="label" x="138" y="146" text-anchor="middle">kernel registry</text>
  <text class="muted" x="138" y="168" text-anchor="middle">LAZY, THREAD SAFE</text>

  <rect class="soft" x="224" y="116" width="124" height="76" rx="2"/>
  <text class="label" x="286" y="146" text-anchor="middle">execution pipeline</text>
  <text class="muted" x="286" y="168" text-anchor="middle">ONE PER CELL</text>

  <rect class="soft" x="372" y="116" width="124" height="76" rx="2"/>
  <text class="label" x="434" y="146" text-anchor="middle">variable store</text>
  <text class="muted" x="434" y="168" text-anchor="middle">NO ISOLATION</text>

  <rect class="soft" x="520" y="116" width="124" height="76" rx="2"/>
  <text class="label" x="582" y="146" text-anchor="middle">extension host</text>
  <text class="muted" x="582" y="168" text-anchor="middle">LOADS EXTENSIONS</text>

  <rect class="box" x="60" y="236" width="600" height="52" rx="2" stroke-width="1" stroke-dasharray="4 3"/>
  <text class="label" x="76" y="260">Added by the hosts, not by the engine</text>
  <text class="muted" x="76" y="278">INOTEBOOKSERVICE IN VERSO.BLAZOR.SHARED, OR THE CLI DRIVING SCAFFOLD DIRECTLY</text>
</svg>
<figcaption>The engine is the solid box. Everything a front end adds sits outside it.</figcaption>
</figure>

<p><code>Scaffold</code> is the session. It owns the in-memory notebook model, the kernel registry, execution dispatch, and the theme, layout, and settings subsystems. <code>ExtensionHost</code> finds and loads extensions, and that is where the kernels come from in the first place, so the two always show up together.</p>
<h2 id="a-complete-program">A complete program</h2>
<p>Here is the whole thing. Read it once, then I will walk back through the parts that are easy to get wrong.</p>
<div class="highlight"><pre><span></span><code><span class="k">using</span><span class="w"> </span><span class="nn">Verso</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Verso.Abstractions</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Verso.Extensions</span><span class="p">;</span>

<span class="c1">// 1. Load the built-ins: kernels, themes, layouts, formatters.</span>
<span class="kt">var</span><span class="w"> </span><span class="n">extensionHost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ExtensionHost</span><span class="p">();</span>
<span class="k">await</span><span class="w"> </span><span class="n">extensionHost</span><span class="p">.</span><span class="n">LoadBuiltInExtensionsAsync</span><span class="p">();</span>

<span class="c1">// 2. Open a session over a blank notebook.</span>
<span class="kt">var</span><span class="w"> </span><span class="n">scaffold</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">Scaffold</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">NotebookModel</span><span class="p">(),</span><span class="w"> </span><span class="n">extensionHost</span><span class="p">,</span><span class="w"> </span><span class="n">filePath</span><span class="p">:</span><span class="w"> </span><span class="k">null</span><span class="p">);</span>

<span class="c1">// 3. Subsystems come after the extensions are loaded, never before.</span>
<span class="n">scaffold</span><span class="p">.</span><span class="n">InitializeSubsystems</span><span class="p">();</span>

<span class="c1">// 4. Whatever the host sets is visible to every kernel.</span>
<span class="n">scaffold</span><span class="p">.</span><span class="n">Variables</span><span class="p">.</span><span class="n">Set</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">,</span><span class="w"> </span><span class="mf">0.95</span><span class="p">);</span>

<span class="c1">// 5. Run code that never becomes a cell.</span>
<span class="kt">var</span><span class="w"> </span><span class="n">outputs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">scaffold</span><span class="p">.</span><span class="n">ExecuteCodeCaptureOutputsAsync</span><span class="p">(</span><span class="s">&quot;1 + 41&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">language</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;csharp&quot;</span><span class="p">);</span>
<span class="k">foreach</span><span class="w"> </span><span class="p">(</span><span class="kt">var</span><span class="w"> </span><span class="n">output</span><span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="n">outputs</span><span class="p">)</span>
<span class="w">    </span><span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="n">output</span><span class="p">.</span><span class="n">Content</span><span class="p">);</span>

<span class="c1">// 6. Or build a real notebook and watch it run.</span>
<span class="n">scaffold</span><span class="p">.</span><span class="n">OnCellExecuted</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="n">cellId</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="p">{</span>
<span class="w">    </span><span class="kt">var</span><span class="w"> </span><span class="n">cell</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">scaffold</span><span class="p">.</span><span class="n">GetCell</span><span class="p">(</span><span class="n">cellId</span><span class="p">);</span>
<span class="w">    </span><span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="s">$&quot;{cell?.Language}: {cell?.LastStatus} in {cell?.LastElapsed}&quot;</span><span class="p">);</span>
<span class="p">};</span>

<span class="n">scaffold</span><span class="p">.</span><span class="n">AddCell</span><span class="p">(</span><span class="s">&quot;code&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">language</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;csharp&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">source</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;var score = 0.97;&quot;</span><span class="p">);</span>
<span class="n">scaffold</span><span class="p">.</span><span class="n">AddCell</span><span class="p">(</span><span class="s">&quot;code&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">language</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;csharp&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="n">source</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;score &gt; Variables.Get&lt;double&gt;(\&quot;threshold\&quot;)&quot;</span><span class="p">);</span>

<span class="k">await</span><span class="w"> </span><span class="n">scaffold</span><span class="p">.</span><span class="n">ExecuteAllAsync</span><span class="p">();</span>

<span class="k">await</span><span class="w"> </span><span class="n">scaffold</span><span class="p">.</span><span class="n">DisposeAsync</span><span class="p">();</span>
</code></pre></div>

<p>That is the whole surface for a first program. Everything below is what each step is doing.</p>
<h2 id="why-the-order-matters">Why the order matters</h2>
<p><code>InitializeSubsystems()</code> asks the extension host for themes, layouts, and settable extensions, and builds the subsystems out of what it finds. Call it before the extensions are loaded and it builds them out of nothing. Call it after, the way step 3 does, and it also subscribes to the host's load and status events, so the subsystems refresh on their own when an extension is added or toggled later.</p>
<p>Until you call it, the <code>ThemeEngine</code>, <code>LayoutManager</code>, and <code>SettingsManager</code> properties are null. That is usually the first surprise.</p>
<h2 id="three-ways-to-execute">Three ways to execute</h2>
<p><code>ExecuteCodeCaptureOutputsAsync(code, language)</code> runs a string and hands back its outputs. Nothing is added to the notebook. This is the one to reach for when the notebook model is an implementation detail and all you want out of the engine is an evaluator.</p>
<p><code>AddCell</code> plus <code>ExecuteAllAsync()</code> is a real notebook run. <code>ExecuteAllAsync</code> resets the kernels first, so a full run behaves as though the file had just been opened.</p>
<p><code>ExecuteCellAsync(cellId)</code> runs one cell without resetting anything, which is what an editor does when you press Run on a single cell. Both of the cell methods also return <code>ExecutionResult</code> values directly, which is simpler than subscribing when you only care about the call you just made.</p>
<h2 id="one-store-every-language">One store, every language</h2>
<p>Every kernel in the session reads and writes the same variable store, and your host code reads and writes it too:</p>
<div class="highlight"><pre><span></span><code><span class="n">scaffold</span><span class="p">.</span><span class="n">Variables</span><span class="p">.</span><span class="n">Set</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">,</span><span class="w"> </span><span class="mf">0.95</span><span class="p">);</span>
<span class="kt">var</span><span class="w"> </span><span class="n">back</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">scaffold</span><span class="p">.</span><span class="n">Variables</span><span class="p">.</span><span class="n">Get</span><span class="o">&lt;</span><span class="kt">double</span><span class="o">&gt;</span><span class="p">(</span><span class="s">&quot;threshold&quot;</span><span class="p">);</span>
</code></pre></div>

<p>There is no per-kernel isolation, and that is the point of the thing. A value your application sets before a run is a plain variable inside a C# cell, and the same name is readable from Python or SQL in the same session.</p>
<p>The built-in C# kernel is in the <code>Verso</code> package. Other languages arrive as their own packages, and because <code>LoadBuiltInExtensionsAsync</code> also scans assemblies sitting beside yours that reference <code>Verso.Abstractions</code>, adding one is a package reference and nothing else:</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso.FSharp
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso.Ado
</code></pre></div>

<h2 id="watching-it-happen">Watching it happen</h2>
<p>Subscribe when you need to drive progress in your own UI. The cell events carry only the cell's id, so look the cell up and read what was stamped on it:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Event</th>
<th>Signature</th>
<th>Fires when</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>OnCellExecuting</code></td>
<td><code>Action&lt;Guid&gt;</code></td>
<td>A cell begins executing</td>
</tr>
<tr>
<td><code>OnCellExecuted</code></td>
<td><code>Action&lt;Guid&gt;</code></td>
<td>A cell finishes, with <code>ExecutionCount</code>, <code>LastElapsed</code>, and <code>LastStatus</code> already set</td>
</tr>
<tr>
<td><code>OnCellOutputUpdated</code></td>
<td><code>Action&lt;Guid&gt;</code></td>
<td>A running cell appends a live output</td>
</tr>
<tr>
<td><code>OnKernelRestarting</code> / <code>OnKernelRestarted</code></td>
<td><code>Action&lt;string?&gt;</code></td>
<td>A kernel restart starts and completes</td>
</tr>
<tr>
<td><code>OnKernelRestartFailed</code></td>
<td><code>Action&lt;string?, Exception&gt;</code></td>
<td>A kernel restart throws</td>
</tr>
</tbody>
</table></div>
<p>The kernel events carry the language name rather than a cell id, because a restart is not about any one cell.</p>
<h2 id="loading-somebody-elses-extension">Loading somebody else's extension</h2>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="n">extensionHost</span><span class="p">.</span><span class="n">LoadFromAssemblyAsync</span><span class="p">(</span><span class="s">&quot;./MyExtension.dll&quot;</span><span class="p">);</span>
</code></pre></div>

<p>A third-party assembly loads into a collectible <code>AssemblyLoadContext</code> that isolates its dependencies while handing it the host's own <code>Verso.Abstractions</code> types, so the interfaces match. Built-ins load into the default context instead, since they ship alongside the host.</p>
<h2 id="cleaning-up">Cleaning up</h2>
<p><code>Scaffold</code> is <code>IAsyncDisposable</code>. Disposing it disposes every registered kernel, clears the internal registries, and disposes the extension host, which unloads the extensions.</p>
<p>In a long-lived process, dispose per session rather than holding one open forever. Kernels hold compiler state and, for some languages, a separate process, so a session nobody closes is a session that never hands any of that back.</p>
<h2 id="where-the-engine-stops">Where the engine stops</h2>
<p>The boundary between the engine and a user interface is <code>INotebookService</code>, and it lives in <code>Verso.Blazor.Shared</code>, not in the engine. The browser host implements it in process, the VS Code host implements it over JSON-RPC, and the CLI's headless runner does not implement it at all: it drives <code>Scaffold</code> directly with no UI boundary. If you are embedding, you are in the third position.</p>
<p>Two host capabilities are worth knowing about, because kernels ask for them through public abstractions. <code>WriteOutputAsync</code> streams an output while a cell is still running, which is how PowerShell's host writes reach a cell before it finishes. <code>RequestInputAsync</code> asks for a single value from the user, which is how <code>Read-Host</code> works in the editor. A host that does not support them keeps the default behavior, which throws <code>NotSupportedException</code>, and kernels are written to treat both as optional, so implement them when your front end can and skip them when it cannot.</p>
<div class="admonition note">
<p class="admonition-title">Reference material</p>
<p>The <a href="https://www.versonotebooks.com/docs/guides/embedding-the-engine.html">embedding guide</a> is the short version of this page, and the engine and execution pipeline architecture pages go deeper into what happens between <code>ExecuteCellAsync</code> and an output.</p>
</div>
<p>If the last release put <a href="https://datafication.co/blog/2026/verso-1-1-custom-layouts-and-a-marketplace.html">layouts and a marketplace</a> in reach of an extension author, this is the same engine pointed the other way, with your application as the front end. Build something on it and tell me where the API fights you.</p>]]></content:encoded>
  </item>
  <item>
    <title>Verso 1.1: layouts you can ship, and a marketplace inside the notebook</title>
    <link>https://datafication.co/blog/2026/verso-1-1-custom-layouts-and-a-marketplace.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/verso-1-1-custom-layouts-and-a-marketplace.html</guid>
    <pubDate>Mon, 06 Jul 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Verso 1.1.1 lets a package replace the notebook surface itself, puts a NuGet marketplace inside the Extensions panel, and ships three showcase layouts built on the same API you get.</description>
    <content:encoded><![CDATA[<p>Verso 1.1.1 is out. The part I am happiest about is that the notebook surface itself is now an extension point: a package can replace what you see when you open a file, and packages are found, installed, and trusted without you leaving the notebook.</p>
<h2 id="what-changed">What changed</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Area</th>
<th>1.1.1</th>
</tr>
</thead>
<tbody>
<tr>
<td>Layouts</td>
<td><code>ILayoutEngine</code> gains two renderer modes, plus <code>ILayoutInteractionHandler</code> and <code>ILayoutLifecycleHandler</code></td>
</tr>
<tr>
<td>Capabilities</td>
<td>Insert, delete, reorder, edit, resize, execute, multi-select, declared per layout</td>
</tr>
<tr>
<td>Marketplace</td>
<td>A pane that searches NuGet and installs or uninstalls in place</td>
</tr>
<tr>
<td>Trust</td>
<td>Pinned to the resolved version, re-prompted on drift, per notebook</td>
</tr>
<tr>
<td>Required extensions</td>
<td>Declared in metadata, loaded before the first render</td>
</tr>
<tr>
<td>Showcase</td>
<td>Grid Studio, Form Studio, and Image Studio on NuGet</td>
</tr>
<tr>
<td>VS Code</td>
<td>Runtime detection and an offer to install a missing .NET runtime</td>
</tr>
</tbody>
</table></div>
<h2 id="two-ways-to-draw-a-layout">Two ways to draw a layout</h2>
<p>A layout decides what a notebook looks like when you open it, and there are two ways to build one.</p>
<p>An inline layout writes the arrangement and hands it to the host, and the host drops your real cells into the places the layout marked for them. Those are live cells: they still execute, still stream output, still edit. You write the arrangement, the host keeps the cells.</p>
<p>An isolated layout ships a JavaScript bundle that the host runs in a sandboxed frame. It draws its own content, keeps its own DOM and CSS, and has no network inside the frame, so anything it needs fetched gets fetched kernel side.</p>
<p>The trade is isolation against reuse. Inline is C# only, inherits the host CSS, and keeps the host's cells. Isolated needs a bundle and gets a clean room. Toolbars and grids usually want the first, visualizations and custom editors the second.</p>
<p>Either way, interactions come back through the same handler, so your code does not need to know which mode drew the screen. Both also take their colors from the host's CSS variables instead of asking what the theme is, which is why a layout recolors correctly in the browser and in VS Code alike.</p>
<h2 id="a-marketplace-that-is-just-nuget">A marketplace that is just NuGet</h2>
<p>The Extensions panel now has a search box above the extensions you have loaded. Type a package name, pick a version, install. There is no Verso feed behind it: the search runs against the NuGet sources configured on the machine, private ones included. Packages unpack one folder per version into <code>~/.verso/extensions/&lt;package-id&gt;/&lt;version&gt;/</code> or <code>%APPDATA%\verso\extensions\&lt;package-id&gt;\&lt;version&gt;\</code>.</p>
<p>An extension is executable code, so the first load asks you. Trust is pinned to the exact version you approved, which means a notebook asking for a different version prompts again instead of quietly trusting the new build.</p>
<p>A notebook can declare what it needs:</p>
<div class="highlight"><pre><span></span><code><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;extensions&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;Contoso.Verso.ChartLayout@1.2.0&quot;</span><span class="p">],</span>
<span class="w">    </span><span class="nt">&quot;optional&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Those load before the notebook renders, so a layout the file depends on is there on the first paint. If one cannot be found or approved, the notebook still opens, with a notice, and falls back to a built-in layout. The on/off toggle beside a loaded extension stays local and does not travel with the file.</p>
<h2 id="three-layouts-that-prove-the-api">Three layouts that prove the API</h2>
<p>We published three of them, each referencing <code>Verso.Abstractions</code> and nothing else, each isolated:</p>
<ul>
<li><strong>Grid Studio</strong> (<code>Verso.Showcase.GridStudio</code>) shows a kernel <code>DataBlock</code> as an editable spreadsheet and commits every edit back to the same variable with the column types preserved. A <code>System.Data.DataTable</code>, such as a shared SQL result, renders read-only on purpose. Bundles Jspreadsheet CE (MIT).</li>
<li><strong>Form Studio</strong> (<code>Verso.Showcase.FormStudio</code>) is a dashboard you build by dragging. Sliders, dropdowns, toggles, and charts drop onto a canvas, each input writes a kernel variable by name, and with auto-run on the notebook recomputes and pushes fresh data back. Charts are Chart.js (MIT).</li>
<li><strong>Image Studio</strong> (<code>Verso.Showcase.ImageStudio</code>) turns the same file into a layered image compositor. A procedural layer hands its drawing off to a kernel variable, so re-running a code cell repaints it. Bundles nothing.</li>
</ul>
<p>They make the point from <a href="https://datafication.co/blog/2026/every-feature-is-an-extension.html">if a built-in feature needs a private API</a>: the built-in Notebook, Dashboard, and Presentation layouts run on the same interfaces.</p>
<h2 id="getting-it">Getting it</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>update<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
code<span class="w"> </span>--install-extension<span class="w"> </span>Datafication.verso-notebook
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso
</code></pre></div>

<p>If you write extensions, the template has a new switch. <code>dotnet new verso-extension --includeLayout</code> scaffolds an inline layout with cell slots, interaction routing, metadata persistence, and themed CSS.</p>
<p>Also in 1.1.1: export moved to an icon-only toolbar behind a per-host download that works from inside an isolated frame, and publishing Python variables with cyclic or deeply nested structures no longer crashes the host.</p>
<div class="admonition warning">
<p class="admonition-title">Codespaces and Safari</p>
<p>The editor now works in GitHub Codespaces from Chromium-based browsers such as Chrome and Edge. Safari in the Codespaces browser client remains a documented limitation.</p>
</div>
<p>Thanks to <a href="https://github.com/fluentfoundation">@fluentfoundation</a> and <a href="https://github.com/fiVISION">@fiVISION</a> for sponsoring this work. The full notes are on the <a href="https://www.versonotebooks.com/release-notes.html">release notes page</a>. Build a layout, and if the API will not let you do something you expected, that is the report I most want to see.</p>]]></content:encoded>
  </item>
  <item>
    <title>Hand the browser to an agent: the Motus MCP server, end to end</title>
    <link>https://datafication.co/blog/2026/hand-the-browser-to-an-agent.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/hand-the-browser-to-an-agent.html</guid>
    <pubDate>Wed, 10 Jun 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Register motus mcp with an MCP client, snapshot a page, act on the elements the snapshot names, and use the parts a browser-only server does not have: WCAG audits, Core Web Vitals, traces, and generated page objects.</description>
    <content:encoded><![CDATA[<p>Motus 1.0.13 added a Model Context Protocol server, and 1.0.14 added coordinate input for surfaces the DOM cannot describe, plus page video recording. Put those together and an agent drives a real browser through the same engine your test suite uses.</p>
<p>Start with what you do not install. There is no <code>Motus.Mcp</code> package on NuGet. The server is a verb on the CLI tool: <code>motus mcp</code>.</p>
<figure class="fig">
<svg viewBox="0 0 720 260" role="img" aria-label="An MCP client on the left, the motus mcp server in the middle, and the browser on the right, with tool calls going right and snapshots coming back left">
  <rect class="box" x="12" y="72" width="168" height="110"/>
  <text class="label b" x="96" y="104" text-anchor="middle">MCP client</text>
  <text class="muted" x="96" y="130" text-anchor="middle">Claude Code,</text>
  <text class="muted" x="96" y="150" text-anchor="middle">Claude Desktop,</text>
  <text class="muted" x="96" y="170" text-anchor="middle">or any other</text>

  <rect class="box on" x="250" y="48" width="220" height="158"/>
  <text class="label b" x="360" y="80" text-anchor="middle">motus mcp</text>
  <text class="muted" x="360" y="106" text-anchor="middle">a verb on the CLI tool</text>
  <text class="muted" x="360" y="128" text-anchor="middle">51 tools</text>
  <text class="muted" x="360" y="150" text-anchor="middle">one browser and one</text>
  <text class="muted" x="360" y="172" text-anchor="middle">context per session</text>
  <text class="muted" x="360" y="194" text-anchor="middle">stdio or Streamable HTTP</text>

  <rect class="box" x="540" y="72" width="168" height="110"/>
  <text class="label b" x="624" y="104" text-anchor="middle">Browser</text>
  <text class="muted" x="624" y="130" text-anchor="middle">Chromium, Chrome,</text>
  <text class="muted" x="624" y="150" text-anchor="middle">Edge or Firefox</text>
  <text class="muted" x="624" y="170" text-anchor="middle">contexts, tabs, pages</text>

  <path class="brand-line" d="M180 108 H242" stroke-width="2"/>
  <path class="fill" d="M250 108 L236 102 L236 114 Z"/>
  <text class="muted" x="211" y="98" text-anchor="middle">tools/call</text>

  <path class="brand-line-2" d="M250 152 H188" stroke-width="2"/>
  <path class="fill" d="M180 152 L194 146 L194 158 Z"/>
  <text class="muted" x="211" y="174" text-anchor="middle">results</text>

  <path class="brand-line" d="M470 108 H532" stroke-width="2"/>
  <path class="fill" d="M540 108 L526 102 L526 114 Z"/>
  <text class="muted" x="505" y="98" text-anchor="middle">CDP</text>

  <path class="brand-line-2" d="M540 152 H478" stroke-width="2"/>
  <path class="fill" d="M470 152 L484 146 L484 158 Z"/>
  <text class="muted" x="505" y="174" text-anchor="middle">AX tree</text>

  <text class="muted" x="360" y="240" text-anchor="middle">Under --http, each connected client gets its own session and its own browser.</text>
</svg>
<figcaption>The agent never sees a selector. It sees a tree of roles and names, and it addresses nodes by the refs that tree carries.</figcaption>
</figure>

<h2 id="install-and-register">Install and register</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
claude<span class="w"> </span>mcp<span class="w"> </span>add<span class="w"> </span>motus<span class="w"> </span>--<span class="w"> </span>motus<span class="w"> </span>mcp
</code></pre></div>

<p>Everything after <code>--</code> is the command the client runs, so server options go there too: <code>claude mcp add motus -- motus mcp --channel chrome</code>.</p>
<p>Clients that read a JSON configuration file, Claude Desktop among them, take the same command and arguments under an <code>mcpServers</code> map:</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;mcpServers&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;motus&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;command&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;motus&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;args&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;mcp&quot;</span><span class="p">]</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>If the client tells you the server failed to connect, the usual cause is that <code>motus</code> is not on the <code>PATH</code> the client inherits. Check with <code>which motus</code>, which should print something like <code>~/.dotnet/tools/motus</code>. If it prints nothing, stop depending on <code>PATH</code> and give the absolute path instead, either as <code>command</code> in the JSON above or on the command line:</p>
<div class="highlight"><pre><span></span><code>claude<span class="w"> </span>mcp<span class="w"> </span>add<span class="w"> </span>motus<span class="w"> </span>--<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$HOME</span><span class="s2">/.dotnet/tools/motus&quot;</span><span class="w"> </span>mcp
</code></pre></div>

<p>You will not find a start-session tool, and you do not need one. The browser and a <code>"default"</code> context are created lazily, on the first call that needs a page.</p>
<h2 id="what-the-agent-actually-sees">What the agent actually sees</h2>
<p><code>snapshot</code> returns the browser's accessibility tree as indented text. Two spaces per level, one line per node, and a <code>ref</code> on every node that maps to a real DOM node. Refs are <code>e1</code>, <code>e2</code> and so on, assigned in document order.</p>
<div class="highlight"><pre><span></span><code>- RootWebArea &quot;Sign in&quot; [ref=e1]
  - navigation [ref=e2]
    - button &quot;Sign in&quot; [ref=e3]
  - form [ref=e4]
    - textbox &quot;Email&quot; [ref=e5]
    - textbox &quot;Password&quot; [ref=e6]
    - button &quot;Sign in&quot; [ref=e7]
</code></pre></div>

<p>State follows the ref in brackets: <code>[value="..."]</code>, then any of <code>disabled</code>, <code>readonly</code>, <code>required</code>, <code>checked</code>, <code>selected</code>, <code>expanded</code>, <code>pressed</code>. The interaction tools take the ref, not a selector:</p>
<div class="highlight"><pre><span></span><code>type(ref: &quot;e5&quot;, text: &quot;ada@example.com&quot;)
type(ref: &quot;e6&quot;, text: &quot;hunter2&quot;, submit: true)
</code></pre></div>

<p>Refs belong to the latest snapshot. Use a stale one and you get back an error result that carries the fix, rather than a protocol failure: <code>Ref 'e5' is not in the latest snapshot. Call snapshot to refresh refs, then retry.</code> An agent reads that and re-snapshots on its own. This is why perception here is a tree rather than a picture. A screenshot shows an agent what a page looks like; a snapshot gives it something it can address.</p>
<h2 id="the-51-tools">The 51 tools</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Area</th>
<th>Tools</th>
</tr>
</thead>
<tbody>
<tr>
<td>Navigation</td>
<td><code>navigate</code>, <code>go_back</code>, <code>go_forward</code>, <code>reload</code>, <code>wait_for</code></td>
</tr>
<tr>
<td>Perception</td>
<td><code>snapshot</code>, <code>screenshot</code>, <code>audit_accessibility</code>, <code>get_performance</code></td>
</tr>
<tr>
<td>Interaction</td>
<td><code>click</code>, <code>type</code>, <code>press</code>, <code>press_key</code>, <code>hover</code>, <code>focus</code>, <code>clear</code>, <code>select_option</code>, <code>set_checked</code>, <code>scroll_into_view</code>, <code>upload_files</code>, <code>wait_for_element</code></td>
</tr>
<tr>
<td>Coordinates</td>
<td><code>click_xy</code>, <code>hover_xy</code>, <code>move_xy</code>, <code>scroll_xy</code>, <code>drag</code>, <code>resize</code></td>
</tr>
<tr>
<td>Tabs and contexts</td>
<td><code>tab_list</code>, <code>tab_open</code>, <code>tab_select</code>, <code>tab_close</code>, <code>context_list</code>, <code>context_create</code>, <code>context_select</code>, <code>context_close</code></td>
</tr>
<tr>
<td>Scripting</td>
<td><code>evaluate</code></td>
</tr>
<tr>
<td>Dialogs</td>
<td><code>handle_dialog</code></td>
</tr>
<tr>
<td>Network</td>
<td><code>route_fulfill</code>, <code>route_abort</code>, <code>route_continue</code>, <code>unroute</code>, <code>route_list</code>, <code>network_requests</code></td>
</tr>
<tr>
<td>Console</td>
<td><code>console_messages</code></td>
</tr>
<tr>
<td>Recording and codegen</td>
<td><code>generate_pom</code>, <code>trace_start</code>, <code>trace_stop</code>, <code>har_start</code>, <code>har_stop</code>, <code>video_start</code>, <code>video_stop</code></td>
</tr>
</tbody>
</table></div>
<h2 id="when-the-tree-has-nothing-to-point-at">When the tree has nothing to point at</h2>
<p>A page that paints to a <code>&lt;canvas&gt;</code> gives the accessibility tree nothing to describe. Rather than let an agent decide the page is empty, <code>snapshot</code> says so and names the way out: take a <code>screenshot</code>, find the control visually, then act on its position.</p>
<p><code>click_xy</code>, <code>hover_xy</code>, <code>move_xy</code>, <code>scroll_xy</code> and <code>drag</code> take CSS pixels in the viewport, the same space screenshots and <code>getBoundingClientRect()</code> report. <code>resize</code> changes the viewport when a target sits past its edge. The session default is 1280x800, and <code>--viewport WIDTHxHEIGHT</code> changes it at launch.</p>
<p>I want to be honest about what you give up here. There is no element resolution and no actionability check. Input is still dispatched as trusted browser-level events, so frameworks that ignore synthetic JavaScript events respond to it, but the browser's own hit test decides what receives the event. An overlay with <code>pointer-events: none</code> gets passed through; an overlay that accepts pointer events takes the click instead of your target. <code>drag</code> accepts refs (<code>start_ref</code>, <code>end_ref</code>) or coordinates, one addressing mode per call, and it always emits intermediate moves, with <code>steps</code> and <code>hold_ms</code> for libraries that threshold a drag start.</p>
<h2 id="the-parts-that-come-from-a-test-framework">The parts that come from a test framework</h2>
<p>Three tools are here because the engine underneath is a test framework rather than a driver.</p>
<p><code>audit_accessibility</code> runs Motus's nine built-in WCAG 2.1 A and AA rules against the browser's accessibility tree, and takes <code>min_severity</code> if you want to filter. <code>get_performance</code> returns the Core Web Vitals the suite asserts on: LCP, FCP, TTFB, CLS and INP, plus JS heap size and DOM node count. Both report exactly what a test would, so an agent's finding and a CI failure are the same measurement rather than two things you have to reconcile.</p>
<p><code>generate_pom</code> closes the loop with <a href="https://datafication.co/blog/2026/record-generate-repair.html">the recorder and code generator</a>. It analyzes the page the agent just explored and hands back a C# Page Object Model class inline, with <code>namespace</code> and <code>class_name</code> optional. That turns an exploration into a test instead of a transcript, which is the whole reason I wanted it in the server.</p>
<p>For evidence, <code>trace_start</code> and <code>trace_stop</code>, <code>har_start</code> and <code>har_stop</code>, and <code>video_start</code> and <code>video_stop</code> all follow one convention: stopping finalizes the file and returns its path, and if you leave the path out, one is generated under the temporary directory. Video is MJPEG in an AVI container, written without an external dependency, captured at viewport resolution, and no mouse cursor shows up in it. Convert it with <code>ffmpeg -i in.avi -c:v libx264 out.mp4</code> when you need another format. If you would rather record everything, <code>--record-video &lt;dir&gt;</code> captures every page for its whole life, and in that mode <code>video_start</code> reports an error because the page is already recording.</p>
<h2 id="serving-it-to-a-team">Serving it to a team</h2>
<p>The same tools run over Streamable HTTP, which is the transport for remote clients. It is not SSE.</p>
<div class="highlight"><pre><span></span><code><span class="c1"># loopback only, no token needed</span>
motus<span class="w"> </span>mcp<span class="w"> </span>--http

<span class="c1"># reachable from other machines: a non-loopback bind requires a token</span>
motus<span class="w"> </span>mcp<span class="w"> </span>--http<span class="w"> </span>--host<span class="w"> </span><span class="m">0</span>.0.0.0<span class="w"> </span>--port<span class="w"> </span><span class="m">8931</span><span class="w"> </span>--token<span class="w"> </span><span class="s2">&quot;</span><span class="nv">$MOTUS_MCP_TOKEN</span><span class="s2">&quot;</span>
</code></pre></div>

<p>Each connected client gets its own session with its own browser, and a session that sits idle for thirty minutes is torn down along with the browser it holds. Binding a non-loopback host without a token is refused at startup rather than warned about, and when a token is set every request is compared against it in constant time. stdio inherits the trust of whoever launched it and needs no token. HTTP does not inherit anything, so treat that token like a credential.</p>
<h2 id="worth-knowing-before-you-start">Worth knowing before you start</h2>
<p>The tools are registered by hand rather than found by scanning, which keeps reflection out of schema generation. The server targets <code>net8.0</code> and <code>net10.0</code> and rolls forward, so a machine carrying only the .NET 10 runtime runs it fine.</p>
<p>Firefox is available through <code>--channel firefox</code>, but not all of this is. Tracing, network interception and the accessibility tree are Chrome DevTools Protocol capabilities, so the tools built on them return a diagnostic instead of a result on a Firefox session. Drive Chromium unless Firefox is the thing you are testing.</p>
<p>The <a href="https://motustesting.com/docs/guides/mcp-server.html">MCP server guide</a> has the full option table, and the site publishes a machine-readable index of every documentation page at <a href="https://motustesting.com/llms.txt">llms.txt</a> for an agent that would rather read the manual itself. Point a client at it, give it a page you know well, and tell me where the snapshot let you down.</p>]]></content:encoded>
  </item>
  <item>
    <title>Record a session, generate the page objects, then keep the selectors alive</title>
    <link>https://datafication.co/blog/2026/record-generate-repair.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/record-generate-repair.html</guid>
    <pubDate>Tue, 21 Apr 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Go from a live page to compiling C#, then let a CI job tell you which selectors broke and fix the ones it is sure about.</description>
    <content:encoded><![CDATA[<p>Browser suites rot from the outside in. The assertions stay true and the selectors quietly stop matching, because somebody renamed a CSS class or moved a button into a different container. The test goes red for a reason that has nothing to do with what it was protecting. If you have ever spent a morning chasing that down, you know how it feels.</p>
<p>Motus 1.0.10 closes the loop. The recorder and the code generator now write a selector manifest beside the code they emit, and <code>motus check-selectors</code> reads it, checks every selector against a live page, and rewrites the broken ones it is sure about.</p>
<figure class="fig">
<svg viewBox="0 0 720 300" role="img" aria-label="A four step cycle around the live page: capture, code and manifest, check, repair">
  <rect class="box on" x="24" y="24" width="210" height="70"/>
  <text class="label b" x="129" y="52" text-anchor="middle">1. Capture</text>
  <text class="muted" x="129" y="76" text-anchor="middle">motus record, motus codegen</text>

  <rect class="box" x="486" y="24" width="210" height="70"/>
  <text class="label b" x="591" y="52" text-anchor="middle">2. Code and manifest</text>
  <text class="muted" x="591" y="76" text-anchor="middle">Tests.cs, Tests.selectors.json</text>

  <rect class="box" x="486" y="206" width="210" height="70"/>
  <text class="label b" x="591" y="234" text-anchor="middle">3. Check</text>
  <text class="muted" x="591" y="258" text-anchor="middle">motus check-selectors</text>

  <rect class="box on" x="24" y="206" width="210" height="70"/>
  <text class="label b" x="129" y="234" text-anchor="middle">4. Repair</text>
  <text class="muted" x="129" y="258" text-anchor="middle">--fix, or --interactive</text>

  <rect class="box soft" x="286" y="118" width="148" height="58"/>
  <text class="label" x="360" y="152" text-anchor="middle">the live page</text>

  <path class="brand-line" d="M234 59 H478" stroke-width="2"/>
  <path class="fill" d="M486 59 L472 53 L472 65 Z"/>
  <path class="brand-line" d="M591 94 V198" stroke-width="2"/>
  <path class="fill" d="M591 206 L585 192 L597 192 Z"/>
  <path class="brand-line" d="M486 241 H242" stroke-width="2"/>
  <path class="fill" d="M234 241 L248 235 L248 247 Z"/>
  <path class="brand-line" d="M129 206 V102" stroke-width="2"/>
  <path class="fill" d="M129 94 L123 108 L135 108 Z"/>
</svg>
<figcaption>Capture and check both read the running application. The manifest is what ties them together.</figcaption>
</figure>

<h2 id="before-you-start">Before you start</h2>
<p>You need the .NET 8 SDK or later, a test project, and the CLI. One thing to get right up front: the verb is <code>motus</code>, never <code>dotnet motus</code>.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus.Testing.MSTest
dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
</code></pre></div>

<p><code>motus install</code> pulls down a Chrome for Testing build into <code>~/.motus/browsers</code>. Every example below drives an application on <code>http://localhost:5000</code> that has a top bar and a sign-in form:</p>
<div class="highlight"><pre><span></span><code><span class="p">&lt;</span><span class="nt">nav</span> <span class="na">class</span><span class="o">=</span><span class="s">&quot;topbar&quot;</span><span class="p">&gt;&lt;</span><span class="nt">button</span> <span class="na">class</span><span class="o">=</span><span class="s">&quot;link-btn&quot;</span><span class="p">&gt;</span>Sign in<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;&lt;/</span><span class="nt">nav</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">form</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">input</span> <span class="na">data-testid</span><span class="o">=</span><span class="s">&quot;email&quot;</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;email&quot;</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&quot;Email&quot;</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">input</span> <span class="na">data-testid</span><span class="o">=</span><span class="s">&quot;password&quot;</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;password&quot;</span> <span class="na">aria-label</span><span class="o">=</span><span class="s">&quot;Password&quot;</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">button</span> <span class="na">data-testid</span><span class="o">=</span><span class="s">&quot;sign-in&quot;</span> <span class="na">type</span><span class="o">=</span><span class="s">&quot;submit&quot;</span><span class="p">&gt;</span>Sign in<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">form</span><span class="p">&gt;</span>
</code></pre></div>

<h2 id="step-1-generate-the-page-objects">Step 1: generate the page objects</h2>
<p>Start with <code>motus codegen</code>. It loads a page, waits for the network to settle, crawls the DOM for interactive elements (inputs, buttons, selects, textareas, links with an <code>href</code>, and anything with <code>role="button"</code>), infers a selector for each one, and writes a partial class.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>codegen<span class="w"> </span>http://localhost:5000/sign-in<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--output<span class="w"> </span>tests/Pages<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--namespace<span class="w"> </span>MyApp.Tests.Pages
</code></pre></div>

<p>The class name comes from the URL: host segments first, with <code>www</code> dropped, then path segments. So that command writes <code>tests/Pages/LocalhostSignInPage.g.cs</code>:</p>
<div class="highlight"><pre><span></span><code><span class="c1">// &lt;auto-generated/&gt;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>

<span class="k">namespace</span><span class="w"> </span><span class="nn">MyApp.Tests.Pages</span><span class="p">;</span>

<span class="k">public</span><span class="w"> </span><span class="k">partial</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">LocalhostSignInPage</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">private</span><span class="w"> </span><span class="k">readonly</span><span class="w"> </span><span class="n">IPage</span><span class="w"> </span><span class="n">_page</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="nf">LocalhostSignInPage</span><span class="p">(</span><span class="n">IPage</span><span class="w"> </span><span class="n">page</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">_page</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">page</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">NavigateAsync</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">_page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;http://localhost:5000/sign-in&quot;</span><span class="p">);</span>

<span class="w">    </span><span class="c1">// Locators</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">ILocator</span><span class="w"> </span><span class="n">EmailInput</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">_page</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">&quot;data-testid=email&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">ILocator</span><span class="w"> </span><span class="n">PasswordInput</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">_page</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">&quot;data-testid=password&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">ILocator</span><span class="w"> </span><span class="n">SignInButton</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">_page</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">&quot;data-testid=sign-in&quot;</span><span class="p">);</span>

<span class="w">    </span><span class="c1">// Form actions</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">SubmitSignInFormAsync</span><span class="p">(</span><span class="kt">string</span><span class="w"> </span><span class="n">email</span><span class="p">,</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">password</span><span class="p">)</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">EmailInput</span><span class="p">.</span><span class="n">FillAsync</span><span class="p">(</span><span class="n">email</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">PasswordInput</span><span class="p">.</span><span class="n">FillAsync</span><span class="p">(</span><span class="n">password</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">SignInButton</span><span class="p">.</span><span class="n">ClickAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Two things about that file. It is <code>partial</code>, so your own helpers live in a second file that regeneration never touches. And the property names come from the first of <code>id</code>, <code>name</code>, <code>aria-label</code>, <code>placeholder</code> and visible text that is actually set, plus a suffix for the kind of element. <code>SubmitSignInFormAsync</code> shows up only because the form holds a fillable input and a submit button.</p>
<p>If you cannot reach the page by URL, there are three ways in. <code>--headed</code> opens a visible browser and waits for you to press Enter. <code>--connect ws://localhost:9222</code> analyzes what is already open in a browser you started yourself. And <code>--scope "#login-form"</code> limits discovery to a single container.</p>
<h2 id="how-a-selector-gets-chosen">How a selector gets chosen</h2>
<p>This part is worth a minute of your time, because it explains the code you get back. Inference walks the registered selector strategies in priority order and takes the first candidate that is at most 200 characters and resolves to exactly one node. These are the same <code>ISelectorStrategy</code> implementations the runtime uses, registered the way <a href="https://datafication.co/blog/2026/every-built-in-is-a-plugin.html">any other plugin is</a>, so a custom strategy changes generation and resolution together.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Strategy</th>
<th>Priority</th>
<th>What it emits</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>_node</code></td>
<td>100</td>
<td>Nothing. Backend node ids are ephemeral</td>
</tr>
<tr>
<td><code>data-testid</code></td>
<td>40</td>
<td><code>data-testid=sign-in</code></td>
</tr>
<tr>
<td><code>role</code></td>
<td>30</td>
<td><code>role=button[name="Sign in"]</code></td>
</tr>
<tr>
<td><code>text</code></td>
<td>20</td>
<td><code>text=Sign in</code></td>
</tr>
<tr>
<td><code>css</code></td>
<td>10</td>
<td><code>#sign-in</code>, or <code>css=nav.topbar &gt; button.link-btn</code></td>
</tr>
<tr>
<td><code>xpath</code></td>
<td>10</td>
<td><code>xpath=/html/body/nav[1]/button[1]</code></td>
</tr>
</tbody>
</table></div>
<p>In practice the ladder is test id, role, text, CSS. CSS sits ahead of XPath at the same priority and almost always succeeds, so XPath rarely wins. When nothing qualifies, the element comes out as a <code>// TODO:</code> comment instead of a guessed locator. I would rather hand you a gap you can see than a locator that looks fine and is not.</p>
<h2 id="step-2-record-a-scenario">Step 2: record a scenario</h2>
<p><code>motus record</code> launches a headed browser, injects a recorder script that survives navigation, and turns your clicks, keystrokes and navigations into one line of C# each. There is no <code>--headless</code> option here, and <code>--selector-priority</code> is reserved on <code>record</code>: only <code>codegen</code> applies it.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>record<span class="w"> </span>--url<span class="w"> </span>http://localhost:5000/<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--output<span class="w"> </span>tests/SignInFlow.cs<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--namespace<span class="w"> </span>MyApp.Tests<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--class-name<span class="w"> </span>SignInFlowTests<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--method-name<span class="w"> </span>CanSignIn
</code></pre></div>

<p>Press Enter when you are done. Consecutive fills on one selector get coalesced into a single line:</p>
<div class="highlight"><pre><span></span><code><span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;http://localhost:5000/&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">page</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">&quot;css=nav.topbar &gt; button.link-btn&quot;</span><span class="p">).</span><span class="n">ClickAsync</span><span class="p">();</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;http://localhost:5000/sign-in&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">page</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">&quot;data-testid=email&quot;</span><span class="p">).</span><span class="n">FillAsync</span><span class="p">(</span><span class="s">&quot;ada@example.com&quot;</span><span class="p">);</span>
</code></pre></div>

<p>Look at the second line. The top bar button has no test id, and <code>role=button[name="Sign in"]</code> also matches the form's submit button, so inference falls all the way through to CSS. That line is now coupled to a class name, which is exactly the kind of thing that breaks later.</p>
<h2 id="step-3-the-manifest">Step 3: the manifest</h2>
<p>Both commands write a sidecar beside their output, here <code>tests/SignInFlow.selectors.json</code>. Each entry records the selector, the locator method, where it sits in your source, the URL it was captured on, and a fingerprint of the element.</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;entries&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;selector&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;css=nav.topbar &gt; button.link-btn&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;locatorMethod&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Locator&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;sourceFile&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;/work/app/tests/SignInFlow.cs&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;sourceLine&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">15</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;pageUrl&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;http://localhost:5000/&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;fingerprint&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="nt">&quot;tagName&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;button&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="nt">&quot;keyAttributes&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{},</span>
<span class="w">        </span><span class="nt">&quot;visibleText&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Sign in&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="nt">&quot;ancestorPath&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;html &gt; body &gt; nav&quot;</span><span class="p">,</span>
<span class="w">        </span><span class="nt">&quot;hash&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;8095124e9ec5e2dad1577e7fff57f70add38c2d933b2861ce27b4c01015bbd17&quot;</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>

<p><code>keyAttributes</code> holds only <code>id</code>, <code>name</code>, <code>role</code>, <code>data-testid</code>, <code>aria-label</code>, <code>type</code> and <code>href</code>, which is why it is empty for that button. <code>ancestorPath</code> is three levels of tag names, and <code>hash</code> is a SHA-256 over the four fields above it. Notice what is not in there: class names. Leaving them out is what lets a fingerprint outlive a restyling.</p>
<h2 id="step-4-check-the-selectors">Step 4: check the selectors</h2>
<p><code>motus check-selectors</code> parses your C# with Roslyn, picks out the calls to <code>Locator</code>, <code>GetByRole</code>, <code>GetByText</code>, <code>GetByTestId</code>, <code>GetByLabel</code>, <code>GetByPlaceholder</code>, <code>GetByAltText</code> and <code>GetByTitle</code>, and resolves each one against a live page. The match count is the verdict.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Verdict</th>
<th>Meaning</th>
<th>Under <code>--ci</code></th>
</tr>
</thead>
<tbody>
<tr>
<td><code>HEALTHY</code></td>
<td>Exactly one match</td>
<td>Passes</td>
</tr>
<tr>
<td><code>BROKEN</code></td>
<td>No match, or the call would not dispatch</td>
<td>Exits non-zero</td>
</tr>
<tr>
<td><code>AMBIGUOUS</code></td>
<td>More than one match</td>
<td>Passes, but the test is a coin toss</td>
</tr>
<tr>
<td><code>SKIPPED</code></td>
<td>Interpolated, or no manifest entry to supply a URL</td>
<td>Passes</td>
</tr>
</tbody>
</table></div>
<p><code>--base-url</code> points every selector at one page. With <code>--manifest</code>, each one is checked against the URL it was recorded on, which is the only way a multi-page flow checks correctly. Say the design system renames <code>.topbar</code> to <code>.appbar</code> and you run this:</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>check-selectors<span class="w"> </span><span class="s2">&quot;tests/**/*.cs&quot;</span><span class="w"> </span>--manifest<span class="w"> </span>tests/SignInFlow.selectors.json
</code></pre></div>

<div class="highlight"><pre><span></span><code>STATUS     SELECTOR                                 FILE:LINE                           MATCHES
---------- ---------------------------------------- ----------------------------------- -------
BROKEN     css=nav.topbar &gt; button.link-btn         SignInFlow.cs:15                          0
  -&gt; Suggestion (High, css): Locator(&quot;css=nav.appbar &gt; button.link-btn&quot;)
  -&gt; Suggestion (High, xpath): Locator(&quot;xpath=/html/body/nav[1]/button[1]&quot;)
HEALTHY    data-testid=email                        SignInFlow.cs:17                          1
HEALTHY    data-testid=password                     SignInFlow.cs:18                          1
HEALTHY    data-testid=sign-in                      SignInFlow.cs:19                          1

Total 4  |  3 healthy  |  1 broken  |  0 ambiguous  |  0 skipped
</code></pre></div>

<h2 id="step-5-repair">Step 5: repair</h2>
<p>Now the part that saves you the morning. For a broken selector that has a manifest entry, Motus scans the live page for the fingerprinted element and grades what it finds. An exact hash recompute is High. All key attributes matching, or at least three of them, is Medium. Same tag and ancestor path with fewer than three is Low. It then regenerates selectors through every strategy and keeps the ones that resolve uniquely.</p>
<p><code>--fix</code> applies the first suggestion, and only when that suggestion grades High. The edit goes through a Roslyn rewriter that replaces the call and leaves the rest of the file exactly as it was, comments and formatting included. The original is copied to <code>&lt;file&gt;.bak</code> unless you pass <code>--no-backup</code>.</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>check-selectors<span class="w"> </span><span class="s2">&quot;tests/**/*.cs&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--manifest<span class="w"> </span>tests/SignInFlow.selectors.json<span class="w"> </span>--fix
</code></pre></div>

<p>A repaired row prints as <code>FIXED</code> with a <code>-&gt; Fixed:</code> line under it. A renamed test id or an edited label grades Medium or Low, so it gets reported and left alone for a person to look at. <code>--interactive</code> walks you through those one at a time in the visual runner, with the candidate highlighted on the page. Repairs need a fingerprint, so both flags require <code>--manifest</code>, and you cannot combine them. A usage error exits <code>2</code>.</p>
<h2 id="in-ci">In CI</h2>
<p>Run the check as its own job against a deployed environment:</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>check-selectors<span class="w"> </span><span class="s2">&quot;tests/**/*.cs&quot;</span><span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--base-url<span class="w"> </span>https://staging.example.com<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--ci<span class="w"> </span>--json<span class="w"> </span>selector-report.json
</code></pre></div>

<p><code>--ci</code> exits non-zero as soon as anything is broken, and <code>--json</code> writes the full result out for a build annotation. Keep <code>--fix</code> out of CI. A repair is a source change, and source changes belong in a pull request where somebody reads them.</p>
<h2 id="one-thing-that-will-catch-you-out">One thing that will catch you out</h2>
<p><code>--detect-listeners</code> adds a second codegen pass over CDP <code>DOMDebugger.getEventListeners</code> to find clickable elements carrying a directly attached handler. It sees vanilla JavaScript and jQuery listeners. It does not see React's delegated events, which are bound at the root rather than on the element. On a React application you get back only the semantic set, so plan on adding test ids to whatever the crawl cannot name.</p>
<p>The full option lists live in the <a href="https://motustesting.com/docs/guides/recording-and-codegen.html">recording and code generation guide</a> and the <a href="https://motustesting.com/docs/reference/cli.html">CLI reference</a>, and <a href="https://datafication.co/blog/2026/motus-1-0-direct-to-the-browser.html">Motus 1.0</a> covers what sits underneath all of it. If you have a suite already, point <code>check-selectors</code> at it and tell me what comes back. I am most curious about the ambiguous ones.</p>]]></content:encoded>
  </item>
  <item>
    <title>Accessibility and performance checks, in the test you already wrote</title>
    <link>https://datafication.co/blog/2026/audits-in-the-box.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/audits-in-the-box.html</guid>
    <pubDate>Fri, 10 Apr 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Motus checks the page your test is already driving against nine WCAG rules and collects Core Web Vitals while the run happens, so both become ordinary assertions and ordinary CI gates instead of a second tool.</description>
    <content:encoded><![CDATA[<p>If you've ever gotten a clean accessibility report out of CI and later found out it had been scanning your login screen, you know the problem. Accessibility and performance usually get checked by a second tool, driving a second browser session, in its own stage of the pipeline, and that tool sees a different page than your test does. It gets the empty state instead of the one with the record that reproduces the bug, because it does not know how to get there and your test suite does.</p>
<p>Motus 1.0.7 added accessibility auditing and 1.0.8, out today, adds Core Web Vitals and performance budgets. Both read from the session your test is already driving. An audit is an assertion in the middle of a test, not a job at the end of the build.</p>
<figure class="fig">
<svg viewBox="0 0 720 332" role="img" aria-label="A test navigating and clicking, with accessibility and performance data taken from the same page session and converging on one test result">
  <rect class="box" x="40" y="12" width="190" height="44" stroke-width="1"/>
  <text class="code" x="135" y="39" text-anchor="middle">GotoAsync(url)</text>
  <path class="line" d="M230 34 H256" stroke-width="1"/>
  <path class="fill" d="M256 29 L265 34 L256 39 Z"/>
  <rect class="box" x="265" y="12" width="190" height="44" stroke-width="1"/>
  <text class="code" x="360" y="39" text-anchor="middle">ClickAsync()</text>
  <path class="line" d="M455 34 H481" stroke-width="1"/>
  <path class="fill" d="M481 29 L490 34 L481 39 Z"/>
  <rect class="box on" x="490" y="12" width="190" height="44" stroke-width="1"/>
  <text class="code" x="585" y="39" text-anchor="middle">Expect.That(page)</text>
  <text class="muted" x="135" y="76" text-anchor="middle">hook fires</text>
  <text class="muted" x="360" y="76" text-anchor="middle">hook fires</text>
  <text class="muted" x="585" y="76" text-anchor="middle">reads the result</text>
  <path class="line" d="M135 84 V104" stroke-width="1"/>
  <path class="line" d="M360 84 V104" stroke-width="1"/>
  <path class="line" d="M585 84 V104" stroke-width="1"/>
  <rect class="box on" x="40" y="104" width="640" height="40" stroke-width="1"/>
  <text class="label b" x="360" y="129" text-anchor="middle">One page session</text>
  <path class="line" d="M190 144 V168" stroke-width="1"/>
  <path class="line" d="M530 144 V168" stroke-width="1"/>
  <rect class="box" x="40" y="168" width="300" height="96" stroke-width="1"/>
  <text class="label b" x="60" y="190">Accessibility</text>
  <text class="code" x="60" y="212">Accessibility.getFullAXTree</text>
  <text class="code" x="60" y="232">nine rules, once per node</text>
  <text class="code" x="60" y="252">AccessibilityViolation list</text>
  <rect class="box" x="380" y="168" width="300" height="96" stroke-width="1"/>
  <text class="label b" x="400" y="190">Performance</text>
  <text class="code" x="400" y="212">PerformanceObserver (injected)</text>
  <text class="code" x="400" y="232">Performance.getMetrics</text>
  <text class="code" x="400" y="252">PerformanceMetrics record</text>
  <path class="brand-line" d="M190 264 V278 H360 V290" stroke-width="2"/>
  <path class="brand-line" d="M530 264 V278 H360 V290" stroke-width="2"/>
  <rect class="box on" x="250" y="290" width="220" height="38" stroke-width="1"/>
  <text class="label b" x="360" y="314" text-anchor="middle">One test result</text>
</svg>
<figcaption>Where an audit runs. The hook fires after a navigation and after a click, fill or select, and the assertion reads what it stored.</figcaption>
</figure>

<h2 id="the-accessibility-side">The accessibility side</h2>
<p>The tree comes from the browser, not from a library we inject into your page. Motus asks CDP for the browser's own computed accessibility tree and hands each node to every registered rule, and color values come from the browser's computed styles. Two of the nine rules do run a single <code>Runtime.evaluate</code> each, for the duplicate-id sweep and for the document's <code>lang</code> attribute, so the honest way to put this is that the audit does not inject a rule engine, not that it runs no JavaScript at all.</p>
<p>Nine rules ship, registered by <code>AccessibilityRulesPlugin</code>:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Rule id</th>
<th>Severity</th>
<th>What it catches</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>a11y-alt-text</code></td>
<td>Error</td>
<td>An image with no accessible name</td>
</tr>
<tr>
<td><code>a11y-empty-button</code></td>
<td>Error</td>
<td>A button with no accessible name</td>
</tr>
<tr>
<td><code>a11y-empty-link</code></td>
<td>Error</td>
<td>A link with no accessible name</td>
</tr>
<tr>
<td><code>a11y-unlabeled-form-control</code></td>
<td>Error</td>
<td>A form control with no label, <code>aria-label</code> or <code>aria-labelledby</code></td>
</tr>
<tr>
<td><code>a11y-color-contrast</code></td>
<td>Error</td>
<td>Text under 4.5:1, or 3:1 for large text</td>
</tr>
<tr>
<td><code>a11y-duplicate-id</code></td>
<td>Error</td>
<td>An <code>id</code> value used more than once in the document</td>
</tr>
<tr>
<td><code>a11y-missing-lang</code></td>
<td>Error</td>
<td>An <code>&lt;html&gt;</code> element with no <code>lang</code> attribute</td>
</tr>
<tr>
<td><code>a11y-missing-landmark</code></td>
<td>Warning</td>
<td>A page with no main landmark</td>
</tr>
<tr>
<td><code>a11y-heading-hierarchy</code></td>
<td>Warning</td>
<td>A heading level that skips, such as <code>h1</code> to <code>h3</code></td>
</tr>
</tbody>
</table></div>
<p>Nine rules is a small number, and I mean it to read that way. axe-core's own published rule set is several times larger, and Motus does not depend on it, port it, or try to replace it. What nine rules buy you is that the check is already in the box, runs in-process, and has no extra package to install and keep current. Playwright delegates accessibility auditing to the separate <code>axe-playwright</code> ecosystem package, and if you need axe's full coverage that is still the right tool.</p>
<h2 id="three-ways-to-use-it">Three ways to use it</h2>
<p>As an assertion, on the page you are already on:</p>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToPassAccessibilityAuditAsync</span><span class="p">();</span>

<span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToPassAccessibilityAuditAsync</span><span class="p">(</span><span class="n">opts</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="p">{</span>
<span class="w">    </span><span class="n">opts</span><span class="p">.</span><span class="n">SkipRules</span><span class="p">(</span><span class="s">&quot;a11y-color-contrast&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;a11y-missing-landmark&quot;</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div>

<p><code>IncludeWarnings</code> defaults to <code>true</code>, so the two warning-severity rules fail the assertion unless you set it to <code>false</code>. Individual elements have their own assertions: <code>ToHaveAccessibleNameAsync("Submit order")</code> and <code>ToHaveRoleAsync("navigation")</code>.</p>
<p>A failure names what it found. Each <code>AccessibilityViolation</code> carries the rule id, the severity, a message, the node's role and accessible name, and the backend node id it came from, so the report points at an element rather than at a page. Worth knowing: unlike most locator assertions, the audit assertion evaluates once instead of polling, which is the right behavior for a whole-page check. Put it after the assertion that establishes the state you meant to audit.</p>
<p>As a lifecycle hook, so every navigation is audited without a line in the test:</p>
<div class="highlight"><pre><span></span><code><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">LaunchBrowserAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">LaunchOptions</span>
<span class="p">{</span>
<span class="w">    </span><span class="n">Headless</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">    </span><span class="n">Accessibility</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">AccessibilityOptions</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="n">Enable</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">        </span><span class="n">Mode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">AccessibilityMode</span><span class="p">.</span><span class="n">Warn</span><span class="p">,</span>
<span class="w">        </span><span class="n">AuditAfterActions</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span>
<span class="w">    </span><span class="p">},</span>
<span class="w">    </span><span class="n">Performance</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">PerformanceOptions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Enable</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="p">}</span>
<span class="p">});</span>
</code></pre></div>

<p><code>AuditAfterNavigation</code> is on by default and <code>AuditAfterActions</code> is off; when it is on, the audited actions are click, fill and select. <code>Mode</code> is <code>Off</code>, <code>Warn</code> or <code>Enforce</code>. When the hook is running, <code>ToPassAccessibilityAuditAsync</code> reuses the audit it already stored rather than running another one; when the hook is off, the assertion runs an audit on demand.</p>
<p>And from the runner:</p>
<div class="highlight"><pre><span></span><code>motus<span class="w"> </span>run<span class="w"> </span>./bin/Debug/net8.0/MyTests.dll<span class="w"> </span>--a11y<span class="w"> </span>enforce<span class="w"> </span>--perf-budget
</code></pre></div>

<p>One thing to know about <code>--a11y</code>: leaving it off means the hook never activates, whatever <code>motus.config.json</code> says. The flag is what turns the section on.</p>
<h2 id="adding-a-rule-of-your-own">Adding a rule of your own</h2>
<p><code>IAccessibilityRule</code> has three members, and a rule is called once per node with a context that carries the page-wide facts a single node cannot answer.</p>
<div class="highlight"><pre><span></span><code><span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>

<span class="k">public</span><span class="w"> </span><span class="k">sealed</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">DisclosureExpandedRule</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">IAccessibilityRule</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">RuleId</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;custom-aria-expanded-disclosure&quot;</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Description</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="s">&quot;Disclosure triggers must expose aria-expanded.&quot;</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">AccessibilityViolation</span><span class="o">?</span><span class="w"> </span><span class="n">Evaluate</span><span class="p">(</span>
<span class="w">        </span><span class="n">AccessibilityNode</span><span class="w"> </span><span class="n">node</span><span class="p">,</span><span class="w"> </span><span class="n">AccessibilityAuditContext</span><span class="w"> </span><span class="n">context</span><span class="p">)</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="kt">string</span><span class="p">.</span><span class="n">Equals</span><span class="p">(</span><span class="n">node</span><span class="p">.</span><span class="n">Role</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;button&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">StringComparison</span><span class="p">.</span><span class="n">OrdinalIgnoreCase</span><span class="p">))</span>
<span class="w">            </span><span class="k">return</span><span class="w"> </span><span class="k">null</span><span class="p">;</span>
<span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="n">node</span><span class="p">.</span><span class="n">Properties</span><span class="p">.</span><span class="n">ContainsKey</span><span class="p">(</span><span class="s">&quot;controls&quot;</span><span class="p">)</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">node</span><span class="p">.</span><span class="n">Properties</span><span class="p">.</span><span class="n">ContainsKey</span><span class="p">(</span><span class="s">&quot;expanded&quot;</span><span class="p">))</span>
<span class="w">            </span><span class="k">return</span><span class="w"> </span><span class="k">null</span><span class="p">;</span>

<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="nf">AccessibilityViolation</span><span class="p">(</span>
<span class="w">            </span><span class="n">RuleId</span><span class="p">:</span><span class="w"> </span><span class="n">RuleId</span><span class="p">,</span>
<span class="w">            </span><span class="n">Severity</span><span class="p">:</span><span class="w"> </span><span class="n">AccessibilityViolationSeverity</span><span class="p">.</span><span class="n">Error</span><span class="p">,</span>
<span class="w">            </span><span class="n">Message</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;Disclosure button must expose aria-expanded.&quot;</span><span class="p">,</span>
<span class="w">            </span><span class="n">NodeRole</span><span class="p">:</span><span class="w"> </span><span class="n">node</span><span class="p">.</span><span class="n">Role</span><span class="p">,</span>
<span class="w">            </span><span class="n">NodeName</span><span class="p">:</span><span class="w"> </span><span class="n">node</span><span class="p">.</span><span class="n">Name</span><span class="p">,</span>
<span class="w">            </span><span class="n">BackendDOMNodeId</span><span class="p">:</span><span class="w"> </span><span class="n">node</span><span class="p">.</span><span class="n">BackendDOMNodeId</span><span class="p">,</span>
<span class="w">            </span><span class="n">Selector</span><span class="p">:</span><span class="w"> </span><span class="k">null</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Register it from a plugin with <code>context.RegisterAccessibilityRule(...)</code>, which is the same call the nine built-in rules use. That is not a coincidence: as we wrote about <a href="https://datafication.co/blog/2026/every-built-in-is-a-plugin.html">the plugin model</a>, the built-ins have no private entrance.</p>
<h2 id="the-performance-side">The performance side</h2>
<p>The performance collector is a lifecycle hook too. It installs a small observer script when the page is created, takes a snapshot after each navigation, and takes a final one when the page closes.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Metric</th>
<th>Where the value comes from</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
<tr>
<td>LCP</td>
<td>injected <code>PerformanceObserver</code> on <code>largest-contentful-paint</code></td>
<td>ms</td>
</tr>
<tr>
<td>FCP</td>
<td>injected <code>PerformanceObserver</code> on <code>paint</code>, with a CDP fallback</td>
<td>ms</td>
</tr>
<tr>
<td>CLS</td>
<td>injected <code>PerformanceObserver</code> on <code>layout-shift</code></td>
<td>score</td>
</tr>
<tr>
<td>INP</td>
<td>injected <code>PerformanceObserver</code> on <code>event</code></td>
<td>ms</td>
</tr>
<tr>
<td>TTFB</td>
<td>a Navigation Timing read: <code>responseStart - startTime</code></td>
<td>ms</td>
</tr>
<tr>
<td>JS heap size</td>
<td>CDP <code>Performance.getMetrics</code></td>
<td>bytes</td>
</tr>
<tr>
<td>DOM node count</td>
<td>CDP <code>Performance.getMetrics</code></td>
<td>count</td>
</tr>
</tbody>
</table></div>
<p>A metric that was never observed stays <code>null</code>, and an assertion against a null metric fails immediately rather than passing quietly.</p>
<p>Thresholds are declared, not passed in:</p>
<div class="highlight"><pre><span></span><code><span class="na">[TestClass]</span>
<span class="na">[PerformanceBudget(Lcp = 2500, Fcp = 1800, Cls = 0.1)]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">DashboardTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">DashboardIsWithinBudget</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://app.example.com/dashboard&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToMeetPerformanceBudgetAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="na">[PerformanceBudget(Lcp = 1500)]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">CheckoutIsTighter</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://app.example.com/checkout&quot;</span><span class="p">);</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Expect</span><span class="p">.</span><span class="n">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToMeetPerformanceBudgetAsync</span><span class="p">();</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Every property on the attribute defaults to <code>-1</code>, which means not enforced, so a budget only covers the metrics you name. Zero is a real threshold. The method attribute wins over the class attribute, and <code>motus.config.json</code> is the fallback. If nothing supplies a budget at all, <code>ToMeetPerformanceBudgetAsync</code> throws instead of passing, so a test can never quietly assert nothing.</p>
<p>Individual metrics have their own assertions when a whole budget is more than you want: <code>ToHaveLcpBelowAsync(2500)</code>, <code>ToHaveFcpBelowAsync(1800)</code>, <code>ToHaveTtfbBelowAsync(600)</code>, <code>ToHaveClsBelowAsync(0.1)</code>, <code>ToHaveInpBelowAsync(200)</code>. Each one re-collects metrics on every poll, so a value that arrives late still counts.</p>
<p>One more thing about the CLI flag: <code>--perf-budget</code> turns enforcement on and nothing else. The numbers still come from <code>[PerformanceBudget]</code> or from the <code>performance</code> section of the config file.</p>
<h2 id="what-firefox-gets">What Firefox gets</h2>
<p>The observer script is ordinary page JavaScript, so LCP, FCP, CLS and INP are collected over WebDriver BiDi as well. JS heap size and DOM node count come from a CDP domain and are always null there. The accessibility tree is CDP-only too, so an audit on a Firefox session comes back empty with a diagnostic message naming the transport rather than throwing halfway through a suite. If accessibility gating matters to you, run that job on Chromium.</p>
<p>None of this says the browser you test in is the browser your users have. It says something narrower, about which page gets checked. The audit that runs after your test has logged in, opened the drawer and filtered the table is looking at the markup a person would actually run into, and the budget it is measured against is the one your team wrote down.</p>
<p>Both features are off by default. Turning them on is one options block or one flag, and after that the checks live where the rest of your assertions do. The <a href="https://github.com/DataficationSDK/Motus/blob/main/docs/guides/accessibility-testing.md">accessibility guide</a> and the <a href="https://github.com/DataficationSDK/Motus/blob/main/docs/guides/performance-testing.md">performance guide</a> cover the rest of the configuration. If you turn it on and there is a rule you wish was in the box, tell me which one.</p>]]></content:encoded>
  </item>
  <item>
    <title>Run the same notebook in CI that you run at your desk</title>
    <link>https://datafication.co/blog/2026/notebooks-in-ci-with-parameters.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/notebooks-in-ci-with-parameters.html</guid>
    <pubDate>Wed, 08 Apr 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Declare your inputs once, hand them in with --param, and let the exit code decide whether the build goes red.</description>
    <content:encoded><![CDATA[<p>Sooner or later a notebook that produces a number somebody cares about has to run on a schedule, with different inputs, while nobody is watching. The usual answer is to rewrite it as a script, and from that day on you are maintaining two things that slowly drift apart.</p>
<p>I did not want to make that trade, so Verso goes the other way. The same file runs at your desk and in a pipeline, the inputs are declared once inside the file, and the command line supplies them. This guide takes a notebook from a form in the editor to a scheduled GitHub Actions job that turns the build red when a cell fails.</p>
<h2 id="before-you-start">Before you start</h2>
<p>You need the .NET 8.0 SDK or later, the Verso command line tool, and a notebook to run. Any <code>.verso</code>, <code>.ipynb</code>, or <code>.dib</code> file will do.</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>-g<span class="w"> </span>Verso.Cli
verso<span class="w"> </span>info
</code></pre></div>

<p><code>verso info</code> prints the CLI version, the .NET runtime, the engine version, and every extension, serializer, and formatter it discovered. Run it once now so you know what a healthy environment looks like, because you will want to compare against it later. The built-in kernels travel with the tool, so there is nothing else to install on the agent.</p>
<h2 id="step-1-declare-the-inputs">Step 1: declare the inputs</h2>
<p>Parameters live in the notebook's metadata. Each one gets a type, and if you want it, a description, a default, a required flag, and a place in the ordering.</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;verso&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;title&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Regional Sales Report&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;defaultKernel&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;csharp&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;parameters&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nt">&quot;region&quot;</span><span class="p">:</span><span class="w">     </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;string&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Region to process&quot;</span><span class="p">,</span>
<span class="w">                      </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">},</span>
<span class="w">      </span><span class="nt">&quot;reportDate&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;date&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;description&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Report date&quot;</span><span class="p">,</span>
<span class="w">                      </span><span class="nt">&quot;required&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="p">},</span>
<span class="w">      </span><span class="nt">&quot;batchSize&quot;</span><span class="p">:</span><span class="w">  </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;int&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;default&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1000</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="p">},</span>
<span class="w">      </span><span class="nt">&quot;dryRun&quot;</span><span class="p">:</span><span class="w">     </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;bool&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;default&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;order&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>You do not have to write that by hand. Insert a <strong>parameters</strong> cell in the editor and you get a form for the name, type, and default, and it writes the same metadata into the file for you.</p>
<p>There are six types, and each one is coerced to a CLR type before any cell runs.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Declared type</th>
<th>CLR type</th>
<th>Format</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>string</code></td>
<td><code>string</code></td>
<td>any text</td>
<td><code>us-east</code></td>
</tr>
<tr>
<td><code>int</code></td>
<td><code>long</code></td>
<td>integer</td>
<td><code>1000</code></td>
</tr>
<tr>
<td><code>float</code></td>
<td><code>double</code></td>
<td>decimal number</td>
<td><code>0.95</code></td>
</tr>
<tr>
<td><code>bool</code></td>
<td><code>bool</code></td>
<td><code>true</code>/<code>false</code>/<code>yes</code>/<code>no</code>/<code>1</code>/<code>0</code></td>
<td><code>false</code></td>
</tr>
<tr>
<td><code>date</code></td>
<td><code>DateOnly</code></td>
<td><code>yyyy-MM-dd</code>, strict</td>
<td><code>2026-04-08</code></td>
</tr>
<tr>
<td><code>datetime</code></td>
<td><code>DateTimeOffset</code></td>
<td>ISO 8601, UTC when no offset</td>
<td><code>2026-04-08T08:00:00Z</code></td>
</tr>
</tbody>
</table></div>
<h2 id="step-2-read-them-from-any-cell">Step 2: read them from any cell</h2>
<p>Parameters go into the shared variable store before the first cell executes. That means they are ordinary variables in every kernel, not something a Python cell has to be handed on its way in. <a href="https://datafication.co/blog/2026/one-variable-store-eight-languages.html">One variable store, eight languages</a> covers how that store works.</p>
<p>In C# they are top-level variables, already the right type:</p>
<div class="highlight"><pre><span></span><code><span class="kt">var</span><span class="w"> </span><span class="n">cutoff</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">reportDate</span><span class="p">.</span><span class="n">AddDays</span><span class="p">(</span><span class="o">-</span><span class="mi">7</span><span class="p">);</span>
<span class="n">Console</span><span class="p">.</span><span class="n">WriteLine</span><span class="p">(</span><span class="s">$&quot;{region}: {cutoff:yyyy-MM-dd} to {reportDate:yyyy-MM-dd}, batch {batchSize}&quot;</span><span class="p">);</span>
</code></pre></div>

<p>In a SQL cell the same names come through as bindings:</p>
<div class="highlight"><pre><span></span><code><span class="k">SELECT</span><span class="w"> </span><span class="n">product</span><span class="p">,</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">amount</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">total</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">sales</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">@</span><span class="n">region</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">sale_date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">@</span><span class="n">reportDate</span>
<span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">product</span>
</code></pre></div>

<p>In a Python cell they are just names. One declaration, three languages reading it.</p>
<h2 id="step-3-run-it-from-a-terminal">Step 3: run it from a terminal</h2>
<p>Now leave the editor and run the same file from a shell.</p>
<div class="highlight"><pre><span></span><code>verso<span class="w"> </span>run<span class="w"> </span>pipeline.verso<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--param<span class="w"> </span><span class="nv">region</span><span class="o">=</span>us-east<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--param<span class="w"> </span><span class="nv">reportDate</span><span class="o">=</span><span class="m">2026</span>-04-08
</code></pre></div>

<p><code>--param</code> is repeatable and uses an equals sign. Values are parsed against the declared type before anything executes, so a bad date is an error you see right away instead of a crash forty seconds in.</p>
<p>What comes back is the cells, in order, then a summary:</p>
<div class="highlight"><pre><span></span><code>─── Cell 2 (csharp) ──────────────────────
us-east: 2026-04-01 to 2026-04-08, batch 1000

─── Summary ──────────────────────────────
Cells: 5 total, 5 succeeded, 0 failed
Time:  3.4s
</code></pre></div>

<p>Two flags are worth knowing here. <code>--show-parameters</code> prints the resolved values alongside the cells, which is what a pipeline log needs when someone asks a month later what the nightly run actually used. <code>--interactive</code> prompts on standard input for any required parameter you left out, which is what you want at your desk.</p>
<h2 id="step-4-let-the-exit-code-decide">Step 4: let the exit code decide</h2>
<p><code>verso run</code> streams to the terminal and returns a specific code. Nothing is written back to the notebook unless you pass <code>--save</code>, so a scheduled run cannot quietly rewrite the file in your repository.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>Every executed cell succeeded</td>
</tr>
<tr>
<td>1</td>
<td>One or more cells failed, or a fatal error outside a cell</td>
</tr>
<tr>
<td>2</td>
<td>Execution timed out</td>
</tr>
<tr>
<td>3</td>
<td>Notebook file not found or unreadable</td>
</tr>
<tr>
<td>4</td>
<td>Serialization error, meaning an invalid notebook format</td>
</tr>
<tr>
<td>5</td>
<td>Missing required notebook parameters</td>
</tr>
</tbody>
</table></div>
<p>Code 5 is the one that saves you time. A required parameter with no value stops the run before a single cell executes and tells you what is missing:</p>
<div class="highlight"><pre><span></span><code>Error: Missing required notebook parameters:

  region (string)  Region to process
  reportDate (date)  Report date

Supply values with --param or use --interactive to be prompted.
</code></pre></div>

<p>By default a run keeps going past a failed cell, so one pass shows you every failure instead of only the first. <code>--fail-fast</code> stops at the first one. <code>--timeout</code> caps the whole run and defaults to 300 seconds, which is short for a report that reads a database, so pick that number on purpose rather than finding out at 6am.</p>
<h2 id="step-5-the-workflow-file">Step 5: the workflow file</h2>
<p>Here is a complete scheduled job: install the tool, run the notebook, write machine-readable results, and keep them whether the run passed or not.</p>
<div class="highlight"><pre><span></span><code><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">Nightly report</span>

<span class="nt">on</span><span class="p">:</span>
<span class="w">  </span><span class="nt">schedule</span><span class="p">:</span>
<span class="w">    </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;0</span><span class="nv"> </span><span class="s">6</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*&quot;</span>
<span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span>

<span class="nt">jobs</span><span class="p">:</span>
<span class="w">  </span><span class="nt">report</span><span class="p">:</span>
<span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">ubuntu-latest</span>
<span class="w">    </span><span class="nt">steps</span><span class="p">:</span>
<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">actions/checkout@v4</span>

<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">actions/setup-dotnet@v4</span>
<span class="w">        </span><span class="nt">with</span><span class="p">:</span>
<span class="w">          </span><span class="nt">dotnet-version</span><span class="p">:</span><span class="w"> </span><span class="s">&quot;8.0.x&quot;</span>

<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">Install the Verso CLI</span>
<span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">dotnet tool install -g Verso.Cli</span>

<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">Run the notebook</span>
<span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">|</span>
<span class="w">          </span><span class="no">verso run reports/regional.verso \</span>
<span class="w">            </span><span class="no">--param region=us-east \</span>
<span class="w">            </span><span class="no">--param reportDate=$(date -u +%Y-%m-%d) \</span>
<span class="w">            </span><span class="no">--show-parameters \</span>
<span class="w">            </span><span class="no">--output json \</span>
<span class="w">            </span><span class="no">--output-file results.json \</span>
<span class="w">            </span><span class="no">--fail-fast \</span>
<span class="w">            </span><span class="no">--timeout 900</span>
<span class="w">        </span><span class="nt">env</span><span class="p">:</span>
<span class="w">          </span><span class="nt">DB_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">${{ secrets.DB_PASSWORD }}</span>

<span class="w">      </span><span class="p p-Indicator">-</span><span class="w"> </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">Keep the results</span>
<span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">always()</span>
<span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">actions/upload-artifact@v4</span>
<span class="w">        </span><span class="nt">with</span><span class="p">:</span>
<span class="w">          </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">regional-results</span>
<span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">results.json</span>
</code></pre></div>

<p><code>--output json</code> writes the notebook path, one entry per cell carrying its index, id, language, status, elapsed time and outputs, the resolved parameters, and a summary of totals. Parse that when a later step needs to know which cell failed.</p>
<p>Notice what is not a <code>--param</code>. Command-line arguments show up in process listings, so a password belongs in the step's <code>env:</code> block and gets read from the connection string:</p>
<div class="highlight"><pre><span></span><code>#!sql-connect --name prod --connection-string &quot;Server=$var:dbServer;Database=Analytics;User Id=svc_report;Password=$env:DB_PASSWORD&quot;
</code></pre></div>

<p><code>$var:</code> reads the variable store, so it picks up a parameter. <code>$env:</code> reads the process environment. Neither value reaches the command line.</p>
<h2 id="step-6-one-notebook-four-regions">Step 6: one notebook, four regions</h2>
<p>Once the inputs are on the command line, a matrix runs the same file several ways:</p>
<div class="highlight"><pre><span></span><code><span class="w">    </span><span class="nt">strategy</span><span class="p">:</span>
<span class="w">      </span><span class="nt">fail-fast</span><span class="p">:</span><span class="w"> </span><span class="l l-Scalar l-Scalar-Plain">false</span>
<span class="w">      </span><span class="nt">matrix</span><span class="p">:</span>
<span class="w">        </span><span class="nt">region</span><span class="p">:</span><span class="w"> </span><span class="p p-Indicator">[</span><span class="nv">us-east</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">us-west</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">eu-central</span><span class="p p-Indicator">,</span><span class="w"> </span><span class="nv">ap-south</span><span class="p p-Indicator">]</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code>verso<span class="w"> </span>run<span class="w"> </span>reports/regional.verso<span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--param<span class="w"> </span><span class="nv">region</span><span class="o">=</span><span class="si">${</span><span class="p">{ matrix.region </span><span class="si">}</span><span class="o">}</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span>--output<span class="w"> </span>json<span class="w"> </span>--output-file<span class="w"> </span>results-<span class="si">${</span><span class="p">{ matrix.region </span><span class="si">}</span><span class="o">}</span>.json
</code></pre></div>

<figure class="fig">
<svg viewBox="0 0 720 232" role="img" aria-label="One notebook file feeding four verso run invocations, each with a different region parameter, each returning its own exit code">
  <rect class="box on" x="16" y="94" width="150" height="48" rx="2" stroke-width="1"/>
  <text class="label b" x="91" y="114" text-anchor="middle">regional.verso</text>
  <text class="muted" x="91" y="131" text-anchor="middle">4 parameters</text>

  <path class="brand-line" d="M166 118 H222 V34 H274" stroke-width="1.5"/>
  <path class="brand-line" d="M166 118 H222 V90 H274" stroke-width="1.5"/>
  <path class="brand-line" d="M166 118 H222 V146 H274" stroke-width="1.5"/>
  <path class="brand-line" d="M166 118 H222 V202 H274" stroke-width="1.5"/>
  <path class="brand" d="M280 34 L272 30 L272 38 Z"/>
  <path class="brand" d="M280 90 L272 86 L272 94 Z"/>
  <path class="brand" d="M280 146 L272 142 L272 150 Z"/>
  <path class="brand" d="M280 202 L272 198 L272 206 Z"/>

  <rect class="box" x="280" y="10" width="280" height="48" rx="2" stroke-width="1"/>
  <text class="code" x="292" y="40">verso run --param region=us-east</text>
  <text class="muted" x="576" y="40">exit 0</text>

  <rect class="box" x="280" y="66" width="280" height="48" rx="2" stroke-width="1"/>
  <text class="code" x="292" y="96">verso run --param region=us-west</text>
  <text class="muted" x="576" y="96">exit 0</text>

  <rect class="box" x="280" y="122" width="280" height="48" rx="2" stroke-width="1"/>
  <text class="code" x="292" y="152">verso run --param region=eu-central</text>
  <text class="muted" x="576" y="152">exit 1</text>

  <rect class="box" x="280" y="178" width="280" height="48" rx="2" stroke-width="1"/>
  <text class="code" x="292" y="208">verso run --param region=ap-south</text>
  <text class="muted" x="576" y="208">exit 0</text>
</svg>
<figcaption>One file, four runs, four exit codes. Only the failing leg turns the job red.</figcaption>
</figure>

<p><code>fail-fast: false</code> keeps the other legs running when one of them fails, so a bad region does not hide the state of the rest.</p>
<h2 id="coming-from-papermill">Coming from Papermill</h2>
<p>If you already run parameterized notebooks with <a href="https://papermill.readthedocs.io/">Papermill</a>, the shapes line up and the mechanics differ. Here is the mapping.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Papermill</th>
<th>Verso</th>
</tr>
</thead>
<tbody>
<tr>
<td>A cell tagged <code>parameters</code> holding assignments</td>
<td><code>metadata.parameters</code> with typed definitions</td>
</tr>
<tr>
<td><code>papermill in.ipynb out.ipynb</code></td>
<td><code>verso run notebook.verso</code></td>
</tr>
<tr>
<td><code>-p name value</code></td>
<td><code>--param name=value</code></td>
</tr>
<tr>
<td>An <code>injected-parameters</code> cell added after the tagged cell</td>
<td>Values coerced into the shared variable store before cell one</td>
</tr>
<tr>
<td>An executed copy of the notebook at the output path</td>
<td>Streamed output, <code>--output json</code>, or <code>--save</code></td>
</tr>
</tbody>
</table></div>
<p>The difference that matters in a pipeline is when the validation happens. Papermill's defaults are Python assignments in a cell. Verso's are declared types, so <code>--param batchSize=abc</code> fails with a parse error and a missing required name fails with exit code 5, both before any cell runs.</p>
<p>The full mapping, including Azure DevOps snippets, is in the <a href="https://www.versonotebooks.com/docs/migration/from-papermill.html">Papermill migration guide</a>.</p>
<div class="admonition warning">
<p class="admonition-title">When the run passes locally and fails on the agent</p>
<p>The usual cause is a kernel or an extension the agent does not have. Run <code>verso info</code> as a step before the notebook and it will list what was discovered in that environment. If the notebook needs an extension you build alongside it, point the run at the build output with <code>--extensions ./MyExtension/bin/Release/net8.0/</code>, a flag that <code>run</code>, <code>serve</code>, and <code>convert</code> all accept.</p>
</div>
<p>The notebook has not changed through any of this. It is the same file you opened in the editor, with a form at the top, and the pipeline is just another caller. Set one up against a report you already have, and tell me where it falls over.</p>]]></content:encoded>
  </item>
  <item>
    <title>If a built-in needs private access, the interface isn&#x27;t finished</title>
    <link>https://datafication.co/blog/2026/every-built-in-is-a-plugin.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/every-built-in-is-a-plugin.html</guid>
    <pubDate>Tue, 07 Apr 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Motus registers its own selector strategies, audit rules and hooks through the same IPluginContext you get, which is a constraint on us rather than a feature for you.</description>
    <content:encoded><![CDATA[<p>Every framework has an extension model. The list of interfaces doesn't tell you whether it's any good. What tells you is whether the framework's own features are allowed to skip it.</p>
<p>We wrote the rule into the Motus README before the first release: every built-in selector strategy, lifecycle hook, wait condition and reporter goes through the same <code>IPluginContext</code> you get, and there are no internal shortcuts. The extension documentation shipped this week with 1.0.7, so the contract is something you can read now instead of something you take on trust. Here is what that rule costs us, and why I think it is worth paying.</p>
<h2 id="what-the-door-looks-like">What the door looks like</h2>
<div class="highlight"><pre><span></span><code><span class="k">public</span><span class="w"> </span><span class="k">interface</span><span class="w"> </span><span class="n">IPluginContext</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">void</span><span class="w"> </span><span class="nf">RegisterSelectorStrategy</span><span class="p">(</span><span class="n">ISelectorStrategy</span><span class="w"> </span><span class="n">strategy</span><span class="p">);</span>
<span class="w">    </span><span class="k">void</span><span class="w"> </span><span class="nf">RegisterWaitCondition</span><span class="p">(</span><span class="n">IWaitCondition</span><span class="w"> </span><span class="n">condition</span><span class="p">);</span>
<span class="w">    </span><span class="k">void</span><span class="w"> </span><span class="nf">RegisterLifecycleHook</span><span class="p">(</span><span class="n">ILifecycleHook</span><span class="w"> </span><span class="n">hook</span><span class="p">);</span>
<span class="w">    </span><span class="k">void</span><span class="w"> </span><span class="nf">RegisterReporter</span><span class="p">(</span><span class="n">IReporter</span><span class="w"> </span><span class="n">reporter</span><span class="p">);</span>
<span class="w">    </span><span class="k">void</span><span class="w"> </span><span class="nf">RegisterAccessibilityRule</span><span class="p">(</span><span class="n">IAccessibilityRule</span><span class="w"> </span><span class="n">rule</span><span class="p">);</span>
<span class="w">    </span><span class="n">IMotusLogger</span><span class="w"> </span><span class="nf">CreateLogger</span><span class="p">(</span><span class="kt">string</span><span class="w"> </span><span class="n">categoryName</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>

<p>Six members, and five of them register something. That is the whole door. A plugin is a class that implements <code>IPlugin</code> and gets handed one of these:</p>
<div class="highlight"><pre><span></span><code><span class="k">public</span><span class="w"> </span><span class="k">interface</span><span class="w"> </span><span class="n">IPlugin</span>
<span class="p">{</span>
<span class="w">    </span><span class="kt">string</span><span class="w"> </span><span class="n">PluginId</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">get</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kt">string</span><span class="w"> </span><span class="n">Name</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">get</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kt">string</span><span class="w"> </span><span class="n">Version</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">get</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kt">string?</span><span class="w"> </span><span class="n">Author</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">get</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="kt">string?</span><span class="w"> </span><span class="n">Description</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">get</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="w">    </span><span class="n">Task</span><span class="w"> </span><span class="nf">OnLoadedAsync</span><span class="p">(</span><span class="n">IPluginContext</span><span class="w"> </span><span class="n">context</span><span class="p">);</span>
<span class="w">    </span><span class="n">Task</span><span class="w"> </span><span class="nf">OnUnloadedAsync</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="what-motus-puts-through-its-own-door">What Motus puts through its own door</h2>
<p>We build the built-ins as an ordinary array of <code>IPlugin</code>, and each one gets <code>OnLoadedAsync</code> called with the same <code>IPluginContext</code> instance a third party receives:</p>
<div class="highlight"><pre><span></span><code><span class="kt">var</span><span class="w"> </span><span class="n">builtins</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">IPlugin</span><span class="p">[]</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">new</span><span class="w"> </span><span class="nf">BuiltinSelectorsPlugin</span><span class="p">(),</span>
<span class="w">    </span><span class="k">new</span><span class="w"> </span><span class="nf">AccessibilityRulesPlugin</span><span class="p">(),</span>
<span class="w">    </span><span class="k">new</span><span class="w"> </span><span class="nf">AccessibilityAuditHook</span><span class="p">(</span><span class="n">options</span><span class="p">.</span><span class="n">Accessibility</span><span class="p">),</span>
<span class="p">};</span>
</code></pre></div>

<p><code>BuiltinSelectorsPlugin</code> makes five <code>RegisterSelectorStrategy</code> calls, one per built-in prefix. <code>AccessibilityRulesPlugin</code> makes nine <code>RegisterAccessibilityRule</code> calls, one per WCAG rule. <code>AccessibilityAuditHook</code> registers itself with <code>RegisterLifecycleHook</code>, exactly as a third-party hook would.</p>
<p>The visual runner is the one that convinced me, because it is a whole application and not just a rule or two. When <code>motus run --visual</code> sees a page, it attaches its timeline recorder like this:</p>
<div class="highlight"><pre><span></span><code><span class="n">page</span><span class="p">.</span><span class="n">Context</span><span class="p">.</span><span class="n">GetPluginContext</span><span class="p">().</span><span class="n">RegisterLifecycleHook</span><span class="p">(</span><span class="n">hook</span><span class="p">);</span>
</code></pre></div>

<p><code>GetPluginContext()</code> is a public method on <code>IBrowserContext</code>, so the runner has no more access to the engine than your own code does. The timeline you watch during a visual run is built from <code>ILifecycleHook</code> callbacks and nothing else. Anything that timeline can show you, a hook you write can see too.</p>
<figure class="fig">
<svg viewBox="0 0 720 300" role="img" aria-label="A built-in plugin and a third-party plugin both calling the same IPluginContext, which fans out to five registries">
  <rect class="box" x="40" y="12" width="300" height="98" stroke-width="1"/>
  <text class="label b" x="60" y="34">Shipped with Motus</text>
  <text class="code" x="60" y="56">BuiltinSelectorsPlugin</text>
  <text class="code" x="60" y="74">AccessibilityRulesPlugin</text>
  <text class="code" x="60" y="92">AccessibilityAuditHook</text>
  <rect class="box on" x="380" y="12" width="300" height="98" stroke-width="1"/>
  <text class="label b" x="400" y="34">Written by you</text>
  <text class="code" x="400" y="56">[MotusPlugin]</text>
  <text class="code" x="400" y="74">class AnalyticsSelectorsPlugin</text>
  <text class="code" x="400" y="92">    : IPlugin</text>
  <path class="line" d="M190 110 V132" stroke-width="1"/>
  <path class="fill" d="M185 132 L195 132 L190 141 Z"/>
  <path class="brand-line" d="M530 110 V132" stroke-width="2"/>
  <path class="brand" d="M525 132 L535 132 L530 141 Z"/>
  <rect class="box on" x="40" y="144" width="640" height="52" stroke-width="1"/>
  <text class="label b" x="360" y="168" text-anchor="middle">IPluginContext</text>
  <text class="muted" x="360" y="186" text-anchor="middle">OnLoadedAsync(context)</text>
  <path class="line" d="M100 196 V216" stroke-width="1"/>
  <path class="line" d="M230 196 V216" stroke-width="1"/>
  <path class="line" d="M360 196 V216" stroke-width="1"/>
  <path class="line" d="M490 196 V216" stroke-width="1"/>
  <path class="line" d="M620 196 V216" stroke-width="1"/>
  <rect class="box" x="41" y="216" width="118" height="56" stroke-width="1"/>
  <text class="muted" x="100" y="240" text-anchor="middle">selector</text>
  <text class="muted" x="100" y="256" text-anchor="middle">strategies</text>
  <rect class="box" x="171" y="216" width="118" height="56" stroke-width="1"/>
  <text class="muted" x="230" y="240" text-anchor="middle">wait</text>
  <text class="muted" x="230" y="256" text-anchor="middle">conditions</text>
  <rect class="box" x="301" y="216" width="118" height="56" stroke-width="1"/>
  <text class="muted" x="360" y="240" text-anchor="middle">lifecycle</text>
  <text class="muted" x="360" y="256" text-anchor="middle">hooks</text>
  <rect class="box" x="431" y="216" width="118" height="56" stroke-width="1"/>
  <text class="muted" x="490" y="248" text-anchor="middle">reporters</text>
  <rect class="box" x="561" y="216" width="118" height="56" stroke-width="1"/>
  <text class="muted" x="620" y="240" text-anchor="middle">accessibility</text>
  <text class="muted" x="620" y="256" text-anchor="middle">rules</text>
</svg>
<figcaption>Both sides enter through the same door. The registries below it cannot tell which side a registration came from.</figcaption>
</figure>

<p>Here are the five registrations, and what we put through each one:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Registration</th>
<th>What it takes</th>
<th>What Motus registers</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>RegisterSelectorStrategy</code></td>
<td><code>ISelectorStrategy</code>: a prefix, a priority, resolution, and selector generation</td>
<td>five strategies: <code>css</code>, <code>xpath</code>, <code>text</code>, <code>role</code>, <code>data-testid</code></td>
</tr>
<tr>
<td><code>RegisterWaitCondition</code></td>
<td><code>IWaitCondition</code>: a name and an <code>EvaluateAsync(IPage, ...)</code></td>
<td>nothing built-in registers one today</td>
</tr>
<tr>
<td><code>RegisterLifecycleHook</code></td>
<td><code>ILifecycleHook</code>: eight callbacks around navigation, actions, page create and close, console output, and page errors</td>
<td>the accessibility audit hook, and the visual runner's timeline recorder</td>
</tr>
<tr>
<td><code>RegisterReporter</code></td>
<td><code>IReporter</code>: run start, test start, test end, run end</td>
<td>the console, HTML, JUnit and TRX reporters implement this interface</td>
</tr>
<tr>
<td><code>RegisterAccessibilityRule</code></td>
<td><code>IAccessibilityRule</code>: a rule id, a description, and <code>Evaluate(node, context)</code></td>
<td>the nine built-in WCAG rules</td>
</tr>
</tbody>
</table></div>
<p>One interface sits beside these rather than under them. <code>IAccessibilityReporter</code> is opt-in: you register through <code>RegisterReporter</code>, and the engine checks at run time whether your reporter also implements it. All four built-in reporters do.</p>
<h2 id="how-a-plugin-gets-found">How a plugin gets found</h2>
<p><code>[MotusPlugin]</code> is a marker attribute with no constructor parameters and no properties. The work happens at compile time, where a generator finds the marked types, including ones in assemblies you reference, and writes this for you:</p>
<div class="highlight"><pre><span></span><code><span class="c1">// &lt;auto-generated/&gt;</span>
<span class="k">internal</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">MotusPluginRegistry</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[ModuleInitializer]</span>
<span class="w">    </span><span class="k">internal</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">void</span><span class="w"> </span><span class="nf">Register</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="n">PluginDiscovery</span><span class="p">.</span><span class="n">Factory</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">            </span><span class="k">new</span><span class="w"> </span><span class="n">IPlugin</span><span class="p">[]</span>
<span class="w">            </span><span class="p">{</span>
<span class="w">                </span><span class="k">new</span><span class="w"> </span><span class="k">global</span><span class="p">::</span><span class="n">Acme</span><span class="p">.</span><span class="n">AnalyticsSelectorsPlugin</span><span class="p">(),</span>
<span class="w">            </span><span class="p">};</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Nothing scans an assembly at run time. If your marked class can't be constructed that way, the build tells you: MOTUS001 for an abstract class, MOTUS002 for a class that doesn't implement <code>IPlugin</code>, MOTUS003 for a missing public parameterless constructor, MOTUS004 for a generic class. Each one names the type and says it will be skipped.</p>
<p>A strategy that finds elements by a <code>data-analytics-id</code> attribute is about as small as a plugin gets:</p>
<div class="highlight"><pre><span></span><code><span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>

<span class="k">public</span><span class="w"> </span><span class="k">sealed</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">AnalyticsIdSelectorStrategy</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">ISelectorStrategy</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">StrategyName</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;analytics-id&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="n">Priority</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="mi">45</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">Task</span><span class="o">&lt;</span><span class="n">IReadOnlyList</span><span class="o">&lt;</span><span class="n">IElementHandle</span><span class="o">&gt;&gt;</span><span class="w"> </span><span class="n">ResolveAsync</span><span class="p">(</span>
<span class="w">        </span><span class="kt">string</span><span class="w"> </span><span class="n">selector</span><span class="p">,</span><span class="w"> </span><span class="n">IFrame</span><span class="w"> </span><span class="n">frame</span><span class="p">,</span><span class="w"> </span><span class="kt">bool</span><span class="w"> </span><span class="n">pierceShadow</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">CancellationToken</span><span class="w"> </span><span class="n">ct</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">default</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="n">frame</span><span class="p">.</span><span class="n">Locator</span><span class="p">(</span><span class="s">$&quot;[data-analytics-id=&#39;{selector}&#39;]&quot;</span><span class="p">).</span><span class="n">ElementHandlesAsync</span><span class="p">();</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="o">&lt;</span><span class="kt">string?</span><span class="o">&gt;</span><span class="w"> </span><span class="n">GenerateSelector</span><span class="p">(</span><span class="n">IElementHandle</span><span class="w"> </span><span class="n">element</span><span class="p">,</span><span class="w"> </span><span class="n">CancellationToken</span><span class="w"> </span><span class="n">ct</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">default</span><span class="p">)</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="k">value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">element</span><span class="p">.</span><span class="n">GetAttributeAsync</span><span class="p">(</span><span class="s">&quot;data-analytics-id&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">ct</span><span class="p">);</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="k">value</span><span class="w"> </span><span class="k">is</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="o">?</span><span class="w"> </span><span class="k">null</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="s">$&quot;analytics-id={value}&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>

<span class="na">[MotusPlugin]</span>
<span class="k">public</span><span class="w"> </span><span class="k">sealed</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">AnalyticsSelectorsPlugin</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">IPlugin</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">PluginId</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;acme.analytics-selectors&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Name</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;Analytics id selectors&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Version</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;1.0.0&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string?</span><span class="w"> </span><span class="n">Author</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;Acme&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string?</span><span class="w"> </span><span class="n">Description</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;Resolves elements by data-analytics-id.&quot;</span><span class="p">;</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">OnLoadedAsync</span><span class="p">(</span><span class="n">IPluginContext</span><span class="w"> </span><span class="n">context</span><span class="p">)</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="n">context</span><span class="p">.</span><span class="n">RegisterSelectorStrategy</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">AnalyticsIdSelectorStrategy</span><span class="p">());</span>
<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">Task</span><span class="p">.</span><span class="n">CompletedTask</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">OnUnloadedAsync</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">Task</span><span class="p">.</span><span class="n">CompletedTask</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>After that, <code>page.Locator("analytics-id=checkout-submit")</code> resolves through your code. A selector routes to a strategy by exact prefix name, so registering <code>analytics-id</code> is what makes the prefix exist. <code>Priority</code> is a different question: it decides which strategy the recorder asks first when it has an element and needs a selector for it.</p>
<h2 id="where-this-is-not-finished">Where this is not finished</h2>
<p>Two things are worth saying out loud, because a post that only lists the parts that work is an ad.</p>
<p><code>CreateLogger</code> returns a no-op today. It hands back a shared null logger and throws away the category name, so every diagnostic a plugin writes goes nowhere. The interface is there and the pipeline behind it isn't.</p>
<p>There is also no <code>RegisterAssertion</code>. <code>LocatorAssertions</code>, <code>PageAssertions</code> and <code>ResponseAssertions</code> are sealed with internal constructors, and the retry engine behind them is internal. You can write extension methods on those sealed classes, but you can't reuse the polling and the failure-message shape that the built-in assertions use. By my own standard, the assertion surface is incomplete. It is on the list.</p>
<p>The loading path is not perfectly even either. Built-in plugins load first and their failures propagate, because a browser context without selector strategies is not much use to anyone. A plugin of yours that throws from <code>OnLoadedAsync</code> is skipped and the run continues. Built-in plugin ids are reserved before any user plugin is considered, so a plugin cannot suppress a built-in by colliding on id, and manual plugins passed through <code>LaunchOptions.Plugins</code> do win over auto-discovered ones.</p>
<h2 id="why-i-keep-the-rule">Why I keep the rule</h2>
<p>Building a feature on your own public interfaces is slower than reaching into the engine. You have to design the interface before the feature, and then keep designing it when the feature turns out to need one more callback. The test is easy to say and hard to pass: could somebody outside the company have built this with the packages we publish? What you get back is that by the time an interface goes public, something real has already leaned on it. The nine accessibility rules were the first real use of <code>IAccessibilityRule</code>, and they are the reason <code>AccessibilityAuditContext</code> exists, because page-wide checks such as duplicate ids cannot be answered from a single node.</p>
<p>Playwright's extensibility is limited to JavaScript-side selector engines and browser context options. Motus hooks are plain .NET. That is the practical difference, and the reason I can say it is that we left ourselves no choice: our own features had to go through the door first.</p>
<p>It is the same argument we made about <a href="https://datafication.co/blog/2026/every-feature-is-an-extension.html">Verso's extension model</a>, and the same rule that shaped <a href="https://datafication.co/blog/2026/motus-1-0-direct-to-the-browser.html">the transport</a>. If you find a place in <a href="https://github.com/DataficationSDK/Motus">the source</a> where a built-in has access you don't, tell me. That is a bug in the interface, not a feature of the engine.</p>]]></content:encoded>
  </item>
  <item>
    <title>One variable store, eight languages, no hand-off</title>
    <link>https://datafication.co/blog/2026/one-variable-store-eight-languages.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/one-variable-store-eight-languages.html</guid>
    <pubDate>Wed, 01 Apr 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Every kernel in a Verso notebook reads and writes the same variable store, so a value you set in C# is already there in Python, SQL and JavaScript. The interesting part is what happens to that value when it leaves the process.</description>
    <content:encoded><![CDATA[<p>Here is what I wanted out of a notebook that mixes languages: you set a value once, and every other language already has it. No command in between, and no naming it twice.</p>
<p>Today's release adds JavaScript and TypeScript cells, which brings Verso to eight languages: C#, F#, Python, JavaScript, TypeScript, PowerShell, SQL and HTTP. All eight read and write the same variable store, which the engine has had <a href="https://datafication.co/blog/2026/verso-1-0-the-first-release.html">since 1.0</a>. A C# cell assigns <code>region</code>, and the next cell down, in whatever language, already has <code>region</code>.</p>
<p>That's the part you feel at the keyboard. The part worth reading past the headline is what happens to a value when it has to leave the process to reach the cell that wants it.</p>
<h2 id="one-store-and-every-kernel-holds-it">One store, and every kernel holds it</h2>
<p>There is one variable store per notebook session, and every kernel receives that same instance through the context it is handed at execution time. There is no per-kernel isolation, and names are compared without regard to case.</p>
<p>From your own cells it is six methods and an event:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Member</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Set(name, value)</code></td>
<td>Upsert. Fires <code>OnVariablesChanged</code>.</td>
</tr>
<tr>
<td><code>Get&lt;T&gt;(name)</code></td>
<td>The value cast to <code>T</code>, or <code>default</code> if missing or the wrong type.</td>
</tr>
<tr>
<td><code>TryGet&lt;T&gt;(name, out value)</code></td>
<td>The same, with the type check surfaced.</td>
</tr>
<tr>
<td><code>GetAll()</code></td>
<td>Every variable as <code>VariableDescriptor(Name, Value, Type)</code>.</td>
</tr>
<tr>
<td><code>Remove(name)</code></td>
<td>Remove one. Fires <code>OnVariablesChanged</code>.</td>
</tr>
<tr>
<td><code>Clear()</code></td>
<td>Remove all. Fires <code>OnVariablesChanged</code>.</td>
</tr>
</tbody>
</table></div>
<p>Kernels use it in two directions. On the way in, a kernel reads what it needs. On the way out, it publishes what you defined: the C# kernel hands the store your top-level variables once the cell finishes, which is why an ordinary <code>var</code> needs no ceremony to become visible elsewhere.</p>
<p>The variables panel follows <code>OnVariablesChanged</code>, so it repaints as values land. Notebook parameters, also new today, are written into the same store before the first cell runs, so a parameter is just a variable that was already there.</p>
<p>One thing the store will not take is null. <code>Set</code> throws on a null value, so a name either has a value or does not exist. Worth knowing before you hand an F# <code>None</code> across.</p>
<h2 id="four-cells-one-value">Four cells, one value</h2>
<div class="highlight"><pre><span></span><code><span class="c1">// C# cell</span>
<span class="kt">var</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;us-east&quot;</span><span class="p">;</span>
<span class="kt">var</span><span class="w"> </span><span class="n">threshold</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mf">0.95</span><span class="p">;</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code><span class="c1">--connection reporting --name services</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">uptime</span>
<span class="k">FROM</span><span class="w">   </span><span class="n">services</span>
<span class="k">WHERE</span><span class="w">  </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">@</span><span class="n">region</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">uptime</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="o">@</span><span class="n">threshold</span>
</code></pre></div>

<p>The SQL kernel resolves <code>@region</code> and <code>@threshold</code> from the store, and puts the result back under the name given by <code>--name</code>, or under <code>lastSqlResult</code>.</p>
<div class="highlight"><pre><span></span><code><span class="c1"># Python cell</span>
<span class="nb">print</span><span class="p">(</span><span class="n">region</span><span class="p">,</span> <span class="n">threshold</span><span class="p">)</span>
<span class="n">alert_count</span> <span class="o">=</span> <span class="mi">3</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code><span class="c1">// JavaScript cell</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">summary</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">region</span><span class="p">,</span><span class="w"> </span><span class="nx">threshold</span><span class="p">,</span><span class="w"> </span><span class="nx">alertCount</span><span class="o">:</span><span class="w"> </span><span class="nx">alert_count</span><span class="w"> </span><span class="p">};</span>
</code></pre></div>

<p>And back in C#, <code>summary</code> is waiting:</p>
<div class="highlight"><pre><span></span><code><span class="c1">// C# cell</span>
<span class="kt">var</span><span class="w"> </span><span class="n">summary</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Variables</span><span class="p">.</span><span class="n">Get</span><span class="o">&lt;</span><span class="n">Dictionary</span><span class="o">&lt;</span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="kt">object</span><span class="o">&gt;&gt;</span><span class="p">(</span><span class="s">&quot;summary&quot;</span><span class="p">);</span>
</code></pre></div>

<p>An HTTP cell joins in too: <code>{{region}}</code> resolves from the store after file-level <code>@variables</code> and dynamic variables, and the response is written back as <code>httpResponse</code> and <code>httpStatus</code>.</p>
<h2 id="where-the-boundary-actually-is">Where the boundary actually is</h2>
<figure class="fig">
<svg viewBox="0 0 720 268" role="img" aria-label="Cells writing into a single variable store, which feeds three groups of kernels: .NET kernels sharing the same object, Python receiving converted native types, and JavaScript receiving JSON across a subprocess boundary">
  <rect class="box" x="8" y="8" width="228" height="34" stroke-width="1"/>
  <text class="label b" x="122" y="30" text-anchor="middle">C# cell sets region</text>
  <rect class="box" x="246" y="8" width="228" height="34" stroke-width="1"/>
  <text class="label b" x="360" y="30" text-anchor="middle">SQL cell sets services</text>
  <rect class="box" x="484" y="8" width="228" height="34" stroke-width="1"/>
  <text class="label b" x="598" y="30" text-anchor="middle">HTTP cell sets httpStatus</text>
  <path class="brand-line" d="M122 42 V66" stroke-width="2"/>
  <path class="brand" d="M122 76 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M360 42 V66" stroke-width="2"/>
  <path class="brand" d="M360 76 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M598 42 V66" stroke-width="2"/>
  <path class="brand" d="M598 76 l-5 -10 h10 Z"/>
  <rect class="box on" x="8" y="78" width="704" height="42" stroke-width="1.5"/>
  <text class="label b" x="360" y="96" text-anchor="middle">VariableStore</text>
  <text class="code" x="360" y="112" text-anchor="middle">one per session, case-insensitive, no isolation</text>
  <path class="brand-line" d="M122 120 V150" stroke-width="2"/>
  <path class="brand" d="M122 160 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M360 120 V150" stroke-width="2"/>
  <path class="brand" d="M360 160 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M598 120 V150" stroke-width="2"/>
  <path class="brand" d="M598 160 l-5 -10 h10 Z"/>
  <rect class="box" x="8" y="162" width="228" height="54" stroke-width="1"/>
  <text class="label b" x="122" y="182" text-anchor="middle">F#, PowerShell, SQL</text>
  <text class="code" x="122" y="200" text-anchor="middle">the same CLR object</text>
  <rect class="box" x="246" y="162" width="228" height="54" stroke-width="1"/>
  <text class="label b" x="360" y="182" text-anchor="middle">Python</text>
  <text class="code" x="360" y="200" text-anchor="middle">converted to native Python types</text>
  <rect class="box on" x="484" y="162" width="228" height="54" stroke-width="1.5"/>
  <text class="label b" x="598" y="182" text-anchor="middle">JavaScript, TypeScript</text>
  <text class="code" x="598" y="200" text-anchor="middle">JSON to a Node subprocess</text>
  <path class="line" d="M479 150 V246" stroke-width="1.5" stroke-dasharray="4 4"/>
  <text class="muted" x="484" y="240">PROCESS BOUNDARY</text>
</svg>
<figcaption>One dictionary. What a kernel sees depends on how far the value has to travel.</figcaption>
</figure>

<p>Inside the engine's process the store holds the object itself, so an F#, PowerShell or SQL cell sees the same instance your C# cell created. The Python kernel is in that process too, and converts on the way across: primitives, lists and dictionaries become native Python <code>list</code> and <code>dict</code> so slicing and iteration behave as expected, and anything else arrives as the .NET object it is.</p>
<p>JavaScript is the one that leaves. With Node.js on the machine, cells run in a persistent subprocess and a variable reaches it as JSON, and your top-level declarations are promoted back onto <code>globalThis</code> so they survive to the next cell. Without Node, cells fall back to Jint, a pure .NET ES2024 interpreter, over the same bridge.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Value in the store</th>
<th>In a Python cell</th>
<th>In a JavaScript cell</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>string</code>, <code>long</code>, <code>double</code>, <code>bool</code></td>
<td>The matching Python type</td>
<td>The matching JSON type</td>
</tr>
<tr>
<td>A list or dictionary</td>
<td>A native <code>list</code> or <code>dict</code></td>
<td>An array or object</td>
</tr>
<tr>
<td>Any other CLR object</td>
<td>The object itself</td>
<td>Whatever <code>System.Text.Json</code> makes of it, or skipped</td>
</tr>
<tr>
<td>Delegate, <code>Task</code>, <code>CancellationToken</code></td>
<td>Skipped</td>
<td>Skipped, along with <code>IAsyncDisposable</code></td>
</tr>
</tbody>
</table></div>
<p>Coming back, a JavaScript object arrives in .NET as a <code>Dictionary&lt;string, object&gt;</code>, an array as a <code>List&lt;object&gt;</code>, and a number as a <code>long</code> when it fits or a <code>double</code> when it does not. Python publishes back everything in its scope that is not a module, not callable and not underscore-prefixed.</p>
<p>Names beginning with <code>__verso_</code> are held back from JavaScript. That prefix is where magic commands keep their own side-channel data, and none of that belongs in someone's script.</p>
<h2 id="how-other-notebooks-do-it">How other notebooks do it</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>Verso</th>
<th>Polyglot Notebooks</th>
<th>Jupyter</th>
</tr>
</thead>
<tbody>
<tr>
<td>How a value moves</td>
<td>One <code>VariableStore</code> per session, handed to every kernel</td>
<td><code>#!share</code> pulls a named variable from a named kernel; <code>#!set</code> creates a copy of one in the current kernel</td>
<td>An open notebook has one interactive session connected to one kernel</td>
</tr>
<tr>
<td>What you type</td>
<td>Nothing; the name is already bound</td>
<td>The command and the source, for example <code>#!set --name csVarFromJs --value @javascript:jsVar</code></td>
<td>Not applicable</td>
</tr>
<tr>
<td>Copy or reference</td>
<td>The object itself in process, JSON across the JavaScript subprocess</td>
<td>Documented as a copy, by default through the <code>application/json</code> MIME type, serialized with <code>System.Text.Json</code> for .NET kernels. Reference sharing needs both kernels in the same process, both CLR-based, and <code>--byref</code> on <code>#!set</code></td>
<td>One kernel's own variables. Several front ends can attach to that kernel and see the same ones</td>
</tr>
<tr>
<td>Languages in one file</td>
<td>Eight</td>
<td>Several, and the project notes that not all kernels support variable sharing</td>
<td>One, recorded as a single <code>kernelspec</code> in the notebook metadata</td>
</tr>
</tbody>
</table></div>
<p>I took those two columns from the Polyglot Notebooks variable sharing and magic command docs, and from the Jupyter docs and <code>nbformat</code> description. Copies buy something real, by the way: a JSON copy cannot be mutated out from under the kernel that produced it.</p>
<p>What we were after was a mixed-language notebook that stops feeling like an integration. There is no <code>#!share</code> in Verso and no <code>#!set</code>, and that is deliberate. If moving a value costs a line of ceremony, most people stop moving values, and a multi-language notebook turns into a single-language notebook with commentary.</p>
<p>The cost is that the store is flat: a name set in one cell can be overwritten by a cell in another language. That is the bargain a REPL session already makes, and we will take it.</p>
<p>Give it a run with the languages you actually use. If a value crosses over in a shape that surprises you, that's the report I want to see.</p>]]></content:encoded>
  </item>
  <item>
    <title>Motus 1.0: browser testing without the middleman</title>
    <link>https://datafication.co/blog/2026/motus-1-0-direct-to-the-browser.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/motus-1-0-direct-to-the-browser.html</guid>
    <pubDate>Sun, 29 Mar 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Motus</category>
    <description>Motus is a new MIT-licensed browser automation and testing framework for .NET, and your test talks to Chrome, Edge or Firefox directly: no Node.js sidecar in between and no driver binary to install.</description>
    <content:encoded><![CDATA[<p>Today we're shipping Motus 1.0. If you've written browser tests in .NET, you've probably lived with a Node process or a driver binary sitting between your code and the browser, and you've probably lost an afternoon to one of them in CI. Motus doesn't have one. Your test talks to Chrome, Edge or Firefox directly, over the same protocols the browser's own dev tools use: the Chrome DevTools Protocol for Chromium, WebDriver BiDi for Firefox.</p>
<p>It's MIT licensed and on NuGet today. The first published version is 1.0.1, and ten packages went out with it.</p>
<h2 id="why-theres-nothing-in-the-middle">Why there's nothing in the middle</h2>
<p>A .NET automation framework usually shows up one of two ways. It wraps a tool from another ecosystem behind a process boundary, or it grows an extension model after the fact. Playwright for .NET ships a bundled Node.js process that acts as the automation server, reached over a named-pipe IPC channel. Selenium WebDriver sends every command as an HTTP round trip to a driver binary such as chromedriver or geckodriver, which you install, pin, and keep in step with the browser.</p>
<p>Both work. Both also put something between your code and the thing it is trying to control, and that something has to be installed on every build agent. We said in <a href="https://datafication.co/blog/2026/four-rules-before-our-first-open-source-release.html">our four rules</a> that a tool built for .NET should install with <code>dotnet add package</code> and run inside your process. Motus is that rule pointed at a browser.</p>
<h2 id="whats-between-your-code-and-the-browser">What's between your code and the browser</h2>
<figure class="fig">
<svg viewBox="0 0 720 336" role="img" aria-label="Five stacked layers from test code down to the browser process, with the transport layer split between a CDP transport for Chromium and a BiDi transport for Firefox over one WebSocket">
  <rect class="box" x="40" y="12" width="640" height="46" stroke-width="1"/>
  <text class="label b" x="360" y="32" text-anchor="middle">Test code</text>
  <text class="muted" x="360" y="48" text-anchor="middle">MSTest / xUnit / NUnit</text>
  <path class="line" d="M360 58 V70" stroke-width="1"/>
  <rect class="box" x="40" y="70" width="640" height="46" stroke-width="1"/>
  <text class="label b" x="360" y="90" text-anchor="middle">Assertions and locators</text>
  <text class="muted" x="360" y="106" text-anchor="middle">Expect.That(...)  page.Locator(...)</text>
  <path class="line" d="M360 116 V128" stroke-width="1"/>
  <rect class="box" x="40" y="128" width="640" height="46" stroke-width="1"/>
  <text class="label b" x="360" y="148" text-anchor="middle">Page / BrowserContext / IBrowser</text>
  <text class="muted" x="360" y="164" text-anchor="middle">navigation, interception, lifecycle hooks</text>
  <path class="line" d="M360 174 V186" stroke-width="1"/>
  <rect class="box on" x="40" y="186" width="640" height="94" stroke-width="1"/>
  <text class="label b" x="360" y="206" text-anchor="middle">Transport</text>
  <rect class="soft" x="62" y="216" width="290" height="34"/>
  <text class="code" x="207" y="237" text-anchor="middle">CdpTransport (Chromium)</text>
  <rect class="soft" x="368" y="216" width="290" height="34"/>
  <text class="code" x="513" y="237" text-anchor="middle">BiDiTransport (Firefox)</text>
  <text class="muted" x="360" y="268" text-anchor="middle">CdpSocket: one ClientWebSocket</text>
  <path class="brand-line" d="M360 280 V292" stroke-width="2"/>
  <rect class="box" x="40" y="292" width="640" height="42" stroke-width="1"/>
  <text class="label b" x="360" y="312" text-anchor="middle">Browser process</text>
  <text class="muted" x="360" y="327" text-anchor="middle">launched or connected to</text>
</svg>
<figcaption>Four layers of Motus over the browser process. Everything above the browser runs in your .NET process.</figcaption>
</figure>

<p>Under the hood there's one WebSocket, opened from your process and kept for the whole run. Every page and worker the browser reports comes back over that same connection, so there's one thing to reason about and nothing extra to install.</p>
<p>The protocol types are written at build time instead of discovered at run time, which keeps reflection out of the engine and out of the abstractions layer. If you publish ahead-of-time, that's the part you care about: the library publishes AOT with no trimming warnings of its own.</p>
<h2 id="getting-it-installed">Getting it installed</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus.Testing.MSTest<span class="w">   </span><span class="c1"># or Motus.Testing.xUnit / Motus.Testing.NUnit</span>
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Motus.Analyzers<span class="w">        </span><span class="c1"># optional</span>
dotnet<span class="w"> </span>tool<span class="w"> </span>install<span class="w"> </span>--global<span class="w"> </span>Motus.Cli
motus<span class="w"> </span>install
</code></pre></div>

<p><code>motus install</code> downloads a Chromium build into <code>~/.motus/browsers</code>. From there, a first MSTest suite is an assembly initializer and a test class:</p>
<div class="highlight"><pre><span></span><code><span class="c1">// AssemblySetup.cs</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Abstractions</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Testing.MSTest</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">AssemblySetup</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[AssemblyInitialize]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Initialize</span><span class="p">(</span><span class="n">TestContext</span><span class="w"> </span><span class="n">_</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">LaunchBrowserAsync</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">LaunchOptions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">Headless</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="w"> </span><span class="p">});</span>

<span class="w">    </span><span class="na">[AssemblyCleanup]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">static</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">Cleanup</span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">MotusTestBase</span><span class="p">.</span><span class="n">CloseBrowserAsync</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div>

<div class="highlight"><pre><span></span><code><span class="c1">// SearchTests.cs</span>
<span class="k">using</span><span class="w"> </span><span class="nn">Motus.Testing.MSTest</span><span class="p">;</span>
<span class="k">using</span><span class="w"> </span><span class="nn">static</span><span class="w"> </span><span class="n">Motus</span><span class="p">.</span><span class="n">Assertions</span><span class="p">.</span><span class="n">Expect</span><span class="p">;</span>

<span class="na">[TestClass]</span>
<span class="k">public</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">SearchTests</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">MotusTestBase</span>
<span class="p">{</span>
<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">PageHasTitle</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://example.com&quot;</span><span class="p">);</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToHaveTitleAsync</span><span class="p">(</span><span class="s">&quot;Example Domain&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>

<span class="w">    </span><span class="na">[TestMethod]</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="w"> </span><span class="nf">ClickLink</span><span class="p">()</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GotoAsync</span><span class="p">(</span><span class="s">&quot;https://example.com&quot;</span><span class="p">);</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">Page</span><span class="p">.</span><span class="n">GetByRole</span><span class="p">(</span><span class="s">&quot;link&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;More information...&quot;</span><span class="p">).</span><span class="n">ClickAsync</span><span class="p">();</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="nf">That</span><span class="p">(</span><span class="n">Page</span><span class="p">).</span><span class="n">ToHaveUrlAsync</span><span class="p">(</span><span class="s">&quot;*iana.org*&quot;</span><span class="p">);</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><code>dotnet test</code> runs it. So does <code>motus run ./bin/Debug/net8.0/MyTests.dll --workers auto --reporter console</code>, which is the CLI's own discovery and parallel runner.</p>
<h2 id="the-ten-packages">The ten packages</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Package</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Motus</code></td>
<td>Engine: transport, browsers, pages, locators, assertions</td>
</tr>
<tr>
<td><code>Motus.Abstractions</code></td>
<td>Public interfaces and types, zero dependencies</td>
</tr>
<tr>
<td><code>Motus.Codegen</code></td>
<td>Source generator for protocol types and plugin discovery</td>
</tr>
<tr>
<td><code>Motus.Analyzers</code></td>
<td>Roslyn diagnostics and code fixes</td>
</tr>
<tr>
<td><code>Motus.Recorder</code></td>
<td>Action capture, selector inference, page object generation</td>
</tr>
<tr>
<td><code>Motus.Testing</code></td>
<td>Shared base types for the framework integrations</td>
</tr>
<tr>
<td><code>Motus.Testing.MSTest</code></td>
<td>MSTest integration</td>
</tr>
<tr>
<td><code>Motus.Testing.xUnit</code></td>
<td>xUnit integration</td>
</tr>
<tr>
<td><code>Motus.Testing.NUnit</code></td>
<td>NUnit integration</td>
</tr>
<tr>
<td><code>Motus.Cli</code></td>
<td>The <code>motus</code> global tool</td>
</tr>
</tbody>
</table></div>
<p>All ten are at 1.0.1, MIT licensed, targeting .NET 8 and .NET 10.</p>
<div class="admonition note">
<p class="admonition-title">A name collision on NuGet</p>
<p>An unrelated motion-planning library also publishes under the Motus name. Use the package page at <a href="https://www.nuget.org/packages/Motus">nuget.org/packages/Motus</a> rather than searching.</p>
</div>
<h2 id="how-it-compares">How it compares</h2>
<p>Here's how it stacks up. I've kept this to things you can check yourself.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Aspect</th>
<th>Motus</th>
<th>Playwright for .NET</th>
<th>Selenium WebDriver</th>
</tr>
</thead>
<tbody>
<tr>
<td>Between your code and the browser</td>
<td>a WebSocket your process owns</td>
<td>a bundled Node.js automation server over a named-pipe IPC channel</td>
<td>a driver binary reached over W3C WebDriver HTTP</td>
</tr>
<tr>
<td>Driver binaries to install and pin</td>
<td>none</td>
<td>not compared</td>
<td>chromedriver, geckodriver</td>
</tr>
<tr>
<td>Element references</td>
<td>locators re-resolve on each action</td>
<td>not compared</td>
<td><code>IWebElement</code> handles go stale and throw <code>StaleElementReferenceException</code></td>
</tr>
<tr>
<td>Waiting</td>
<td>actionability checks inside each action</td>
<td>not compared</td>
<td><code>WebDriverWait</code>, implicit waits, or <code>ExpectedConditions</code></td>
</tr>
<tr>
<td>API shape</td>
<td>async throughout</td>
<td>not compared</td>
<td>synchronous</td>
</tr>
<tr>
<td>Extension model</td>
<td>.NET plugins through <code>IPluginContext</code></td>
<td>JavaScript-side selector engines and browser context options</td>
<td>not compared</td>
</tr>
</tbody>
</table></div>
<p>I haven't benchmarked Motus against either one, so there's no speed claim in this post.</p>
<h2 id="what-else-shipped">What else shipped</h2>
<p>Flaky browser tests usually trace back to a short list of mistakes. <code>Motus.Analyzers</code> adds seven compile-time diagnostics, MOT001 to MOT007, for the ones we keep running into: an un-awaited async call, a hardcoded delay, a fragile selector, a browser or context not disposed with <code>await using</code>, an unused locator result, an old selector prefix, and a navigation with no wait after it. Three carry a code fix, and all three support Fix All.</p>
<p>The rest of the tooling lives in the CLI. <code>motus record</code> opens a headed browser, watches what you do, and writes a compilable test class. <code>motus codegen &lt;url&gt;</code> crawls a live page and emits a typed page object. <code>motus run --visual</code> starts a runner in your browser with a timeline instead of printing to the terminal. <code>motus screenshot</code> and <code>motus pdf</code> capture a page in one command.</p>
<h2 id="what-to-watch-for">What to watch for</h2>
<p>Chromium and Firefox are the whole browser list, and the two transports are not equal. Target multiplexing, network interception, emulation overrides, and tracing are CDP features, so asking for one on a Firefox session throws a <code>NotSupportedException</code> that names the feature and the transport. Locators, actions, assertions, script evaluation, and BiDi's own network interception work on both.</p>
<p>The <a href="https://github.com/DataficationSDK/Motus">source</a> and the notes for the <a href="https://github.com/DataficationSDK/Motus/releases/tag/motus-v1.0.1">1.0.1 release</a> are on GitHub. Give it a try, and if the plugin model is missing something you need, that's the issue I most want to see.</p>]]></content:encoded>
  </item>
  <item>
    <title>Every Verso feature is an extension, and a Perl kernel proves it</title>
    <link>https://datafication.co/blog/2026/every-feature-is-an-extension.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/every-feature-is-an-extension.html</guid>
    <pubDate>Sat, 14 Mar 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>Verso&#x27;s C# kernel, dark theme and dashboard layout are extensions written on the same public interfaces your package would use. Today&#x27;s release adds a Perl kernel sample, which is a cheaper way to test that claim than another manifesto.</description>
    <content:encoded><![CDATA[<p>A month ago we published <a href="https://datafication.co/blog/2026/four-rules-before-our-first-open-source-release.html">four rules</a> for Verso. The second one was that every built-in feature has to be implemented on the same public interfaces any third party gets, with no private APIs for our own code.</p>
<p>Rules like that are easy to write and easy to quietly stop keeping. What I like about this one is that you can check it. Point at a built-in, ask which public interface it implements, and see whether the answer is a real name from a package anyone can install.</p>
<p>Here is the whole list, as of today's release.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Interface</th>
<th>Built-ins that implement it</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ILanguageKernel</code></td>
<td>C# (Roslyn), F#, Python, PowerShell, SQL, HTTP</td>
</tr>
<tr>
<td><code>ICellRenderer</code></td>
<td>Markdown (Markdig), the SQL result renderer, HTML, Mermaid</td>
</tr>
<tr>
<td><code>ICellType</code></td>
<td>SQL cells, HTML cells, Mermaid cells, HTTP cells</td>
</tr>
<tr>
<td><code>IToolbarAction</code></td>
<td>Run Cell, Run All, Restart Kernel, Switch Theme, Export CSV, and the rest of the toolbar</td>
</tr>
<tr>
<td><code>IDataFormatter</code></td>
<td>Primitives, collections, the object tree, F# types, SQL result sets</td>
</tr>
<tr>
<td><code>IMagicCommand</code></td>
<td><code>#!time</code>, <code>#!nuget</code>, <code>#!extension</code>, <code>#!pip</code>, <code>#!sql-connect</code> and friends</td>
</tr>
<tr>
<td><code>ITheme</code></td>
<td>Verso Light, Verso Dark, Verso High Contrast</td>
</tr>
<tr>
<td><code>ILayoutEngine</code></td>
<td>Notebook, Dashboard, Presentation</td>
</tr>
<tr>
<td><code>INotebookSerializer</code></td>
<td>The <code>.verso</code>, <code>.ipynb</code> and <code>.dib</code> readers</td>
</tr>
<tr>
<td><code>INotebookPostProcessor</code></td>
<td>The F# and SQL import hooks that rewrite a notebook after it is read</td>
</tr>
<tr>
<td><code>ICellInteractionHandler</code></td>
<td>Nothing yet</td>
</tr>
<tr>
<td><code>IExtensionSettings</code></td>
<td>The F# kernel's warning level, language version and display limits</td>
</tr>
</tbody>
</table></div>
<p>The last two are supplemental: they get implemented alongside a primary capability rather than standing on their own. One of them, <code>ICellInteractionHandler</code>, has no built-in behind it yet. I am leaving that row in, because an interface with no first-party consumer is an interface nobody on our side has had to live with.</p>
<h2 id="the-cheapest-proof-i-could-think-of">The cheapest proof I could think of</h2>
<p>Today's release, 1.0.9, adds a sample called <code>Verso.Sample.Perl</code>. It is a language kernel for Perl, and it works the only way a kernel for Perl reasonably can: it writes the cell to a temporary <code>.pl</code> file and runs the system <code>perl</code> against it.</p>
<div class="highlight"><pre><span></span><code><span class="na">[VersoExtension]</span>
<span class="k">public</span><span class="w"> </span><span class="k">sealed</span><span class="w"> </span><span class="k">class</span><span class="w"> </span><span class="nc">PerlKernel</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="n">ILanguageKernel</span>
<span class="p">{</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">ExtensionId</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;com.verso.sample.perl&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Name</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;Perl Kernel&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">Version</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;1.0.0&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">LanguageId</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;perl&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="n">DisplayName</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="s">&quot;Perl&quot;</span><span class="p">;</span>
<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="n">IReadOnlyList</span><span class="o">&lt;</span><span class="kt">string</span><span class="o">&gt;</span><span class="w"> </span><span class="n">FileExtensions</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="k">new</span><span class="p">[]</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s">&quot;.pl&quot;</span><span class="p">,</span><span class="w"> </span><span class="s">&quot;.pm&quot;</span><span class="w"> </span><span class="p">};</span>

<span class="w">    </span><span class="k">public</span><span class="w"> </span><span class="k">async</span><span class="w"> </span><span class="n">Task</span><span class="o">&lt;</span><span class="n">IReadOnlyList</span><span class="o">&lt;</span><span class="n">CellOutput</span><span class="o">&gt;&gt;</span><span class="w"> </span><span class="n">ExecuteAsync</span><span class="p">(</span>
<span class="w">        </span><span class="kt">string</span><span class="w"> </span><span class="n">code</span><span class="p">,</span><span class="w"> </span><span class="n">IExecutionContext</span><span class="w"> </span><span class="n">context</span><span class="p">)</span>
<span class="w">    </span><span class="p">{</span>
<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="n">tempFile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Path</span><span class="p">.</span><span class="n">GetTempFileName</span><span class="p">()</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="s">&quot;.pl&quot;</span><span class="p">;</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">File</span><span class="p">.</span><span class="n">WriteAllTextAsync</span><span class="p">(</span><span class="n">tempFile</span><span class="p">,</span><span class="w"> </span><span class="n">code</span><span class="p">,</span><span class="w"> </span><span class="n">context</span><span class="p">.</span><span class="n">CancellationToken</span><span class="p">);</span>

<span class="w">        </span><span class="k">using</span><span class="w"> </span><span class="nn">var</span><span class="w"> </span><span class="n">process</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Process</span><span class="p">.</span><span class="n">Start</span><span class="p">(</span><span class="k">new</span><span class="w"> </span><span class="n">ProcessStartInfo</span><span class="p">(</span><span class="s">&quot;perl&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">tempFile</span><span class="p">)</span>
<span class="w">        </span><span class="p">{</span>
<span class="w">            </span><span class="n">RedirectStandardOutput</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">            </span><span class="n">RedirectStandardError</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span>
<span class="w">            </span><span class="n">UseShellExecute</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="p">,</span>
<span class="w">            </span><span class="n">CreateNoWindow</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span>
<span class="w">        </span><span class="p">})</span><span class="o">!</span><span class="p">;</span>

<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="k">using</span><span class="w"> </span><span class="nn">var</span><span class="w"> </span><span class="n">registration</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">context</span><span class="p">.</span><span class="n">CancellationToken</span><span class="p">.</span><span class="n">Register</span><span class="p">(</span>
<span class="w">            </span><span class="p">()</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="n">process</span><span class="p">.</span><span class="n">Kill</span><span class="p">(</span><span class="n">entireProcessTree</span><span class="p">:</span><span class="w"> </span><span class="k">true</span><span class="p">));</span>

<span class="w">        </span><span class="kt">var</span><span class="w"> </span><span class="n">stdout</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">await</span><span class="w"> </span><span class="n">process</span><span class="p">.</span><span class="n">StandardOutput</span><span class="p">.</span><span class="n">ReadToEndAsync</span><span class="p">();</span>
<span class="w">        </span><span class="k">await</span><span class="w"> </span><span class="n">process</span><span class="p">.</span><span class="n">WaitForExitAsync</span><span class="p">(</span><span class="n">context</span><span class="p">.</span><span class="n">CancellationToken</span><span class="p">);</span>

<span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="k">new</span><span class="p">[]</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">CellOutput</span><span class="p">(</span><span class="s">&quot;text/plain&quot;</span><span class="p">,</span><span class="w"> </span><span class="n">stdout</span><span class="p">)</span><span class="w"> </span><span class="p">};</span>
<span class="w">    </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>That is abridged. The real file also captures standard error as an error output, deletes the temporary file, and reports cancellation. The point is what is not in it. No registration call into the engine. No switch statement somewhere that knows Perl exists. No entry in a list of blessed languages. The host finds the class because it carries <code>[VersoExtension]</code> and implements <code>ILanguageKernel</code>, and from that moment Perl is a choice in the cell language picker.</p>
<p>The rest of the interface is the more interesting part, because <code>ILanguageKernel</code> asks for more than execution. <code>GetDiagnosticsAsync</code> runs <code>perl -c</code> on the same temporary file and turns the compiler's complaint into a <code>Diagnostic</code> on the right line. <code>GetCompletionsAsync</code> returns a list of snippets. <code>GetHoverInfoAsync</code> reports the interpreter version detected during <code>InitializeAsync</code>. So you get squiggles, completions and hover in a Perl cell, out of a sample of about two hundred and thirty lines that references exactly one package.</p>
<p>It is a sample, not a supported kernel. Its job is to be the thing we would have to delete if the claim stopped being true.</p>
<h2 id="what-the-rule-costs">What the rule costs</h2>
<p>The bill arrives in the loader. Because a built-in and a stranger's package implement the same interfaces, the engine has to be able to load both, and they don't want the same treatment.</p>
<figure class="fig">
<svg viewBox="0 0 720 250" role="img" aria-label="Two loading paths for extensions, built-in and third party, both passing a compatibility check and both resolving Verso.Abstractions from the host">
  <rect class="box" x="8" y="8" width="336" height="34" stroke-width="1"/>
  <text class="label b" x="176" y="30" text-anchor="middle">Built in, co-deployed with the engine</text>
  <rect class="box on" x="376" y="8" width="336" height="34" stroke-width="1.5"/>
  <text class="label b" x="544" y="30" text-anchor="middle">Third party, by path or package</text>
  <path class="brand-line" d="M176 42 V70" stroke-width="2"/>
  <path class="brand" d="M176 80 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M544 42 V70" stroke-width="2"/>
  <path class="brand" d="M544 80 l-5 -10 h10 Z"/>
  <rect class="soft" x="8" y="82" width="704" height="34"/>
  <text class="label b" x="360" y="104" text-anchor="middle">Does it reference Verso.Abstractions, and at a version this host accepts?</text>
  <path class="brand-line" d="M176 116 V144" stroke-width="2"/>
  <path class="brand" d="M176 154 l-5 -10 h10 Z"/>
  <path class="brand-line" d="M544 116 V144" stroke-width="2"/>
  <path class="brand" d="M544 154 l-5 -10 h10 Z"/>
  <rect class="box" x="8" y="156" width="336" height="46" stroke-width="1"/>
  <text class="label b" x="176" y="176" text-anchor="middle">Default AssemblyLoadContext</text>
  <text class="code" x="176" y="192" text-anchor="middle">Assembly.LoadFrom, no isolation</text>
  <rect class="box on" x="376" y="156" width="336" height="46" stroke-width="1.5"/>
  <text class="label b" x="544" y="176" text-anchor="middle">ExtensionLoadContext</text>
  <text class="code" x="544" y="192" text-anchor="middle">collectible, own dependency resolver</text>
  <path class="brand-line" d="M176 202 V222 H544 V202" stroke-width="2"/>
  <rect class="box" x="184" y="216" width="352" height="26" stroke-width="1"/>
  <text class="label b" x="360" y="234" text-anchor="middle">Verso.Abstractions, the host's own copy</text>
</svg>
<figcaption>Two ways in, and the same interface types at the end of both.</figcaption>
</figure>

<p>Built-in extensions sit next to the engine and load with no isolation. They were compiled against the same <code>Verso.Abstractions</code> the host is running, so isolating them would buy nothing. A third-party extension gets its own load context, which can be unloaded, and which resolves the extension's own dependencies out of its own folder rather than fighting with the host's.</p>
<p>That isolation creates the problem it is supposed to solve. An extension compiled against its own copy of <code>Verso.Abstractions</code> would produce an <code>ILanguageKernel</code> that is not the host's <code>ILanguageKernel</code>, and the cast would fail for reasons nobody enjoys debugging. So the load context intercepts that one assembly name and hands back the host's own instance, whatever version the extension was built against.</p>
<p>Which then needs a rule about versions. This release adds a compatibility check that reads the referenced version out of the assembly and compares it with the host's. A different major version is refused outright. A higher minor version is refused with a message asking for a newer host, because the extension may be using interface members this host has never heard of. Patch versions are not compared at all. An incompatible extension is skipped during discovery, or throws when you load it by path and ask for it directly.</p>
<h2 id="what-it-is-like-to-write-one">What it is like to write one</h2>
<p>None of that is visible when you write one. The template scaffolds a project that references <code>Verso.Abstractions</code> and nothing else:</p>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>new<span class="w"> </span>install<span class="w"> </span>Verso.Templates
dotnet<span class="w"> </span>new<span class="w"> </span>verso-extension<span class="w"> </span>-n<span class="w"> </span>MyExtension<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--extensionId<span class="w"> </span>com.mycompany.myext<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--author<span class="w"> </span><span class="s2">&quot;Your Name&quot;</span>
</code></pre></div>

<p>And a cell loads your build output straight from disk, with the path resolved relative to the notebook:</p>
<div class="highlight"><pre><span></span><code>#!extension ./MyExtension/bin/Debug/net8.0/MyExtension.dll
</code></pre></div>

<p>Build, re-run the cell, and the new layout or theme or kernel is registered in the running session. A NuGet package id works in the same command and asks for consent first, since that one reaches the network.</p>
<p>I don't think this rule makes Verso better in a way you can feel in the first ten minutes. It makes it harder for us to paint ourselves into a corner later. A built-in that quietly reaches for an internal API is a feature nobody outside can reproduce, and it is also a signal that the interface it should have used is missing something. I would much rather find that out from a small Perl sample than from your bug report.</p>]]></content:encoded>
  </item>
  <item>
    <title>Verso 1.0: the notebook, and the engine underneath it</title>
    <link>https://datafication.co/blog/2026/verso-1-0-the-first-release.html</link>
    <guid isPermaLink="true">https://datafication.co/blog/2026/verso-1-0-the-first-release.html</guid>
    <pubDate>Fri, 20 Feb 2026 12:00:00 +0000</pubDate>
    <dc:creator>Torrey Betts</dc:creator>
    <category>Verso</category>
    <description>The first release of Verso: a headless .NET notebook engine, C#, F# and SQL kernels that share one variable store, a file format you can read in a diff, and ten interfaces every built-in already uses.</description>
    <content:encoded><![CDATA[<p>Verso 1.0 is out today, and the part I care most about isn't the notebook. It is the engine underneath it, a headless .NET library with no UI dependencies, of which the notebook is one consumer.</p>
<p>It is MIT licensed and published today as six NuGet packages plus a VS Code extension build attached to the GitHub release. You need the .NET 8 SDK.</p>
<h2 id="what-is-in-the-box">What is in the box</h2>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Category</th>
<th>Included at 1.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>Kernels</td>
<td>C# (Roslyn), F# (FSharp.Compiler.Service), SQL (ADO.NET, provider-agnostic)</td>
</tr>
<tr>
<td>Cell rendering</td>
<td>Markdown via Markdig, SQL cells with paginated result tables</td>
</tr>
<tr>
<td>Data formatters</td>
<td>Primitives, collections, HTML, images, SVG, exceptions, F# types, SQL result sets</td>
</tr>
<tr>
<td>Themes</td>
<td>Verso Light, Verso Dark, Verso High Contrast (WCAG 2.1 AA)</td>
</tr>
<tr>
<td>Layouts</td>
<td>Notebook (linear), Dashboard (12-column CSS grid)</td>
</tr>
<tr>
<td>Magic commands</td>
<td><code>#!time</code>, <code>#!nuget</code>, <code>#!restart</code>, <code>#!about</code>, <code>#!import</code>, and <code>#!sql-connect</code>, <code>#!sql-disconnect</code>, <code>#!sql-schema</code>, <code>#!sql-scaffold</code> from Verso.Ado</td>
</tr>
<tr>
<td>Toolbar actions</td>
<td>Run Cell, Run All, Clear Outputs, Restart Kernel, Switch Layout, Switch Theme, four exports (HTML, Markdown, CSV, JSON)</td>
</tr>
<tr>
<td>Serializers</td>
<td><code>.verso</code> (JSON, read and write), <code>.ipynb</code> (import, nbformat v4 and later)</td>
</tr>
<tr>
<td>Front ends</td>
<td>VS Code extension, standalone Blazor Server web app</td>
</tr>
<tr>
<td>Packages</td>
<td><code>Verso</code>, <code>Verso.Abstractions</code>, <code>Verso.FSharp</code>, <code>Verso.Ado</code>, <code>Verso.Templates</code>, <code>Verso.Testing</code></td>
</tr>
</tbody>
</table></div>
<h2 id="one-engine-two-front-ends">One engine, two front ends</h2>
<figure class="fig">
<svg viewBox="0 0 720 268" role="img" aria-label="Three stacked layers: front ends on top, the Verso engine below it, and Verso.Abstractions at the bottom, joined by downward arrows">
  <rect class="box" x="8" y="8" width="704" height="80" stroke-width="1"/>
  <text class="muted" x="24" y="30">FRONT ENDS</text>
  <rect class="box on" x="24" y="40" width="320" height="34" stroke-width="1.5"/>
  <text class="label b" x="184" y="62" text-anchor="middle">VS Code extension</text>
  <rect class="box on" x="360" y="40" width="336" height="34" stroke-width="1.5"/>
  <text class="label b" x="528" y="62" text-anchor="middle">Blazor Server app</text>
  <path class="brand-line" d="M360 88 V112" stroke-width="2"/>
  <path class="brand" d="M360 122 l-5 -10 h10 Z"/>
  <rect class="box on" x="8" y="124" width="704" height="76" stroke-width="1.5"/>
  <text class="muted" x="24" y="146">VERSO ENGINE, HEADLESS, NO UI</text>
  <rect class="soft" x="24" y="156" width="156" height="30"/>
  <text class="label b" x="102" y="176" text-anchor="middle">Scaffold</text>
  <rect class="box" x="192" y="156" width="156" height="30" stroke-width="1"/>
  <text class="label b" x="270" y="176" text-anchor="middle">Extension host</text>
  <rect class="box" x="360" y="156" width="156" height="30" stroke-width="1"/>
  <text class="label b" x="438" y="176" text-anchor="middle">Pipeline</text>
  <rect class="box" x="528" y="156" width="168" height="30" stroke-width="1"/>
  <text class="label b" x="612" y="176" text-anchor="middle">Variable store</text>
  <path class="brand-line" d="M360 200 V224" stroke-width="2"/>
  <path class="brand" d="M360 234 l-5 -10 h10 Z"/>
  <rect class="box" x="8" y="228" width="704" height="32" stroke-width="1"/>
  <text class="label b" x="24" y="249">Verso.Abstractions</text>
</svg>
<figcaption>The engine knows nothing about the UI, and an extension references neither.</figcaption>
</figure>

<p>The VS Code extension and the Blazor Server web app both sit on top of the engine, and they render the same components, so a notebook behaves the same in either place.</p>
<p>That split is why <code>dotnet add package Verso</code> makes sense in an application with no notebook in it at all. The engine is a library first.</p>
<h2 id="three-kernels-one-variable-store">Three kernels, one variable store</h2>
<p>There is one variable store per session, and every kernel is handed the same one. There is no per-kernel isolation, so a value written by one language is readable by the next cell in another. You don't move it, and you don't name it twice.</p>
<div class="highlight"><pre><span></span><code><span class="c1">// C# cell</span>
<span class="kt">var</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&quot;us-east&quot;</span><span class="p">;</span>
<span class="kt">var</span><span class="w"> </span><span class="n">threshold</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mf">0.95</span><span class="p">;</span>
</code></pre></div>

<p>Your top-level C# variables are published to the store after the cell runs. An F# cell reads them back through a typed helper the F# kernel injects into the session:</p>
<div class="highlight"><pre><span></span><code><span class="c1">// F# cell</span>
<span class="k">let</span><span class="w"> </span><span class="nv">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">tryGetVar</span><span class="o">&lt;</span><span class="kt">string</span><span class="o">&gt;</span><span class="w"> </span><span class="s">&quot;region&quot;</span>
</code></pre></div>

<p>A SQL cell binds them as query parameters by name:</p>
<div class="highlight"><pre><span></span><code><span class="c1">-- after #!sql-connect --name reporting --connection-string &quot;...&quot;</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">name</span><span class="p">,</span><span class="w"> </span><span class="n">uptime</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">services</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">region</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">@</span><span class="n">region</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="n">uptime</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="o">@</span><span class="n">threshold</span>
</code></pre></div>

<p>The SQL kernel is provider-agnostic, so you bring the ADO.NET provider yourself with <code>#r "nuget: ..."</code>. Verso.Ado also does named connections, schema inspection, and EF Core scaffolding from a live database through <code>#!sql-scaffold</code>.</p>
<h2 id="a-file-you-can-read-in-a-diff">A file you can read in a diff</h2>
<p>The <code>.verso</code> format is JSON: a format identifier, notebook metadata, an ordered list of cells with stable ids, per-layout positioning, and outputs.</p>
<div class="highlight"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;verso&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;1.0&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;metadata&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;defaultKernel&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;csharp&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;activeLayout&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;notebook&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;preferredTheme&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;verso-light&quot;</span>
<span class="w">  </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;cells&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;...&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;code&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;language&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;csharp&quot;</span><span class="p">,</span>
<span class="w">      </span><span class="nt">&quot;source&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Console.WriteLine(\&quot;Hello from Verso\&quot;);&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;outputs&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="w"> </span><span class="p">}</span>
<span class="w">  </span><span class="p">],</span>
<span class="w">  </span><span class="nt">&quot;layouts&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;dashboard&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;cells&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;cell-id&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;row&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;col&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;width&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">6</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;height&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">4</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>Those ids do more work than they look like. A cell keeps the same id across saves, so a reviewer can tell that a cell moved rather than that one disappeared and a similar one appeared elsewhere, and layout metadata points at cells by id rather than by position. Jupyter files come in through the <code>.ipynb</code> reader, nbformat v4 and later.</p>
<h2 id="ten-interfaces-and-our-own-features-are-behind-them">Ten interfaces, and our own features are behind them</h2>
<p><code>Verso.Abstractions</code> defines ten extension interfaces: <code>ILanguageKernel</code>, <code>ICellRenderer</code>, <code>ICellType</code>, <code>IToolbarAction</code>, <code>IDataFormatter</code>, <code>IMagicCommand</code>, <code>ITheme</code>, <code>ILayoutEngine</code>, <code>INotebookSerializer</code>, and <code>INotebookPostProcessor</code>. A supplementary <code>IExtensionSettings</code> sits alongside any of them. All inherit <code>IExtension</code>, which carries identity and two lifecycle hooks.</p>
<p>Every built-in in the table above is written on those interfaces. The C# kernel is an <code>ILanguageKernel</code>. Verso Dark is an <code>ITheme</code>. Dashboard is an <code>ILayoutEngine</code>. Verso.FSharp and Verso.Ado are ordinary extension packages that reference <code>Verso.Abstractions</code> and ship on NuGet the way yours would.</p>
<p>That is the second of the <a href="https://datafication.co/blog/2026/four-rules-before-our-first-open-source-release.html">four rules we published this week</a>, and the one with the sharpest edge. If one of our own features cannot be built on the public interfaces, the interfaces are what is wrong, and the interfaces are what we fix.</p>
<h2 id="getting-it">Getting it</h2>
<div class="highlight"><pre><span></span><code>dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso<span class="w">                 </span><span class="c1"># the engine, in your own app</span>
dotnet<span class="w"> </span>add<span class="w"> </span>package<span class="w"> </span>Verso.Abstractions<span class="w">    </span><span class="c1"># all an extension author needs</span>
dotnet<span class="w"> </span>new<span class="w"> </span>install<span class="w"> </span>Verso.Templates
dotnet<span class="w"> </span>new<span class="w"> </span>verso-extension<span class="w"> </span>-n<span class="w"> </span>MyExtension
</code></pre></div>

<p>The VS Code extension is a <code>.vsix</code> on the <a href="https://github.com/DataficationSDK/Verso/releases/tag/verso-v1.0.0">GitHub release</a>. The browser app runs from source with <code>dotnet run --project src/Verso.Blazor</code>.</p>
<h2 id="what-10-does-not-have">What 1.0 does not have</h2>
<p>No command line tool, so headless execution in CI means driving the engine yourself. No Python, PowerShell, JavaScript or HTTP cells. <code>.ipynb</code> is import only. Two layouts, and no way to browse or install an extension from inside the notebook.</p>
<p>The interfaces are the promise, and everything above them is what we keep adding to, one release at a time. Give it a try, and if you go to write an extension and the interface you need is missing something, that's the report I most want to see.</p>]]></content:encoded>
  </item>
</channel>
</rss>
