My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

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

Add implementation plan for widget protocol integration

8 tasks: view types, JSON encoding, protocol messages, Widget module,
worker wiring, JS client, test page, browser verification.

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

+839
+839
docs/plans/2026-02-24-widget-protocol-integration-plan.md
··· 1 + # Widget Protocol Integration — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Integrate the widget/FRP bridge into js_top_worker's JSON message protocol so interactive widgets can be created from OCaml toplevel code. 6 + 7 + **Architecture:** The Widget module (like Mime_printer — small, toplevel-loadable) provides `display`/`update`/`clear` functions that send serialized View.node trees over the existing postMessage JSON protocol. A handler registry routes incoming WidgetEvent messages to user-registered callbacks. The JS client gets new callbacks and methods for widget messages. A test HTML page validates the full round-trip. 8 + 9 + **Tech Stack:** OCaml, js_of_ocaml, note (FRP), dune, Brr (test renderer) 10 + 11 + --- 12 + 13 + ## Context 14 + 15 + **Design doc:** `docs/plans/2026-02-24-widget-protocol-integration-design.md` 16 + 17 + **Key existing files:** 18 + - `js_top_worker/idl/message.ml` — JSON message protocol (310 lines) 19 + - `js_top_worker/lib/worker.ml` — Worker message handler (358 lines) 20 + - `js_top_worker/client/ocaml-worker.js` — JS client (447 lines) 21 + - `mime_printer/mime_printer.ml` — Pattern for toplevel-loadable module (20 lines) 22 + 23 + **Key patterns:** 24 + - JSON built with `Js.Unsafe.obj` / `json_of_obj` helper, stringified with `JSON.stringify` 25 + - Messages have a `"type"` field for dispatch 26 + - Worker sends via `Worker.post_message (Js.string json)` 27 + - Client sends via `worker.postMessage(JSON.stringify(msg))` 28 + - All JSON uses plain `JSON.parse`/`JSON.stringify` (NOT jsoo's `Json` module) for cross-version compat 29 + 30 + --- 31 + 32 + ### Task 1: Add View types to the message library 33 + 34 + Add the serializable view types (from experiments) alongside message.ml. These are the types that cross the worker↔client boundary. 35 + 36 + **Files:** 37 + - Create: `js_top_worker/idl/widget_view.ml` 38 + - Modify: `js_top_worker/idl/dune` (add module to `js_top_worker_message` library) 39 + 40 + **Step 1: Create widget_view.ml** 41 + 42 + ```ocaml 43 + (** Serializable view descriptions for interactive widgets. 44 + 45 + No closures, no JS references. Event handlers are symbolic 46 + string identifiers resolved by the worker's handler registry. *) 47 + 48 + type event_id = string 49 + 50 + type attr = 51 + | Property of string * string 52 + | Style of string * string 53 + | Class of string 54 + | Handler of string * event_id (** event name, handler id *) 55 + 56 + type node = 57 + | Text of string 58 + | Element of { tag : string; attrs : attr list; children : node list } 59 + 60 + type event_msg = { 61 + handler_id : event_id; 62 + event_type : string; 63 + value : string option; 64 + } 65 + ``` 66 + 67 + **Step 2: Add module to dune** 68 + 69 + In `js_top_worker/idl/dune`, change the `js_top_worker_message` library's modules line: 70 + 71 + ``` 72 + (modules message) 73 + ``` 74 + to: 75 + ``` 76 + (modules message widget_view) 77 + ``` 78 + 79 + **Step 3: Verify build** 80 + 81 + Run: `opam exec -- dune build js_top_worker/idl/` 82 + Expected: Clean build, no errors. 83 + 84 + **Step 4: Commit** 85 + 86 + ```bash 87 + git add js_top_worker/idl/widget_view.ml js_top_worker/idl/dune 88 + git commit -m "feat: add serializable view types to message library" 89 + ``` 90 + 91 + --- 92 + 93 + ### Task 2: Add View.node JSON encoding to message.ml 94 + 95 + Add functions to serialize/deserialize View.node trees as JSON, using the existing `json_of_obj` / `json_string` / `get_string` helpers. 96 + 97 + **Files:** 98 + - Modify: `js_top_worker/idl/message.ml` 99 + 100 + **Step 1: Add JSON encoding functions after the existing helpers section (~line 141)** 101 + 102 + Add after `let get_string_array obj key =` block, before `(** {1 Worker message serialization} *)`: 103 + 104 + ```ocaml 105 + (** {1 View node JSON encoding} *) 106 + 107 + let rec json_of_view_attr (a : Widget_view.attr) = 108 + match a with 109 + | Property (k, v) -> 110 + json_of_obj [("t", json_string "prop"); ("k", json_string k); ("v", json_string v)] 111 + | Style (k, v) -> 112 + json_of_obj [("t", json_string "style"); ("k", json_string k); ("v", json_string v)] 113 + | Class c -> 114 + json_of_obj [("t", json_string "cls"); ("v", json_string c)] 115 + | Handler (ev, id) -> 116 + json_of_obj [("t", json_string "handler"); ("ev", json_string ev); ("id", json_string id)] 117 + 118 + and json_of_view_node (n : Widget_view.node) = 119 + match n with 120 + | Text s -> 121 + json_of_obj [("t", json_string "txt"); ("v", json_string s)] 122 + | Element { tag; attrs; children } -> 123 + json_of_obj [ 124 + ("t", json_string "el"); 125 + ("tag", json_string tag); 126 + ("a", json_array (List.map (fun a -> Js.Unsafe.inject (json_of_view_attr a)) attrs)); 127 + ("c", json_array (List.map (fun c -> Js.Unsafe.inject (json_of_view_node c)) children)); 128 + ] 129 + 130 + let view_attr_of_json obj : Widget_view.attr = 131 + let t = get_string obj "t" in 132 + match t with 133 + | "prop" -> Property (get_string obj "k", get_string obj "v") 134 + | "style" -> Style (get_string obj "k", get_string obj "v") 135 + | "cls" -> Class (get_string obj "v") 136 + | "handler" -> Handler (get_string obj "ev", get_string obj "id") 137 + | _ -> failwith ("Unknown attr type: " ^ t) 138 + 139 + let rec view_node_of_json obj : Widget_view.node = 140 + let t = get_string obj "t" in 141 + match t with 142 + | "txt" -> Text (get_string obj "v") 143 + | "el" -> 144 + let attrs = Array.to_list (Array.map view_attr_of_json (get_array obj "a")) in 145 + let children = Array.to_list (Array.map view_node_of_json (get_array obj "c")) in 146 + Element { tag = get_string obj "tag"; attrs; children } 147 + | _ -> failwith ("Unknown node type: " ^ t) 148 + ``` 149 + 150 + **Step 2: Verify build** 151 + 152 + Run: `opam exec -- dune build js_top_worker/idl/` 153 + Expected: Clean build. 154 + 155 + **Step 3: Commit** 156 + 157 + ```bash 158 + git add js_top_worker/idl/message.ml 159 + git commit -m "feat: add View.node JSON encoding to message protocol" 160 + ``` 161 + 162 + --- 163 + 164 + ### Task 3: Add widget message types to protocol 165 + 166 + Add WidgetUpdate, WidgetClear (worker→client) and WidgetEvent (client→worker) message variants, with JSON serialization/deserialization. 167 + 168 + **Files:** 169 + - Modify: `js_top_worker/idl/message.ml` 170 + 171 + **Step 1: Add new variants to worker_msg type (~line 93)** 172 + 173 + Add before the closing of the `worker_msg` type: 174 + 175 + ```ocaml 176 + | WidgetUpdate of { widget_id : string; view : Widget_view.node } 177 + | WidgetClear of { widget_id : string } 178 + ``` 179 + 180 + **Step 2: Add new variant to client_msg type (~line 69)** 181 + 182 + Add before the closing of the `client_msg` type: 183 + 184 + ```ocaml 185 + | WidgetEvent of { widget_id : string; handler_id : string; event_type : string; value : string option } 186 + ``` 187 + 188 + **Step 3: Add JSON serialization for new worker_msg variants** 189 + 190 + In `json_of_worker_msg`, add cases before the final `in`: 191 + 192 + ```ocaml 193 + | WidgetUpdate { widget_id; view } -> 194 + json_of_obj [ 195 + ("type", json_string "widget_update"); 196 + ("widget_id", json_string widget_id); 197 + ("view", Js.Unsafe.inject (json_of_view_node view)); 198 + ] 199 + | WidgetClear { widget_id } -> 200 + json_of_obj [ 201 + ("type", json_string "widget_clear"); 202 + ("widget_id", json_string widget_id); 203 + ] 204 + ``` 205 + 206 + **Step 4: Add JSON deserialization for WidgetEvent** 207 + 208 + In `client_msg_of_string`, add a case before the wildcard: 209 + 210 + ```ocaml 211 + | "widget_event" -> 212 + WidgetEvent { 213 + widget_id = get_string obj "widget_id"; 214 + handler_id = get_string obj "handler_id"; 215 + event_type = get_string obj "event_type"; 216 + value = get_string_opt obj "value"; 217 + } 218 + ``` 219 + 220 + **Step 5: Verify build** 221 + 222 + Run: `opam exec -- dune build js_top_worker/idl/` 223 + Expected: Warning about non-exhaustive match in worker.ml (new message variants not handled yet). The idl/ library itself should build clean. 224 + 225 + **Step 6: Commit** 226 + 227 + ```bash 228 + git add js_top_worker/idl/message.ml 229 + git commit -m "feat: add WidgetUpdate/WidgetEvent/WidgetClear to message protocol" 230 + ``` 231 + 232 + --- 233 + 234 + ### Task 4: Create the Widget module 235 + 236 + A toplevel-loadable module (like Mime_printer) that provides the user-facing API. Uses a global `send` callback ref that the worker injects at startup. 237 + 238 + **Files:** 239 + - Create: `js_top_worker/widget/widget.ml` 240 + - Create: `js_top_worker/widget/widget.mli` 241 + - Create: `js_top_worker/widget/dune` 242 + 243 + **Step 1: Create dune build file** 244 + 245 + `js_top_worker/widget/dune`: 246 + ``` 247 + (library 248 + (name widget) 249 + (public_name js_top_worker-widget) 250 + (libraries js_top_worker-rpc.message)) 251 + ``` 252 + 253 + Note: No dependency on `note` — the Widget module is imperative. Note integration is done in user code via `Note.S.log`. 254 + 255 + **Step 2: Create widget.mli** 256 + 257 + ```ocaml 258 + (** Interactive widget support for the OCaml toplevel. 259 + 260 + Widgets are rendered in the client as HTML elements built from 261 + {!Widget_view.node} trees. Event handlers in the view are symbolic 262 + string identifiers — when the user interacts with a widget, the 263 + client sends the handler ID and input value back to the worker, 264 + where the registered callback is invoked. 265 + 266 + Typical usage with Note FRP: 267 + {[ 268 + let e, send = Note.E.create () 269 + let s = Note.S.hold 50 e 270 + 271 + let () = 272 + Widget.display ~id:"my-slider" 273 + ~handlers:["x", (fun v -> 274 + send (int_of_string (Option.get v)))] 275 + (Widget_view.Element { tag = "input"; 276 + attrs = [Property ("type", "range")]; 277 + children = [] }) 278 + 279 + (* Wire up automatic updates via Note: *) 280 + let _logr = Note.S.log 281 + (Note.S.map (fun v -> ... build view ...) s) 282 + (Widget.update ~id:"my-slider") 283 + ]} *) 284 + 285 + val display : 286 + id:string -> 287 + handlers:(string * (string option -> unit)) list -> 288 + Widget_view.node -> 289 + unit 290 + (** [display ~id ~handlers view] registers a widget with the given [id], 291 + installs [handlers] for routing incoming events, and sends the 292 + initial [view] to the client. If a widget with this [id] already 293 + exists, it is replaced. *) 294 + 295 + val update : id:string -> Widget_view.node -> unit 296 + (** [update ~id view] sends an updated view for an existing widget. 297 + The handler map is not changed. *) 298 + 299 + val clear : id:string -> unit 300 + (** [clear ~id] removes the widget and its handlers. Sends a 301 + WidgetClear message to the client. *) 302 + 303 + val handle_event : 304 + widget_id:string -> handler_id:string -> value:string option -> unit 305 + (** [handle_event ~widget_id ~handler_id ~value] routes an incoming 306 + event to the registered handler. Called by the worker message loop 307 + when a WidgetEvent is received. *) 308 + 309 + val set_sender : (string -> unit) -> unit 310 + (** [set_sender f] installs the function used to send JSON strings to 311 + the client. Called once by the worker at startup. The function [f] 312 + should call [Worker.post_message]. *) 313 + ``` 314 + 315 + **Step 3: Create widget.ml** 316 + 317 + ```ocaml 318 + open Js_top_worker_message.Message 319 + 320 + (* --- Send function, injected by worker at startup --- *) 321 + 322 + let sender : (string -> unit) ref = ref (fun _ -> ()) 323 + 324 + let set_sender f = sender := f 325 + 326 + let send_msg msg = 327 + let json = json_of_worker_msg msg in 328 + !sender json 329 + 330 + (* --- Handler registry --- *) 331 + 332 + type widget_state = { 333 + handlers : (string * (string option -> unit)) list; 334 + } 335 + 336 + let widgets : (string, widget_state) Hashtbl.t = Hashtbl.create 16 337 + 338 + (* --- Public API --- *) 339 + 340 + let display ~id ~handlers view = 341 + Hashtbl.replace widgets id { handlers }; 342 + send_msg (WidgetUpdate { widget_id = id; view }) 343 + 344 + let update ~id view = 345 + send_msg (WidgetUpdate { widget_id = id; view }) 346 + 347 + let clear ~id = 348 + Hashtbl.remove widgets id; 349 + send_msg (WidgetClear { widget_id = id }) 350 + 351 + let handle_event ~widget_id ~handler_id ~value = 352 + match Hashtbl.find_opt widgets widget_id with 353 + | None -> () 354 + | Some state -> 355 + match List.assoc_opt handler_id state.handlers with 356 + | None -> () 357 + | Some handler -> handler value 358 + ``` 359 + 360 + **Step 4: Verify build** 361 + 362 + Run: `opam exec -- dune build js_top_worker/widget/` 363 + Expected: Clean build. 364 + 365 + **Step 5: Commit** 366 + 367 + ```bash 368 + git add js_top_worker/widget/ 369 + git commit -m "feat: add Widget module for interactive toplevel widgets" 370 + ``` 371 + 372 + --- 373 + 374 + ### Task 5: Wire Widget into the worker 375 + 376 + Connect the Widget module to the worker's message loop: initialize the sender, handle WidgetEvent messages, and add the widget library as a dependency. 377 + 378 + **Files:** 379 + - Modify: `js_top_worker/lib/worker.ml` 380 + - Modify: `js_top_worker/lib/dune` (add widget dependency to `js_top_worker-web`) 381 + 382 + **Step 1: Add js_top_worker-widget to js_top_worker-web dependencies** 383 + 384 + In `js_top_worker/lib/dune`, in the `js_top_worker-web` library stanza, add `js_top_worker-widget` to the libraries list: 385 + 386 + ``` 387 + (libraries 388 + js_top_worker 389 + js_top_worker-rpc.message 390 + js_top_worker-widget 391 + js_of_ocaml-ppx 392 + ... 393 + ``` 394 + 395 + **Step 2: Initialize Widget sender in worker.ml** 396 + 397 + In `worker.ml`, in the `run` function, add after the `Logs.set_level` line (~line 343): 398 + 399 + ```ocaml 400 + (* Initialize Widget module sender *) 401 + Widget.set_sender (fun json -> 402 + Jslib.log "Widget sending: %s" json; 403 + Js_of_ocaml.Worker.post_message (Js_of_ocaml.Js.string json)); 404 + ``` 405 + 406 + **Step 3: Handle WidgetEvent in worker.ml** 407 + 408 + In `handle_message`, add a new case: 409 + 410 + ```ocaml 411 + | Msg.WidgetEvent { widget_id; handler_id; value; _ } -> 412 + Widget.handle_event ~widget_id ~handler_id ~value; 413 + Lwt.return_unit 414 + ``` 415 + 416 + **Step 4: Verify build** 417 + 418 + Run: `opam exec -- dune build js_top_worker/` 419 + Expected: Clean build. The worker now handles widget messages. 420 + 421 + **Step 5: Commit** 422 + 423 + ```bash 424 + git add js_top_worker/lib/dune js_top_worker/lib/worker.ml 425 + git commit -m "feat: wire Widget module into worker message loop" 426 + ``` 427 + 428 + --- 429 + 430 + ### Task 6: Update JS client 431 + 432 + Add widget support to ocaml-worker.js: new constructor options for widget callbacks, new method to send widget events, and handling for widget message types. 433 + 434 + **Files:** 435 + - Modify: `js_top_worker/client/ocaml-worker.js` 436 + - Modify: `js_top_worker/client/ocaml-worker.d.ts` 437 + 438 + **Step 1: Add widget options to constructor** 439 + 440 + In the constructor (~line 143), after `this.onOutputAt = options.onOutputAt || null;`, add: 441 + 442 + ```javascript 443 + this.onWidgetUpdate = options.onWidgetUpdate || null; 444 + this.onWidgetClear = options.onWidgetClear || null; 445 + ``` 446 + 447 + **Step 2: Handle widget messages in _handleMessage** 448 + 449 + In `_handleMessage`, add cases before the `default:` in the switch: 450 + 451 + ```javascript 452 + case 'widget_update': 453 + if (this.onWidgetUpdate) { 454 + this.onWidgetUpdate(msg); 455 + } 456 + break; 457 + 458 + case 'widget_clear': 459 + if (this.onWidgetClear) { 460 + this.onWidgetClear(msg); 461 + } 462 + break; 463 + ``` 464 + 465 + **Step 3: Add sendWidgetEvent method** 466 + 467 + Add after the `destroyEnv` method: 468 + 469 + ```javascript 470 + /** 471 + * Send a widget event to the worker. 472 + * @param {string} widgetId - Widget identifier 473 + * @param {string} handlerId - Handler identifier within the widget 474 + * @param {string} eventType - DOM event type (e.g., 'input', 'click') 475 + * @param {string|null} [value=null] - Input value if applicable 476 + */ 477 + sendWidgetEvent(widgetId, handlerId, eventType, value = null) { 478 + this.worker.postMessage(JSON.stringify({ 479 + type: 'widget_event', 480 + widget_id: widgetId, 481 + handler_id: handlerId, 482 + event_type: eventType, 483 + value: value, 484 + })); 485 + } 486 + ``` 487 + 488 + **Step 4: Update TypeScript definitions** 489 + 490 + In `ocaml-worker.d.ts`, add to the options interface and class: 491 + 492 + ```typescript 493 + onWidgetUpdate?: (msg: { widget_id: string; view: any }) => void; 494 + onWidgetClear?: (msg: { widget_id: string }) => void; 495 + ``` 496 + 497 + And add the method: 498 + 499 + ```typescript 500 + sendWidgetEvent(widgetId: string, handlerId: string, eventType: string, value?: string | null): void; 501 + ``` 502 + 503 + **Step 5: Verify build (no compilation needed for JS, but check syntax)** 504 + 505 + Run: `node -c js_top_worker/client/ocaml-worker.js` 506 + Expected: No syntax errors. 507 + 508 + **Step 6: Commit** 509 + 510 + ```bash 511 + git add js_top_worker/client/ocaml-worker.js js_top_worker/client/ocaml-worker.d.ts 512 + git commit -m "feat: add widget support to JS client library" 513 + ``` 514 + 515 + --- 516 + 517 + ### Task 7: Add widget to example worker and create test page 518 + 519 + Add the Widget library to the example worker's dependencies (so it's available in the toplevel), create a test HTML page that exercises the full round-trip: eval creates a widget with Note signals, frontend renders it, user interaction sends events back, view updates. 520 + 521 + **Files:** 522 + - Modify: `js_top_worker/example/dune` (add js_top_worker-widget to worker deps) 523 + - Create: `js_top_worker/example/widget_test.html` 524 + 525 + **Step 1: Add widget dependency to example worker** 526 + 527 + In `js_top_worker/example/dune`, in the worker executable stanza, add `js_top_worker-widget` and `note` to libraries: 528 + 529 + ``` 530 + (libraries js_top_worker-web logs.browser mime_printer tyxml js_top_worker-widget note)) 531 + ``` 532 + 533 + Also add `note` to the `jtw opam` rule so it's available as a toplevel package: 534 + 535 + ``` 536 + (run jtw opam -o _opam str stringext mime_printer note))) 537 + ``` 538 + 539 + **Step 2: Create widget_test.html** 540 + 541 + `js_top_worker/example/widget_test.html`: 542 + 543 + ```html 544 + <!DOCTYPE html> 545 + <html> 546 + <head> 547 + <meta charset="utf-8"> 548 + <title>Widget Protocol Test</title> 549 + <style> 550 + body { font-family: monospace; max-width: 800px; margin: 2em auto; } 551 + #log { background: #f5f5f5; padding: 1em; white-space: pre-wrap; max-height: 400px; overflow-y: auto; } 552 + .widget-container { border: 1px solid #ccc; padding: 1em; margin: 1em 0; } 553 + .widget-container h3 { margin-top: 0; color: #666; } 554 + button { margin: 0.5em; padding: 0.5em 1em; } 555 + </style> 556 + </head> 557 + <body> 558 + <h1>Widget Protocol Test</h1> 559 + <div id="widgets"></div> 560 + <h2>Log</h2> 561 + <div id="log"></div> 562 + 563 + <script type="module"> 564 + import { OcamlWorker } from '../client/ocaml-worker.js'; 565 + 566 + const logEl = document.getElementById('log'); 567 + const widgetsEl = document.getElementById('widgets'); 568 + let worker; 569 + 570 + function log(msg) { 571 + logEl.textContent += msg + '\n'; 572 + logEl.scrollTop = logEl.scrollHeight; 573 + } 574 + 575 + // --- View.node JSON renderer --- 576 + function renderNode(node, worker) { 577 + if (node.t === 'txt') { 578 + return document.createTextNode(node.v); 579 + } 580 + if (node.t === 'el') { 581 + const el = document.createElement(node.tag); 582 + 583 + // Attributes 584 + for (const attr of (node.a || [])) { 585 + switch (attr.t) { 586 + case 'prop': 587 + el.setAttribute(attr.k, attr.v); 588 + break; 589 + case 'style': 590 + el.style[attr.k] = attr.v; 591 + break; 592 + case 'cls': 593 + el.classList.add(attr.v); 594 + break; 595 + case 'handler': 596 + el.addEventListener(attr.ev, (event) => { 597 + const isInput = ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName); 598 + const value = isInput ? el.value : null; 599 + const widgetId = el.closest('[data-widget-id]')?.dataset.widgetId; 600 + if (widgetId) { 601 + log(`Event: widget=${widgetId} handler=${attr.id} value=${value}`); 602 + worker.sendWidgetEvent(widgetId, attr.id, attr.ev, value); 603 + } 604 + }); 605 + break; 606 + } 607 + } 608 + 609 + // Children 610 + for (const child of (node.c || [])) { 611 + el.appendChild(renderNode(child, worker)); 612 + } 613 + return el; 614 + } 615 + return document.createTextNode(''); 616 + } 617 + 618 + function renderWidget(widgetId, viewJson) { 619 + let container = document.getElementById('widget-' + widgetId); 620 + if (!container) { 621 + container = document.createElement('div'); 622 + container.id = 'widget-' + widgetId; 623 + container.className = 'widget-container'; 624 + container.dataset.widgetId = widgetId; 625 + container.innerHTML = `<h3>${widgetId}</h3>`; 626 + widgetsEl.appendChild(container); 627 + } 628 + // Keep the heading, replace content 629 + const heading = container.querySelector('h3'); 630 + container.innerHTML = ''; 631 + container.appendChild(heading); 632 + container.dataset.widgetId = widgetId; 633 + container.appendChild(renderNode(viewJson, worker)); 634 + } 635 + 636 + async function run() { 637 + log('Creating worker...'); 638 + worker = new OcamlWorker('_opam/worker.js', { 639 + onWidgetUpdate: (msg) => { 640 + log(`WidgetUpdate: id=${msg.widget_id}`); 641 + renderWidget(msg.widget_id, msg.view); 642 + }, 643 + onWidgetClear: (msg) => { 644 + log(`WidgetClear: id=${msg.widget_id}`); 645 + const container = document.getElementById('widget-' + msg.widget_id); 646 + if (container) container.remove(); 647 + }, 648 + onOutputAt: (msg) => { 649 + if (msg.caml_ppf) log(`OutputAt: ${msg.caml_ppf}`); 650 + }, 651 + }); 652 + 653 + log('Initializing...'); 654 + await worker.init({ 655 + findlib_requires: [], 656 + findlib_index: null, 657 + }); 658 + log('Worker ready.'); 659 + 660 + // Test 1: Static widget (no FRP) 661 + log('\n--- Test 1: Static widget ---'); 662 + const r1 = await worker.eval(` 663 + Widget.display ~id:"hello" 664 + ~handlers:[] 665 + (Widget_view.Element { 666 + tag = "div"; attrs = []; 667 + children = [Widget_view.Text "Hello from OCaml!"] 668 + });; 669 + `); 670 + log(`Eval result: ${r1.caml_ppf}`); 671 + 672 + // Test 2: Interactive counter with Note 673 + log('\n--- Test 2: Interactive counter ---'); 674 + const r2 = await worker.eval(` 675 + let inc_e, send_inc = Note.E.create ();; 676 + let dec_e, send_dec = Note.E.create ();; 677 + let count = 678 + let delta = Note.E.select [ 679 + Note.E.map (fun () n -> n + 1) inc_e; 680 + Note.E.map (fun () n -> n - 1) dec_e; 681 + ] in 682 + Note.S.accum 0 delta;; 683 + 684 + let counter_view n = 685 + let open Widget_view in 686 + Element { tag = "div"; attrs = [Class "counter"]; children = [ 687 + Element { tag = "button"; 688 + attrs = [Handler ("click", "dec")]; 689 + children = [Text "-"] }; 690 + Element { tag = "span"; 691 + attrs = [Style ("margin", "0 1em")]; 692 + children = [Text (string_of_int n)] }; 693 + Element { tag = "button"; 694 + attrs = [Handler ("click", "inc")]; 695 + children = [Text "+"] }; 696 + ] };; 697 + 698 + Widget.display ~id:"counter" 699 + ~handlers:[ 700 + "inc", (fun _ -> send_inc ()); 701 + "dec", (fun _ -> send_dec ()); 702 + ] 703 + (counter_view 0);; 704 + 705 + let _logr = Note.S.log 706 + (Note.S.map counter_view count) 707 + (Widget.update ~id:"counter");; 708 + Note.Logr.hold _logr;; 709 + `); 710 + log(`Eval result: ${r2.caml_ppf}`); 711 + 712 + // Test 3: Interactive slider (cross-cell signals) 713 + log('\n--- Test 3: Slider (cell 1) ---'); 714 + await worker.eval(` 715 + let x_e, send_x = Note.E.create ();; 716 + let x = Note.S.hold 50 x_e;; 717 + 718 + let slider_view v = 719 + let open Widget_view in 720 + Element { tag = "div"; attrs = []; children = [ 721 + Element { tag = "label"; attrs = []; 722 + children = [Text (Printf.sprintf "X: %d" v)] }; 723 + Element { tag = "input"; attrs = [ 724 + Property ("type", "range"); 725 + Property ("min", "0"); 726 + Property ("max", "100"); 727 + Property ("value", string_of_int v); 728 + Handler ("input", "x"); 729 + ]; children = [] }; 730 + ] };; 731 + 732 + Widget.display ~id:"slider" 733 + ~handlers:["x", (fun v -> 734 + send_x (int_of_string (Option.get v)))] 735 + (slider_view 50);; 736 + 737 + let _logr2 = Note.S.log 738 + (Note.S.map slider_view x) 739 + (Widget.update ~id:"slider");; 740 + Note.Logr.hold _logr2;; 741 + `); 742 + 743 + log('\n--- Test 3b: Derived widget (cell 2, uses x from cell 1) ---'); 744 + await worker.eval(` 745 + let doubled_view v = 746 + let open Widget_view in 747 + Element { tag = "div"; attrs = []; children = [ 748 + Text (Printf.sprintf "2x = %d" (v * 2)) 749 + ] };; 750 + 751 + Widget.display ~id:"doubled" 752 + ~handlers:[] 753 + (doubled_view (Note.S.value x));; 754 + 755 + let _logr3 = Note.S.log 756 + (Note.S.map doubled_view x) 757 + (Widget.update ~id:"doubled");; 758 + Note.Logr.hold _logr3;; 759 + `); 760 + 761 + log('\nAll tests dispatched. Interact with widgets above.'); 762 + } 763 + 764 + run().catch(e => log('Error: ' + e.message)); 765 + </script> 766 + </body> 767 + </html> 768 + ``` 769 + 770 + **Step 3: Add widget_test.html to the default alias** 771 + 772 + In `js_top_worker/example/dune`, add `widget_test.html` to the alias deps list. 773 + 774 + **Step 4: Build and verify** 775 + 776 + Run: `opam exec -- dune build js_top_worker/example/` 777 + Expected: Clean build. The worker now includes Widget and Note modules. 778 + 779 + **Step 5: Commit** 780 + 781 + ```bash 782 + git add js_top_worker/example/dune js_top_worker/example/widget_test.html 783 + git commit -m "feat: add widget test page with counter and slider examples" 784 + ``` 785 + 786 + --- 787 + 788 + ### Task 8: Browser test 789 + 790 + Serve the example directory and test the full round-trip in a browser. 791 + 792 + **Step 1: Build and serve** 793 + 794 + ```bash 795 + opam exec -- dune build js_top_worker/example/ 796 + cd _build/default/js_top_worker/example 797 + python3 -m http.server 8080 798 + ``` 799 + 800 + **Step 2: Open widget_test.html** 801 + 802 + Navigate to `http://localhost:8080/widget_test.html`. 803 + 804 + Expected behavior: 805 + 1. Log shows "Worker ready." 806 + 2. "hello" widget appears with "Hello from OCaml!" text 807 + 3. "counter" widget appears with - 0 + buttons 808 + 4. Clicking + increments the counter, clicking - decrements 809 + 5. "slider" widget appears with a range input at 50 810 + 6. "doubled" widget appears with "2x = 100" 811 + 7. Dragging the slider updates both the slider label AND the doubled display 812 + 813 + **Step 3: Fix any issues found during testing** 814 + 815 + If events don't route or views don't render, debug by checking the browser console log (the worker logs all messages). 816 + 817 + **Step 4: Commit any fixes** 818 + 819 + ```bash 820 + git add -A 821 + git commit -m "fix: browser test fixes for widget protocol" 822 + ``` 823 + 824 + --- 825 + 826 + ## Summary 827 + 828 + | Task | What | Files | 829 + |------|------|-------| 830 + | 1 | View types in message lib | `idl/widget_view.ml`, `idl/dune` | 831 + | 2 | View.node JSON encoding | `idl/message.ml` | 832 + | 3 | Widget message types | `idl/message.ml` | 833 + | 4 | Widget module | `widget/widget.ml`, `widget.mli`, `dune` | 834 + | 5 | Worker integration | `lib/worker.ml`, `lib/dune` | 835 + | 6 | JS client updates | `client/ocaml-worker.js`, `.d.ts` | 836 + | 7 | Test page + worker deps | `example/dune`, `example/widget_test.html` | 837 + | 8 | Browser test | Manual verification | 838 + 839 + All paths relative to `js_top_worker/`. Tasks 1–3 are protocol-only (no runtime behavior). Tasks 4–5 add worker-side runtime. Task 6 adds client-side runtime. Tasks 7–8 validate end-to-end.