My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add JTW user's guide and admin guide documentation

User's guide covers the authoring format (.mld files with {@ocaml} blocks),
x-ocaml WebComponent, universes (ocaml.org multiverse and self-hosted),
exercise/assessment workflows, and the client library API for custom
integrations. Admin guide covers artifact generation via jtw opam and
day10 batch pipeline, including caching, layer structure, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+653
+328
docs/jtw-admin-guide.md
··· 1 + # js_top_worker Admin Guide 2 + 3 + This guide covers how to generate and host JTW (js_top_worker) artifacts -- 4 + the compiled OCaml libraries and toplevel worker that power in-browser REPLs. 5 + 6 + There are two tools: 7 + 8 + - **`jtw opam`** -- standalone tool for generating artifacts from an opam 9 + switch. Good for self-hosting a fixed set of packages. 10 + - **`day10 batch --with-jtw`** -- the universe builder pipeline. Solves 11 + dependencies, builds packages in containers, generates documentation and 12 + JTW artifacts at scale. Used for ocaml.org. 13 + 14 + ## jtw opam: standalone artifact generation 15 + 16 + ### Prerequisites 17 + 18 + An opam switch with the desired packages installed: 19 + 20 + ```bash 21 + opam switch create myswitch ocaml-base-compiler.5.4.0 22 + opam install fmt cmdliner str 23 + ``` 24 + 25 + The `jtw` binary, built from the js_top_worker repo: 26 + 27 + ```bash 28 + git clone https://tangled.org/jon.recoil.org/js_top_worker 29 + cd js_top_worker 30 + opam install . --deps-only 31 + dune build 32 + ``` 33 + 34 + ### Generating artifacts for a set of packages 35 + 36 + ```bash 37 + dune exec -- jtw opam -o output fmt cmdliner str 38 + ``` 39 + 40 + This produces: 41 + 42 + ``` 43 + output/ 44 + worker.js 45 + findlib_index.json 46 + lib/ 47 + fmt/ 48 + META, *.cmi, fmt.cma.js, dynamic_cmis.json 49 + cmdliner/ 50 + META, *.cmi, cmdliner.cma.js, dynamic_cmis.json 51 + str/ 52 + META, *.cmi, str.cma.js, dynamic_cmis.json 53 + ocaml/ 54 + META, *.cmi, stdlib.cma.js, dynamic_cmis.json 55 + ``` 56 + 57 + The tool: 58 + 1. Resolves transitive dependencies via `ocamlfind` 59 + 2. Copies `.cmi` files for each library (used by the type checker) 60 + 3. Compiles `.cma` archives to `.cma.js` via `js_of_ocaml` 61 + 4. Generates `dynamic_cmis.json` metadata per library directory 62 + 5. Writes `findlib_index.json` listing all META file paths 63 + 6. Compiles `worker.js` (the OCaml toplevel as a web worker) 64 + 65 + ### Flags 66 + 67 + | Flag | Default | Description | 68 + |------|---------|-------------| 69 + | `-o DIR` | `html` | Output directory | 70 + | `-v` | off | Verbose logging | 71 + | `--switch SWITCH` | current | Opam switch to use | 72 + | `--no-worker` | off | Skip worker.js generation | 73 + | `--path PATH` | none | Write output under a subdirectory (for per-package builds) | 74 + | `--deps-file FILE` | none | File listing dependency paths (one per line) | 75 + 76 + ### Generating per-package universes 77 + 78 + To generate separate artifact directories per package (each with its own 79 + dependency closure): 80 + 81 + ```bash 82 + dune exec -- jtw opam-all -o output --all 83 + ``` 84 + 85 + This produces a directory per installed findlib package, each containing its 86 + own `findlib_index.json` and `lib/` tree, plus a root-level 87 + `findlib_index.json` covering everything. 88 + 89 + The `--all` flag builds every package returned by `ocamlfind list`. Without 90 + it, pass specific package names as positional arguments. 91 + 92 + ### Serving the output 93 + 94 + Serve the output directory over HTTP. Any static file server works: 95 + 96 + ```bash 97 + cd output 98 + python3 -m http.server 8080 99 + ``` 100 + 101 + If loading from a different origin, configure CORS headers on the server. 102 + 103 + The `findlib_index.json` URL is the single entry point clients need. See the 104 + User's Guide for how to connect to it from JavaScript. 105 + 106 + ## day10: universe builder pipeline 107 + 108 + day10 is the batch pipeline that builds, tests, documents, and generates JTW 109 + artifacts for opam packages at scale. It runs builds inside OCI containers 110 + using `runc` with overlay filesystems. 111 + 112 + ### Prerequisites 113 + 114 + - Linux (uses `runc`, overlay mounts, user namespaces) 115 + - An opam-repository checkout 116 + - Root access (for container operations) 117 + - The js_top_worker repo accessible via HTTPS (for container builds) 118 + 119 + ### Building day10 120 + 121 + ```bash 122 + cd monopam # or wherever the monorepo lives 123 + dune build day10/ 124 + ``` 125 + 126 + ### Running a batch with JTW 127 + 128 + ```bash 129 + dune exec -- day10 batch \ 130 + --cache-dir /var/cache/day10 \ 131 + --opam-repository /var/cache/opam-repository \ 132 + --ocaml-version ocaml-base-compiler.5.4.0 \ 133 + --with-jtw \ 134 + --jtw-output /var/www/jtw \ 135 + --html-output /var/www/docs \ 136 + --with-doc \ 137 + @packages.json 138 + ``` 139 + 140 + Where `packages.json` is: 141 + 142 + ```json 143 + {"packages": ["fmt.0.9.0", "cmdliner.1.3.0", "lwt.5.9.0"]} 144 + ``` 145 + 146 + ### JTW-specific flags 147 + 148 + | Flag | Default | Description | 149 + |------|---------|-------------| 150 + | `--with-jtw` | false | Enable JTW artifact generation | 151 + | `--jtw-output DIR` | none | Output directory for assembled JTW artifacts | 152 + | `--jtw-tools-repo URL` | `https://tangled.org/jon.recoil.org/js_top_worker` | Git repo for js_top_worker | 153 + | `--jtw-tools-branch BRANCH` | `main` | Git branch for js_top_worker | 154 + 155 + ### How it works 156 + 157 + The JTW pipeline has three phases: 158 + 159 + #### Phase 1: jtw-tools layer 160 + 161 + A one-time (per OCaml version + repo + branch) container build that installs 162 + the JTW toolchain: 163 + 164 + 1. Installs `ocaml-base-compiler.<version>` in a fresh container 165 + 2. Pins all js_top_worker packages from the configured git repo/branch 166 + 3. Installs `js_of_ocaml`, `js_top_worker-bin`, `js_top_worker-web` 167 + 4. Runs `jtw opam -o /home/opam/jtw-tools-output stdlib` to produce 168 + `worker.js` and stdlib artifacts 169 + 170 + The result is cached at `<cache>/<os-key>/jtw-tools-<hash>/`. The hash 171 + depends on the OCaml version, repo URL, and branch name. Changing any of 172 + these invalidates the cache. 173 + 174 + #### Phase 2: per-package JTW generation 175 + 176 + For each package in the solution, a container runs: 177 + 178 + ```bash 179 + jtw opam --path <pkg-name> --no-worker -o /home/opam/jtw-output <findlib-names> 180 + ``` 181 + 182 + This produces `.cmi`, `.cma.js`, `META`, and `dynamic_cmis.json` for the 183 + package's findlib libraries. The container has the package's build layer and 184 + all dependency build layers mounted, so `ocamlfind` can resolve everything. 185 + 186 + Results are cached per package in `<cache>/<os-key>/jtw-<hash>/lib/`. 187 + 188 + Packages with no findlib META files (e.g. `ocaml-base-compiler`, 189 + `base-threads`) produce no JTW artifacts and are marked as `"status":"success"` 190 + with an empty layer. 191 + 192 + #### Phase 3: assembly 193 + 194 + `assemble_jtw_output` combines per-package layers into the final 195 + content-hashed directory structure: 196 + 197 + ``` 198 + <jtw-output>/ 199 + compiler/<version>/<compiler-hash>/ 200 + worker.js 201 + lib/ocaml/*.cmi, stdlib.cma.js, dynamic_cmis.json 202 + 203 + p/<package>/<version>/<content-hash>/ 204 + lib/<findlib-name>/ 205 + META, *.cmi, *.cma.js, dynamic_cmis.json 206 + 207 + u/<universe-hash>/ 208 + findlib_index.json 209 + ``` 210 + 211 + Content hashes are computed from the payload files (`.cmi`, `.cma.js`, `META`). 212 + Identical content always produces the same hash, enabling deduplication across 213 + universe builds. 214 + 215 + The `dynamic_cmis.json` files have their `dcs_url` field rewritten to use 216 + relative paths from the compiler directory (where `worker.js` loads them). 217 + 218 + ### Caching and layer structure 219 + 220 + day10 uses a layered caching system. JTW-related layers: 221 + 222 + | Layer | Path pattern | Contents | 223 + |-------|-------------|----------| 224 + | jtw-tools | `jtw-tools-<hash>/` | js_of_ocaml + jtw binaries, worker.js, stdlib | 225 + | per-package | `jtw-<hash>/` | Package's `.cmi`, `.cma.js`, `META`, `dynamic_cmis.json` | 226 + 227 + Each layer has a `layer.json` with metadata: 228 + 229 + ```json 230 + { 231 + "package": "fmt.0.9.0", 232 + "build_hash": "build-abc123", 233 + "jtw": {"status": "success"} 234 + } 235 + ``` 236 + 237 + Possible status values: `"success"`, `"failure"` (with `"error"` field), 238 + `"skipped"` (no findlib packages or jtw-tools unavailable). 239 + 240 + ### Inspecting layers 241 + 242 + ```bash 243 + # List all jtw layers 244 + ls /var/cache/day10/ubuntu-25.04-x86_64/jtw-*/ 245 + 246 + # Check a layer's status 247 + cat /var/cache/day10/ubuntu-25.04-x86_64/jtw-abc123/layer.json 248 + 249 + # View the build log 250 + cat /var/cache/day10/ubuntu-25.04-x86_64/jtw-abc123/jtw.log 251 + 252 + # Check jtw-tools layer 253 + cat /var/cache/day10/ubuntu-25.04-x86_64/jtw-tools-*/layer.json 254 + cat /var/cache/day10/ubuntu-25.04-x86_64/jtw-tools-*/build.log 255 + ``` 256 + 257 + ### Invalidating caches 258 + 259 + To force a rebuild of the jtw-tools layer (e.g. after updating 260 + js_top_worker): 261 + 262 + ```bash 263 + sudo rm -rf /var/cache/day10/ubuntu-25.04-x86_64/jtw-tools-*/ 264 + ``` 265 + 266 + To force a rebuild of per-package JTW artifacts: 267 + 268 + ```bash 269 + sudo rm -rf /var/cache/day10/ubuntu-25.04-x86_64/jtw-[0-9a-f]*/ 270 + ``` 271 + 272 + Root is required because the container filesystem layers are owned by the 273 + container's uid. 274 + 275 + ### Using a custom js_top_worker 276 + 277 + To test changes to js_top_worker before merging: 278 + 279 + 1. Push your branch to a git-accessible URL 280 + 2. Pass it to day10: 281 + 282 + ```bash 283 + dune exec -- day10 batch \ 284 + --with-jtw \ 285 + --jtw-tools-repo https://tangled.org/jon.recoil.org/js_top_worker \ 286 + --jtw-tools-branch my-feature-branch \ 287 + ... 288 + ``` 289 + 290 + The jtw-tools layer hash will change (it includes the branch name), so a 291 + fresh toolchain build will occur. 292 + 293 + ### Troubleshooting 294 + 295 + **jtw-tools layer fails with "Unknown archive type"** 296 + 297 + The repo URL needs to be accessible as a git repository. Opam uses the 298 + `git+https://` scheme internally. If your URL serves HTML instead of git, 299 + the pin will fail. Verify with: 300 + 301 + ```bash 302 + git ls-remote https://your-repo-url 303 + ``` 304 + 305 + **All jtw layers show status "skipped"** 306 + 307 + This means `has_jsoo` returned false -- the jtw-tools layer doesn't contain 308 + `js_of_ocaml`. Check the jtw-tools build log for installation failures. 309 + 310 + **jtw layer shows status "failure" with exit code 125** 311 + 312 + This usually means the package has no installable findlib libraries, or 313 + `jtw opam` couldn't find any `.cma` files to compile. This is normal for 314 + packages like `ocaml-compiler` that don't install findlib packages. 315 + 316 + **Per-package artifacts exist in cache but jtw-output is empty** 317 + 318 + Assembly only runs in `batch` mode, not `health-check`. Use: 319 + 320 + ```bash 321 + dune exec -- day10 batch --with-jtw --jtw-output /path/to/output ... 322 + ``` 323 + 324 + **Content hashes change unexpectedly** 325 + 326 + The content hash is computed from `.cmi`, `.cma.js`, and `META` file contents. 327 + If the OCaml compiler version or any dependency changes, the compiled artifacts 328 + will differ and produce a new hash. This is by design.
+325
docs/jtw-users-guide.md
··· 1 + # Interactive OCaml Tutorials — User's Guide 2 + 3 + This guide is for tutorial authors and web developers who want to create 4 + interactive OCaml content — live code cells, exercises, and widgets — served 5 + as static HTML pages with no server-side component. 6 + 7 + ## How it works 8 + 9 + Authors write documentation in `.mld` files using odoc's tagged code block 10 + syntax. The odoc plugin translates `{@ocaml ...}` blocks into `<x-ocaml>` 11 + HTML elements. A WebComponent (`x-ocaml.js`) and a Web Worker (`worker.js`) 12 + handle all interactivity in the browser: editing, execution, autocompletion, 13 + and type feedback. 14 + 15 + ``` 16 + Author writes .mld odoc plugin x-ocaml + worker 17 + ───────────────────── ──> ──────────────── ──> ───────────────── 18 + {@ocaml exercise <x-ocaml WebComponent reads 19 + id=factorial mode="exercise" data attrs, manages 20 + [let facr n = ...]} data-id="factorial"> UI, sends code to 21 + ... worker for execution 22 + </x-ocaml> 23 + ``` 24 + 25 + There is no server-side component beyond serving static files over HTTP. 26 + 27 + ## Universes 28 + 29 + A **universe** is a self-consistent set of compiled OCaml packages. OCaml 30 + requires that all libraries in a universe are built with exactly the same 31 + versions of all transitive dependencies — you cannot mix libraries from 32 + different build environments. 33 + 34 + Each universe is identified by a content hash and has a single entry point: 35 + `findlib_index.json`. This file tells the runtime which compiler version to 36 + use and where to find every package's artifacts. 37 + 38 + ### Content sources 39 + 40 + **The ocaml.org multiverse** — the OCaml package documentation site hosts 41 + pre-built universes for published opam packages. Each package version's 42 + documentation page links to the correct universe for that package and its 43 + dependencies. 44 + 45 + **Self-hosted** — you can generate and host your own universes for custom 46 + package sets or private libraries. See the Admin Guide for instructions on 47 + using `jtw opam` or `day10` to produce these artifacts. Serve the output 48 + directory over HTTP with appropriate CORS headers if loading cross-origin. 49 + 50 + ### Directory layout 51 + 52 + ``` 53 + compiler/<ocaml-version>/<hash>/ 54 + worker.js -- the OCaml toplevel web worker 55 + lib/ocaml/ 56 + *.cmi, stdlib.cma.js -- stdlib artifacts 57 + 58 + p/<package>/<version>/<hash>/ 59 + lib/<findlib-name>/ 60 + META, *.cmi, *.cma.js -- package artifacts 61 + 62 + u/<universe-hash>/ 63 + findlib_index.json -- entry point 64 + ``` 65 + 66 + All paths include a content hash, making them safe to cache indefinitely. 67 + 68 + ## Authoring tutorials 69 + 70 + ### Page-level configuration 71 + 72 + Custom tags at the top of the `.mld` file configure the page: 73 + 74 + ``` 75 + @x-ocaml.universe https://ocaml.org/universe/5.3.0 76 + @x-ocaml.requires cmdliner, astring 77 + @x-ocaml.auto-execute false 78 + @x-ocaml.merlin false 79 + ``` 80 + 81 + | Tag | Default | Purpose | 82 + |-----|---------|---------| 83 + | `universe` | `./universe/` | URL where `findlib_index.json` lives | 84 + | `requires` | none | Packages to preload before any cells run | 85 + | `auto-execute` | `true` | Whether cells run automatically on page load | 86 + | `merlin` | `true` | Whether Merlin-based LSP feedback is enabled | 87 + 88 + ### Cell types 89 + 90 + Code blocks use odoc's tagged code block syntax: 91 + `{@ocaml <attributes> [...code...]}`. 92 + 93 + | Attribute | Purpose | Editable? | Visible? | 94 + |---------------|---------------------------------|-----------|----------| 95 + | `interactive` | Demo or example cell | No | Yes | 96 + | `exercise` | Skeleton for the reader to edit | Yes | Yes | 97 + | `test` | Immutable test assertions | No | Yes | 98 + | `hidden` | Setup code, runs but not shown | No | No | 99 + 100 + ### Per-cell attributes 101 + 102 + | Attribute | Purpose | 103 + |------------|----------------------------------------| 104 + | `id=name` | Name this cell for explicit linking | 105 + | `for=name` | Link a test cell to a specific exercise | 106 + | `env=name` | Named execution environment | 107 + | `merlin` | Override page-level merlin setting | 108 + 109 + ### Execution environments 110 + 111 + Cells sharing an `env` attribute see each other's definitions. By default, 112 + all cells on a page share one environment. Named environments allow 113 + isolation when needed: 114 + 115 + ``` 116 + {@ocaml hidden env=greetings [ 117 + let greeting = "Hello" 118 + ]} 119 + 120 + {@ocaml interactive env=greetings [ 121 + Printf.printf "%s, world!\n" greeting 122 + ]} 123 + 124 + {@ocaml interactive env=math [ 125 + (* This cell cannot see 'greeting' *) 126 + let pi = Float.pi 127 + ]} 128 + ``` 129 + 130 + ### Exercise linking 131 + 132 + Test cells are linked to exercise cells by two mechanisms: 133 + 134 + - **Positional (default)** — a test cell applies to the nearest preceding 135 + exercise cell. 136 + - **Explicit** — use `id` and `for` attributes when the test is distant or 137 + ambiguous. 138 + 139 + ## Examples 140 + 141 + ### Interactive tutorial 142 + 143 + A step-by-step walkthrough where cells build on each other: 144 + 145 + ``` 146 + @x-ocaml.universe https://ocaml.org/universe/5.4.0 147 + @x-ocaml.requires fmt 148 + 149 + {1 Working with Fmt} 150 + 151 + The [Fmt] library provides composable pretty-printing combinators. 152 + 153 + {@ocaml interactive [ 154 + let pp_greeting ppf name = 155 + Fmt.pf ppf "Hello, %s!" name 156 + ]} 157 + 158 + Try it: 159 + 160 + {@ocaml interactive [ 161 + Fmt.pr "%a@." pp_greeting "world" 162 + ]} 163 + ``` 164 + 165 + ### Assessment worksheet 166 + 167 + An exercise with hidden setup, editable skeleton, and visible tests: 168 + 169 + ``` 170 + {@ocaml hidden [ 171 + (* Setup code the student doesn't see *) 172 + let check_positive f = 173 + assert (f 0 = 1); 174 + assert (f 1 = 1) 175 + ]} 176 + 177 + Write an OCaml function [facr] to compute the factorial by recursion. 178 + 179 + {@ocaml exercise id=factorial [ 180 + let rec facr n = 181 + (* YOUR CODE HERE *) 182 + failwith "Not implemented" 183 + ]} 184 + 185 + {@ocaml test for=factorial [ 186 + assert (facr 10 = 3628800);; 187 + assert (facr 11 = 39916800);; 188 + ]} 189 + ``` 190 + 191 + ### Generated HTML 192 + 193 + The odoc plugin maps attributes directly to HTML data attributes: 194 + 195 + ```html 196 + <x-ocaml mode="hidden"> 197 + (* Setup code the student doesn't see *) 198 + let check_positive f = ... 199 + </x-ocaml> 200 + 201 + <p>Write an OCaml function <code>facr</code> to compute the factorial 202 + by recursion.</p> 203 + 204 + <x-ocaml mode="exercise" data-id="factorial"> 205 + let rec facr n = 206 + (* YOUR CODE HERE *) 207 + failwith "Not implemented" 208 + </x-ocaml> 209 + 210 + <x-ocaml mode="test" data-for="factorial"> 211 + assert (facr 10 = 3628800);; 212 + assert (facr 11 = 39916800);; 213 + </x-ocaml> 214 + ``` 215 + 216 + The plugin also injects a `<script>` tag for `x-ocaml.js` (once per page) 217 + and `<meta>` tags for page-level configuration. 218 + 219 + ## Using x-ocaml outside odoc 220 + 221 + The `<x-ocaml>` WebComponent works in any HTML page — it doesn't require 222 + odoc. You can write the elements by hand: 223 + 224 + ```html 225 + <!DOCTYPE html> 226 + <html> 227 + <head> 228 + <meta name="x-ocaml-universe" 229 + content="https://ocaml.org/universe/5.4.0"> 230 + <meta name="x-ocaml-requires" content="str"> 231 + <script src="https://ocaml.org/jtw/x-ocaml.js" type="module"></script> 232 + </head> 233 + <body> 234 + <x-ocaml mode="interactive"> 235 + let words = Str.split (Str.regexp " ") "hello world" 236 + </x-ocaml> 237 + 238 + <x-ocaml mode="exercise" data-id="reverse"> 239 + let reverse lst = 240 + (* YOUR CODE HERE *) 241 + failwith "todo" 242 + </x-ocaml> 243 + 244 + <x-ocaml mode="test" data-for="reverse"> 245 + assert (reverse [1;2;3] = [3;2;1]);; 246 + </x-ocaml> 247 + </body> 248 + </html> 249 + ``` 250 + 251 + This makes the system usable with other documentation generators, static 252 + site builders, or hand-written HTML. 253 + 254 + ## Client library (advanced) 255 + 256 + For custom integrations that need direct control over the Web Worker, the 257 + `OcamlWorker` JavaScript class provides a programmatic API. 258 + 259 + ### Creating a worker 260 + 261 + ```javascript 262 + import { OcamlWorker } from './ocaml-worker.js'; 263 + 264 + const indexUrl = './u/UNIVERSE_HASH/findlib_index.json'; 265 + const { worker, stdlib_dcs, findlib_index } = 266 + await OcamlWorker.fromIndex(indexUrl, '.', { timeout: 120000 }); 267 + 268 + await worker.init({ 269 + findlib_requires: ['fmt'], 270 + stdlib_dcs, 271 + findlib_index, 272 + }); 273 + ``` 274 + 275 + ### Evaluating code 276 + 277 + ```javascript 278 + const result = await worker.eval('List.map (fun x -> x * 2) [1;2;3];;'); 279 + console.log(result.caml_ppf); 280 + // val x : int list = [2; 4; 6] 281 + ``` 282 + 283 + The result object contains: 284 + 285 + | Field | Type | Description | 286 + |-------------|-------------|------------------------------------------------| 287 + | `caml_ppf` | `string` | Toplevel-style output (e.g. `val x : int = 3`) | 288 + | `stdout` | `string` | Anything printed to stdout | 289 + | `stderr` | `string` | Warnings and errors | 290 + | `mime_vals` | `MimeVal[]` | Rich output (HTML, SVG, images) | 291 + 292 + ### Other methods 293 + 294 + - **`complete(code, pos)`** — autocompletion suggestions at a cursor position 295 + - **`typeAt(code, pos)`** — type of the expression at a position 296 + - **`errors(code)`** — check code for errors without executing it 297 + - **`createEnv(name)` / `destroyEnv(name)`** — manage isolated execution 298 + environments 299 + - **`terminate()`** — shut down the web worker 300 + 301 + ### Rich output (MIME values) 302 + 303 + `eval()` results may include `mime_vals` — an array of objects with 304 + `mime_type` and `data`. Libraries that produce graphical output (plotting, 305 + diagrams) use this mechanism to display results in the browser. Common types: 306 + `text/html`, `image/svg+xml`, `image/png`. 307 + 308 + ### Loading libraries at runtime 309 + 310 + In addition to preloading via `findlib_requires`, users can load libraries 311 + dynamically: 312 + 313 + ```ocaml 314 + #require "str";; 315 + Str.split (Str.regexp " ") "hello world";; 316 + ``` 317 + 318 + This works for any library present in the universe's `findlib_index.json`. 319 + 320 + ## What's next 321 + 322 + - **Scrollycode tutorials** — scroll-driven code walkthroughs using 323 + `odoc-scrollycode-extension` (already prototyped) 324 + - **Interactive widgets** — reactive UI elements (sliders, plots, mini-apps) 325 + driven by an FRP library running in the Worker (experimental)