···11+# Widget/FRP Experiments C & D — Implementation Plan
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Build a richer multi-slider widget (Experiment C) and evaluate TyXML functor integration for type-safe HTML construction (Experiment D), both using Note as the FRP library.
66+77+**Architecture:** Same worker/main-thread split as Experiments A/B. Experiment C extends the renderer to extract input values from DOM events and tests Note's composition with multiple interacting signals. Experiment D implements TyXML's `Xml_sigs.T` over our `View.node` type, giving a typed HTML DSL that produces serializable view trees.
88+99+**Tech Stack:** OCaml, js_of_ocaml 6.2, Brr 0.0.8, Note 0.0.3, TyXML 4.6.0 (`tyxml.functor`), dune 3.21
1010+1111+**Existing code:** `experiments/widget-bridge/` with `lib/view.ml`, `renderer/renderer.ml`, working Note counter in `note_counter/`.
1212+1313+---
1414+1515+## Task 1: Extend renderer to extract input values
1616+1717+The renderer currently sends `value = None` for all events. For
1818+`<input>`, `<select>`, and `<textarea>` elements, it should extract
1919+the element's current value and include it in the event message.
2020+2121+**Files:**
2222+- Modify: `experiments/widget-bridge/renderer/renderer.ml`
2323+2424+**Step 1: Read the current renderer code**
2525+2626+Read `experiments/widget-bridge/renderer/renderer.ml` to understand
2727+the current event handler wiring (lines 19-28).
2828+2929+The key change: when an event fires on an input element, read the
3030+DOM element's `.value` property and include it in `event_msg.value`.
3131+3232+**Step 2: Modify the Handler arm to extract values**
3333+3434+Replace the Handler case in the `List.iter` block:
3535+3636+```ocaml
3737+| Handler (event_name, handler_id) ->
3838+ let is_input = match tag with
3939+ | "input" | "select" | "textarea" -> true
4040+ | _ -> false
4141+ in
4242+ ignore @@ Ev.listen
4343+ (Ev.Type.void (Jstr.v event_name))
4444+ (fun _ev ->
4545+ let value =
4646+ if is_input then
4747+ Some (Jstr.to_string (El.prop El.Prop.value el))
4848+ else None
4949+ in
5050+ on_event { View.handler_id; event_type = event_name; value })
5151+ (El.as_target el)
5252+```
5353+5454+Note: `tag` is already bound in the outer `Element` match arm.
5555+`El.prop El.Prop.value el` reads the DOM `.value` property. Check
5656+`$(opam var lib)/brr/brr.mli` for `El.Prop.value` — it may be
5757+`El.Prop.value` or need `Jstr.to_string` wrapping.
5858+5959+If `El.Prop.value` doesn't exist, use:
6060+```ocaml
6161+Jstr.to_string (Jv.Jstr.get (El.to_jv el) "value")
6262+```
6363+6464+**Step 3: Build**
6565+6666+Run: `opam exec -- dune build --root experiments/widget-bridge`
6767+6868+Expected: Compiles without errors.
6969+7070+**Step 4: Commit**
7171+7272+```bash
7373+git add experiments/widget-bridge/renderer/renderer.ml
7474+git commit -m "feat: extract input values from DOM events in renderer"
7575+```
7676+7777+---
7878+7979+## Task 2: Experiment C — Two-slider widget with Note
8080+8181+Two range sliders (X, Y) controlling a computed display (X * Y).
8282+Tests Note's composition with multiple interacting signals.
8383+8484+**Files:**
8585+- Create: `experiments/widget-bridge/sliders/dune`
8686+- Create: `experiments/widget-bridge/sliders/worker.ml`
8787+- Create: `experiments/widget-bridge/sliders/main.ml`
8888+- Create: `experiments/widget-bridge/html/sliders.html`
8989+9090+**Step 1: Create sliders/dune**
9191+9292+```ocaml
9393+(executable
9494+ (name worker)
9595+ (modules worker)
9696+ (modes js)
9797+ (libraries view note js_of_ocaml))
9898+9999+(executable
100100+ (name main)
101101+ (modules main)
102102+ (modes js)
103103+ (libraries view renderer brr))
104104+```
105105+106106+**Step 2: Create sliders/worker.ml**
107107+108108+```ocaml
109109+open View
110110+111111+(* --- Event sources --- *)
112112+113113+let x_input, send_x = Note.E.create ()
114114+let y_input, send_y = Note.E.create ()
115115+116116+(* --- Reactive state --- *)
117117+118118+let x = Note.S.hold 50 x_input
119119+let y = Note.S.hold 50 y_input
120120+121121+(* --- View derivation --- *)
122122+123123+let slider ~label ~handler_id value =
124124+ Element { tag = "div"; attrs = [Class "slider-row"]; children = [
125125+ Element { tag = "label"; attrs = []; children = [
126126+ Text (Printf.sprintf "%s: %d" label value)
127127+ ] };
128128+ Element { tag = "input"; attrs = [
129129+ Property ("type", "range");
130130+ Property ("min", "0");
131131+ Property ("max", "100");
132132+ Property ("value", string_of_int value);
133133+ Handler ("input", handler_id);
134134+ ]; children = [] };
135135+ ] }
136136+137137+let view =
138138+ Note.S.l2 (fun x y ->
139139+ Element { tag = "div"; attrs = [Class "sliders"]; children = [
140140+ slider ~label:"X" ~handler_id:"x" x;
141141+ slider ~label:"Y" ~handler_id:"y" y;
142142+ Element { tag = "div"; attrs = [Class "result"]; children = [
143143+ Text (Printf.sprintf "X × Y = %d" (x * y))
144144+ ] };
145145+ ] })
146146+ x y
147147+148148+(* --- Worker loop --- *)
149149+150150+let send_view (v : node) =
151151+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes v [])
152152+153153+let () =
154154+ let logr = Note.S.log view send_view in
155155+ Note.Logr.hold logr;
156156+ Js_of_ocaml.Worker.set_onmessage (fun data ->
157157+ let ev : event_msg = Marshal.from_bytes data 0 in
158158+ match ev.handler_id, ev.value with
159159+ | "x", Some v -> (try send_x (int_of_string v) with _ -> ())
160160+ | "y", Some v -> (try send_y (int_of_string v) with _ -> ())
161161+ | _ -> ())
162162+```
163163+164164+**Step 3: Create sliders/main.ml**
165165+166166+Copy from `note_counter/main.ml`, changing the worker URL:
167167+168168+```ocaml
169169+module Worker = Brr_webworkers.Worker
170170+open Brr
171171+172172+let () =
173173+ let container =
174174+ match Document.find_el_by_id G.document (Jstr.v "app") with
175175+ | Some el -> el
176176+ | None -> failwith "No #app element found"
177177+ in
178178+ let worker = Worker.create (Jstr.v "../sliders/worker.bc.js") in
179179+ let on_event (ev : View.event_msg) =
180180+ Worker.post worker (Marshal.to_bytes ev [])
181181+ in
182182+ ignore @@ Ev.listen Brr_io.Message.Ev.message
183183+ (fun msg ->
184184+ let msg = Ev.as_type msg in
185185+ let data : bytes = Brr_io.Message.Ev.data msg in
186186+ let view : View.node = Marshal.from_bytes data 0 in
187187+ let t0 = Performance.now_ms G.performance in
188188+ let el = Renderer.render ~on_event view in
189189+ El.set_children container [el];
190190+ let t1 = Performance.now_ms G.performance in
191191+ Console.(log [str "Render:"; (t1 -. t0); str "ms"]))
192192+ (Worker.as_target worker)
193193+```
194194+195195+**Step 4: Create html/sliders.html**
196196+197197+```html
198198+<!DOCTYPE html>
199199+<html>
200200+<head>
201201+ <meta charset="utf-8">
202202+ <title>Experiment C: Two Sliders</title>
203203+ <style>
204204+ body { font-family: system-ui, sans-serif; padding: 2rem; }
205205+ .sliders { max-width: 400px; }
206206+ .slider-row { margin-bottom: 1rem; }
207207+ .slider-row label { display: block; margin-bottom: 0.25rem; font-size: 1.1rem; }
208208+ .slider-row input[type="range"] { width: 100%; }
209209+ .result { font-size: 1.5rem; margin-top: 1rem; padding: 0.5rem;
210210+ background: #f0f0f0; border-radius: 4px; text-align: center; }
211211+ </style>
212212+</head>
213213+<body>
214214+ <h1>Experiment C: Two Sliders</h1>
215215+ <div id="app">Loading...</div>
216216+ <script src="../sliders/main.bc.js"></script>
217217+</body>
218218+</html>
219219+```
220220+221221+**Step 5: Build**
222222+223223+Run: `opam exec -- dune build --root experiments/widget-bridge`
224224+225225+Expected: Compiles and produces `sliders/worker.bc.js` and
226226+`sliders/main.bc.js`.
227227+228228+**Step 6: Test in browser**
229229+230230+Serve: `python3 -m http.server 8765 --directory experiments/widget-bridge/_build/default`
231231+232232+Copy HTML: `cp -r experiments/widget-bridge/html experiments/widget-bridge/_build/default/html`
233233+234234+Open `http://localhost:8765/html/sliders.html`. Expected:
235235+- Two range sliders labelled X and Y, both starting at 50
236236+- A result display showing "X × Y = 2500"
237237+- Dragging either slider updates the label and result in real-time
238238+239239+**Step 7: Commit**
240240+241241+```bash
242242+git add experiments/widget-bridge/sliders/ experiments/widget-bridge/html/sliders.html
243243+git commit -m "feat: Experiment C — two-slider widget with Note"
244244+```
245245+246246+---
247247+248248+## Task 3: Implement TyXML backend over View.node
249249+250250+Create a module implementing `Xml_sigs.T` that produces `View.node`
251251+values. Then use `Svg_f.Make` and `Html_f.Make` to get a type-safe
252252+HTML DSL.
253253+254254+**Files:**
255255+- Create: `experiments/widget-bridge/tyxml_backend/dune`
256256+- Create: `experiments/widget-bridge/tyxml_backend/view_xml.ml`
257257+- Create: `experiments/widget-bridge/tyxml_backend/view_html.ml`
258258+259259+**Step 1: Create tyxml_backend/dune**
260260+261261+```ocaml
262262+(library
263263+ (name view_html)
264264+ (libraries view tyxml))
265265+```
266266+267267+Note: `tyxml` (not `tyxml.functor`) — check which library name
268268+exposes `Html_f`, `Svg_f`, `Xml_sigs`, `Xml_wrap`. If the build
269269+fails, try `tyxml.functor` instead.
270270+271271+**Step 2: Create tyxml_backend/view_xml.ml**
272272+273273+This implements `Xml_sigs.T` using `View.node` as the element type.
274274+275275+```ocaml
276276+(** TyXML XML backend producing {!View.node} values. *)
277277+278278+include Xml_sigs.T
279279+ with type elt = View.node
280280+ and type attrib = View.attr
281281+282282+module W = Xml_wrap.NoWrap
283283+284284+type 'a wrap = 'a
285285+type 'a list_wrap = 'a list
286286+287287+(* --- URIs --- *)
288288+289289+type uri = string
290290+let uri_of_string s = s
291291+let string_of_uri s = s
292292+293293+(* --- Attributes --- *)
294294+295295+type aname = string
296296+297297+(* Event handlers are handler IDs; strip "on" prefix for event name *)
298298+type event_handler = string
299299+type mouse_event_handler = string
300300+type keyboard_event_handler = string
301301+type touch_event_handler = string
302302+303303+type attrib = View.attr
304304+305305+let float_attrib name value = View.Property (name, string_of_float value)
306306+let int_attrib name value = View.Property (name, string_of_int value)
307307+let string_attrib name value = View.Property (name, value)
308308+let space_sep_attrib name values = View.Property (name, String.concat " " values)
309309+let comma_sep_attrib name values = View.Property (name, String.concat "," values)
310310+311311+let event_name_of_aname aname =
312312+ (* "onclick" -> "click", "oninput" -> "input", etc. *)
313313+ if String.length aname > 2 && String.sub aname 0 2 = "on"
314314+ then String.sub aname 2 (String.length aname - 2)
315315+ else aname
316316+317317+let event_handler_attrib aname handler_id =
318318+ View.Handler (event_name_of_aname aname, handler_id)
319319+let mouse_event_handler_attrib = event_handler_attrib
320320+let keyboard_event_handler_attrib = event_handler_attrib
321321+let touch_event_handler_attrib = event_handler_attrib
322322+let uri_attrib name value = View.Property (name, value)
323323+let uris_attrib name values = View.Property (name, String.concat " " values)
324324+325325+(* --- Elements --- *)
326326+327327+type elt = View.node
328328+type ename = string
329329+330330+let empty () = View.Text ""
331331+let comment _s = View.Text "" (* Comments not meaningful in view tree *)
332332+let pcdata s = View.Text s
333333+let encodedpcdata s = View.Text s
334334+let entity e = View.Text ("&" ^ e ^ ";")
335335+336336+let leaf ?(a = []) name =
337337+ View.Element { tag = name; attrs = a; children = [] }
338338+339339+let node ?(a = []) name children =
340340+ View.Element { tag = name; attrs = a; children }
341341+342342+let cdata s = View.Text s
343343+let cdata_script s = View.Text s
344344+let cdata_style s = View.Text s
345345+```
346346+347347+**IMPORTANT**: This code likely won't compile as-is. The `Xml_sigs.T`
348348+signature is strict. Read `$(opam var lib)/tyxml/functor/xml_sigs.mli`
349349+carefully and adjust:
350350+351351+- The `include` at the top is pseudocode — you need a proper module
352352+ that satisfies the signature. Write the module body and let it be
353353+ constrained by usage.
354354+- Check if `W` needs to be a specific module type
355355+- Check the exact types of `float_attrib`, `int_attrib` etc. — they
356356+ may take wrapped values (`float wrap` not `float`)
357357+358358+Reference: `/home/jons-agent/.opam/default/lib/tyxml/tyxml_xml.ml`
359359+is the built-in string backend — copy its structure.
360360+361361+**Step 3: Create tyxml_backend/view_html.ml**
362362+363363+```ocaml
364364+(** Type-safe HTML DSL producing {!View.node} values.
365365+366366+ Usage:
367367+ {[
368368+ open View_html
369369+ let my_div = div ~a:[a_class ["counter"]] [
370370+ button ~a:[a_onclick "inc"] [txt "+"];
371371+ span [txt "0"];
372372+ ]
373373+ (* my_div : [> `Div ] elt, but underlying type is View.node *)
374374+ ]}
375375+*)
376376+377377+module Xml = View_xml
378378+module Svg = Svg_f.Make(Xml)
379379+module Html = Html_f.Make(Xml)(Svg)
380380+include Html
381381+```
382382+383383+**Step 4: Build**
384384+385385+Run: `opam exec -- dune build --root experiments/widget-bridge`
386386+387387+This will likely need several iterations. Common issues:
388388+- `Xml_sigs.T` signature mismatches — read the exact signature
389389+- `Svg_f.Make` may need additional constraints
390390+- `Html_f.Make` may require `Xml` with specific `W.ft` type
391391+392392+Fix compilation errors by reading the .mli files and adjusting
393393+`view_xml.ml`. The built-in `tyxml_xml.ml` at
394394+`$(opam var lib)/tyxml/tyxml_xml.ml` is the authoritative reference.
395395+396396+**Step 5: Commit**
397397+398398+```bash
399399+git add experiments/widget-bridge/tyxml_backend/
400400+git commit -m "feat: TyXML backend producing View.node values"
401401+```
402402+403403+---
404404+405405+## Task 4: Experiment D — Rewrite sliders with TyXML HTML DSL
406406+407407+Use the TyXML-based HTML DSL from Task 3 to build the same two-slider
408408+widget. Compare ergonomics with the raw `View.node` construction.
409409+410410+**Files:**
411411+- Create: `experiments/widget-bridge/tyxml_sliders/dune`
412412+- Create: `experiments/widget-bridge/tyxml_sliders/worker.ml`
413413+- Create: `experiments/widget-bridge/tyxml_sliders/main.ml`
414414+- Create: `experiments/widget-bridge/html/tyxml_sliders.html`
415415+416416+**Step 1: Create tyxml_sliders/dune**
417417+418418+```ocaml
419419+(executable
420420+ (name worker)
421421+ (modules worker)
422422+ (modes js)
423423+ (libraries view view_html note js_of_ocaml))
424424+425425+(executable
426426+ (name main)
427427+ (modules main)
428428+ (modes js)
429429+ (libraries view renderer brr))
430430+```
431431+432432+**Step 2: Create tyxml_sliders/worker.ml**
433433+434434+```ocaml
435435+open View
436436+open View_html
437437+438438+(* --- Event sources --- *)
439439+440440+let x_input, send_x = Note.E.create ()
441441+let y_input, send_y = Note.E.create ()
442442+443443+(* --- Reactive state --- *)
444444+445445+let x = Note.S.hold 50 x_input
446446+let y = Note.S.hold 50 y_input
447447+448448+(* --- View derivation with TyXML DSL --- *)
449449+450450+let slider_row ~label_text ~handler_id value =
451451+ div ~a:[a_class ["slider-row"]] [
452452+ label [txt (Printf.sprintf "%s: %d" label_text value)];
453453+ input ~a:[
454454+ a_input_type `Range;
455455+ a_input_min (`Number 0);
456456+ a_input_max (`Number 100);
457457+ a_value (string_of_int value);
458458+ a_oninput handler_id;
459459+ ] ();
460460+ ]
461461+ |> toelt (* Convert typed elt to View.node *)
462462+463463+let view =
464464+ Note.S.l2 (fun x y ->
465465+ Element { tag = "div"; attrs = [Class "sliders"]; children = [
466466+ slider_row ~label_text:"X" ~handler_id:"x" x;
467467+ slider_row ~label_text:"Y" ~handler_id:"y" y;
468468+ toelt (div ~a:[a_class ["result"]] [
469469+ txt (Printf.sprintf "X × Y = %d" (x * y))
470470+ ]);
471471+ ] })
472472+ x y
473473+474474+(* --- Worker loop (identical to Experiment C) --- *)
475475+476476+let send_view (v : node) =
477477+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes v [])
478478+479479+let () =
480480+ let logr = Note.S.log view send_view in
481481+ Note.Logr.hold logr;
482482+ Js_of_ocaml.Worker.set_onmessage (fun data ->
483483+ let ev : event_msg = Marshal.from_bytes data 0 in
484484+ match ev.handler_id, ev.value with
485485+ | "x", Some v -> (try send_x (int_of_string v) with _ -> ())
486486+ | "y", Some v -> (try send_y (int_of_string v) with _ -> ())
487487+ | _ -> ())
488488+```
489489+490490+**IMPORTANT**: The TyXML attribute names may differ from what's
491491+shown above. Check the generated `View_html` module for exact
492492+function names:
493493+- `a_input_type` may be `a_input_type` or `a_type`
494494+- `a_input_min` may be `a_min` or take a different argument type
495495+- `a_value` may take `Jstr.t` or `string` depending on wrapping
496496+- `a_oninput` takes an `event_handler` (= `string` in our backend)
497497+- `toelt` converts `'a elt` to `Xml.elt` (= `View.node`)
498498+499499+If TyXML attribute functions don't match, read the generated
500500+module interface or use `View_html.Xml.string_attrib` as fallback.
501501+502502+**Step 3: Create tyxml_sliders/main.ml**
503503+504504+Copy from `sliders/main.ml`, change worker URL to
505505+`"../tyxml_sliders/worker.bc.js"`.
506506+507507+**Step 4: Create html/tyxml_sliders.html**
508508+509509+Copy from `html/sliders.html`, change title to "Experiment D: TyXML
510510+Sliders" and script src to `"../tyxml_sliders/main.bc.js"`.
511511+512512+**Step 5: Build**
513513+514514+Run: `opam exec -- dune build --root experiments/widget-bridge`
515515+516516+The TyXML DSL may need adjustments — attribute function names,
517517+argument types, or missing `toelt` calls. Fix iteratively.
518518+519519+**Step 6: Test in browser**
520520+521521+Same as Experiment C: serve, open page, drag sliders, verify
522522+the result updates. The behaviour should be identical.
523523+524524+**Step 7: Commit**
525525+526526+```bash
527527+git add experiments/widget-bridge/tyxml_sliders/ experiments/widget-bridge/html/tyxml_sliders.html
528528+git commit -m "feat: Experiment D — TyXML sliders with typed HTML DSL"
529529+```
530530+531531+---
532532+533533+## Task 5: Update RESULTS.md
534534+535535+**Files:**
536536+- Modify: `experiments/widget-bridge/RESULTS.md`
537537+538538+**Step 1: Measure bundle sizes**
539539+540540+```bash
541541+ls -lh experiments/widget-bridge/_build/default/sliders/worker.bc.js
542542+ls -lh experiments/widget-bridge/_build/default/tyxml_sliders/worker.bc.js
543543+```
544544+545545+**Step 2: Count lines**
546546+547547+```bash
548548+wc -l experiments/widget-bridge/sliders/worker.ml
549549+wc -l experiments/widget-bridge/tyxml_sliders/worker.ml
550550+wc -l experiments/widget-bridge/tyxml_backend/view_xml.ml
551551+wc -l experiments/widget-bridge/tyxml_backend/view_html.ml
552552+```
553553+554554+**Step 3: Add Experiment C and D sections to RESULTS.md**
555555+556556+Append sections covering:
557557+558558+- **Experiment C (sliders)**: Does Note composition work well with
559559+ multiple signals? How does `S.l2` feel? Does the input value
560560+ extraction work smoothly?
561561+562562+- **Experiment D (TyXML)**: Code comparison — raw vs typed
563563+ constructor ergonomics. Is `toelt` conversion awkward? Does type
564564+ safety catch real mistakes? Is the setup cost (view_xml.ml) worth
565565+ it? Bundle size impact of tyxml?
566566+567567+- **Updated recommendation**: Confirm or revise the Note
568568+ recommendation. Add guidance on whether TyXML is worth adopting.
569569+570570+**Step 4: Commit**
571571+572572+```bash
573573+git add experiments/widget-bridge/RESULTS.md
574574+git commit -m "docs: add Experiment C and D results to comparison"
575575+```
576576+577577+---
578578+579579+## Troubleshooting
580580+581581+### TyXML functor compilation
582582+583583+The most likely failure point is Task 3 (view_xml.ml). If
584584+`Html_f.Make(View_xml)(View_svg)` fails:
585585+586586+1. Read the exact error — it will say which type or function is
587587+ missing or has the wrong signature
588588+2. Check `$(opam var lib)/tyxml/functor/xml_sigs.mli` for the
589589+ exact `T` signature
590590+3. Check `$(opam var lib)/tyxml/tyxml_xml.ml` for a working
591591+ reference implementation
592592+4. The `W.ft` type constraint is critical — `Html_f.Make` requires
593593+ `('a, 'b) W.ft = 'a -> 'b`
594594+595595+### Range input values
596596+597597+The `input` event fires on every slider movement. The renderer
598598+extracts the value as a string (e.g., "73"). The worker must parse
599599+it with `int_of_string`. If the value is empty or non-numeric, the
600600+`try ... with` guard prevents crashes.
601601+602602+### Note.S.l2 for combining signals
603603+604604+`Note.S.l2 f s1 s2` creates a signal that applies `f` to the current
605605+values of `s1` and `s2` whenever either changes. This is the key
606606+composition primitive for Experiment C. If it doesn't exist, try
607607+`Note.S.map2`.
···11+# Widget Protocol Integration Design
22+33+**Goal:** Integrate the widget/FRP bridge from the experiments into
44+js_top_worker's existing JSON message protocol, enabling interactive
55+widgets in OCaml toplevel output.
66+77+**Context:** Experiments A–D (on the `widget-frp` branch) validated
88+that Note + TyXML + serializable View.node trees work well for
99+worker-side widget authoring. This design integrates that into the
1010+real toplevel.
1111+1212+## Architecture
1313+1414+User OCaml code running in the toplevel creates Note signals and
1515+calls `Widget.display` to register a live widget. The Widget module
1616+attaches a `Note.S.log` to the view signal. Whenever the signal
1717+changes, the logger serializes the View.node tree to JSON and sends
1818+a `WidgetUpdate` message through the existing worker→client message
1919+protocol. User interactions in the frontend produce `WidgetEvent`
2020+messages that flow back to the worker, where the Widget module
2121+routes them to the registered handler callbacks. Those callbacks
2222+fire Note events, which propagate through the signal graph, which
2323+triggers view updates for all affected widgets automatically.
2424+2525+```
2626+┌─────────────────── Worker ───────────────────┐
2727+│ │
2828+│ User code: │
2929+│ let e, send = Note.E.create () │
3030+│ let s = Note.S.hold 50 e │
3131+│ Widget.display ~id:"my-widget" │
3232+│ ~handlers:["x", fun v -> send ...] │
3333+│ (Note.S.map view_fn s) │
3434+│ │
3535+│ Widget module: │
3636+│ S.log → serialize View.node → send msg │
3737+│ receive WidgetEvent → lookup handler → call │
3838+│ │
3939+└──────────── JSON over postMessage ────────────┘
4040+ ↕
4141+┌─────────────── Frontend ─────────────────────┐
4242+│ │
4343+│ Receive WidgetUpdate → render View.node DOM │
4444+│ User interaction → send WidgetEvent back │
4545+│ │
4646+└───────────────────────────────────────────────┘
4747+```
4848+4949+## Key Design Decisions
5050+5151+**Explicit widget IDs.** User code names each widget with a string
5252+ID: `Widget.display ~id:"my-slider" ...`. The frontend uses this ID
5353+to identify where to render the widget and where to route events.
5454+No auto-generated IDs — the user controls naming.
5555+5656+**Handler map co-located with display.** `Widget.display` takes both
5757+a view signal and a handler map:
5858+`~handlers:["x", (fun v -> send_x ...)]`. This keeps the view
5959+definition and its event routing together.
6060+6161+**Signals span cells, widgets render per-ID.** Note signals are
6262+OCaml values that persist in the toplevel environment across evals.
6363+Cell 1 can define a signal, cell 2 can derive from it and display
6464+a widget. When cell 1's widget fires an event, cell 2's widget
6565+updates automatically via the signal graph. Widget rendering is
6666+identified by widget_id, not cell_id.
6767+6868+**Generic protocol.** The protocol carries View.node trees and
6969+string events. No widget-type-specific messages. A slider, a
7070+button, a data table — all just different View.node trees.
7171+Convenience functions (e.g., `Widget.slider`) are built on top
7272+in user-space, not in the protocol.
7373+7474+**JSON serialization.** View.node trees are encoded as JSON using
7575+the same `Js.Unsafe` / `JSON.stringify` pattern as the existing
7676+protocol, for cross-jsoo-version compatibility.
7777+7878+## Protocol Additions
7979+8080+### New message types in message.ml
8181+8282+**Worker → Client:**
8383+8484+```
8585+WidgetUpdate { widget_id: string; view: <View.node as JSON> }
8686+WidgetClear { widget_id: string }
8787+```
8888+8989+**Client → Worker:**
9090+9191+```
9292+WidgetEvent { widget_id: string; handler_id: string;
9393+ event_type: string; value: string option }
9494+```
9595+9696+### View.node JSON encoding
9797+9898+```json
9999+{"t":"el","tag":"div","a":[{"t":"cls","v":"counter"}],
100100+ "c":[{"t":"txt","v":"hello"}]}
101101+```
102102+103103+Attribute variants:
104104+- `{"t":"prop","k":"type","v":"range"}`
105105+- `{"t":"style","k":"margin","v":"0 1em"}`
106106+- `{"t":"cls","v":"active"}`
107107+- `{"t":"handler","ev":"input","id":"x"}`
108108+109109+Short keys (`t`, `k`, `v`, `c`, `a`, `ev`, `id`) to minimize
110110+payload size since these are sent on every signal change.
111111+112112+## Components
113113+114114+### 1. View types (shared library)
115115+116116+Move `View.node`, `View.attr`, `View.event_msg` from
117117+`experiments/widget-bridge/lib/` into a standalone opam package
118118+(like `mime_printer`). Zero dependencies. Used by both the Widget
119119+module (worker-side) and the message protocol.
120120+121121+### 2. JSON encoding (in message.ml)
122122+123123+Add `json_of_view_node`, `view_node_of_json`, `json_of_view_attr`
124124+functions to `message.ml` alongside the existing JSON helpers.
125125+These use the same `Js.Unsafe` / `json_of_obj` pattern.
126126+127127+### 3. Widget module (toplevel-loadable library)
128128+129129+Similar to `mime_printer` — a small library with no heavy
130130+dependencies that gets loaded into the toplevel. Provides:
131131+132132+```ocaml
133133+module Widget : sig
134134+ val display :
135135+ id:string ->
136136+ handlers:(string * (string option -> unit)) list ->
137137+ View.node Note.S.t ->
138138+ unit
139139+140140+ val clear : id:string -> unit
141141+end
142142+```
143143+144144+`display` attaches a `Note.S.log` to the view signal. The logger
145145+calls a `send` function (injected at init time by the worker) to
146146+emit WidgetUpdate messages. It also registers the handler map so
147147+incoming WidgetEvent messages can be routed.
148148+149149+`clear` detaches the logger and sends a WidgetClear message.
150150+151151+**Dependency consideration:** This module depends on `note` and
152152+`widget-view`. It does NOT depend on js_of_ocaml or brr — it
153153+only calls a `send` callback injected by the worker. This keeps
154154+it portable and avoids polluting the toplevel namespace.
155155+156156+### 4. Worker integration (worker.ml)
157157+158158+- Handle `WidgetEvent` in `handle_message`: look up widget_id
159159+ in the Widget module's registry, find the handler_id callback,
160160+ call it.
161161+- Initialize the Widget module's send function at startup so it
162162+ can emit messages.
163163+164164+### 5. JS client (ocaml-worker.js)
165165+166166+- Add `onWidgetUpdate` callback option (like `onOutputAt`)
167167+- Add `onWidgetClear` callback option
168168+- Add `sendWidgetEvent(widgetId, handlerId, eventType, value)`
169169+ method
170170+- Handle new message types in `_handleMessage`
171171+172172+### 6. Frontend renderer
173173+174174+A small JS/OCaml module that takes a View.node JSON tree and
175175+renders it to DOM elements, attaching event listeners that call
176176+`sendWidgetEvent`. This is essentially the experiment's
177177+`renderer.ml` adapted to work with the JSON representation.
178178+179179+For the prototype, this can be a standalone HTML test page rather
180180+than full x-ocaml integration.
181181+182182+## Prototype Scope
183183+184184+The prototype validates the full round-trip: user code creates a
185185+widget with Note signals → view updates flow to the frontend as
186186+JSON → frontend renders DOM → user interaction flows back → signal
187187+graph updates → all affected widgets re-render.
188188+189189+**In scope:**
190190+- View types as shared library
191191+- View.node JSON encoding in message.ml
192192+- Widget module with display/clear/handler routing
193193+- WidgetUpdate/WidgetEvent/WidgetClear message types
194194+- JS client additions (onWidgetUpdate, sendWidgetEvent)
195195+- Worker-side event routing
196196+- Test HTML page with JS renderer
197197+198198+**Out of scope (follow-up work):**
199199+- x-ocaml WebComponent integration
200200+- TyXML functor backend integration (convenience, not required)
201201+- Widget convenience library (Widget.slider, Widget.button, etc.)
202202+- CSS styling / theming
203203+- Widget persistence / serialization
···11+# Widget Protocol Integration — Implementation Plan
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Integrate the widget/FRP bridge into js_top_worker's JSON message protocol so interactive widgets can be created from OCaml toplevel code.
66+77+**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.
88+99+**Tech Stack:** OCaml, js_of_ocaml, note (FRP), dune, Brr (test renderer)
1010+1111+---
1212+1313+## Context
1414+1515+**Design doc:** `docs/plans/2026-02-24-widget-protocol-integration-design.md`
1616+1717+**Key existing files:**
1818+- `js_top_worker/idl/message.ml` — JSON message protocol (310 lines)
1919+- `js_top_worker/lib/worker.ml` — Worker message handler (358 lines)
2020+- `js_top_worker/client/ocaml-worker.js` — JS client (447 lines)
2121+- `mime_printer/mime_printer.ml` — Pattern for toplevel-loadable module (20 lines)
2222+2323+**Key patterns:**
2424+- JSON built with `Js.Unsafe.obj` / `json_of_obj` helper, stringified with `JSON.stringify`
2525+- Messages have a `"type"` field for dispatch
2626+- Worker sends via `Worker.post_message (Js.string json)`
2727+- Client sends via `worker.postMessage(JSON.stringify(msg))`
2828+- All JSON uses plain `JSON.parse`/`JSON.stringify` (NOT jsoo's `Json` module) for cross-version compat
2929+3030+---
3131+3232+### Task 1: Add View types to the message library
3333+3434+Add the serializable view types (from experiments) alongside message.ml. These are the types that cross the worker↔client boundary.
3535+3636+**Files:**
3737+- Create: `js_top_worker/idl/widget_view.ml`
3838+- Modify: `js_top_worker/idl/dune` (add module to `js_top_worker_message` library)
3939+4040+**Step 1: Create widget_view.ml**
4141+4242+```ocaml
4343+(** Serializable view descriptions for interactive widgets.
4444+4545+ No closures, no JS references. Event handlers are symbolic
4646+ string identifiers resolved by the worker's handler registry. *)
4747+4848+type event_id = string
4949+5050+type attr =
5151+ | Property of string * string
5252+ | Style of string * string
5353+ | Class of string
5454+ | Handler of string * event_id (** event name, handler id *)
5555+5656+type node =
5757+ | Text of string
5858+ | Element of { tag : string; attrs : attr list; children : node list }
5959+6060+type event_msg = {
6161+ handler_id : event_id;
6262+ event_type : string;
6363+ value : string option;
6464+}
6565+```
6666+6767+**Step 2: Add module to dune**
6868+6969+In `js_top_worker/idl/dune`, change the `js_top_worker_message` library's modules line:
7070+7171+```
7272+(modules message)
7373+```
7474+to:
7575+```
7676+(modules message widget_view)
7777+```
7878+7979+**Step 3: Verify build**
8080+8181+Run: `opam exec -- dune build js_top_worker/idl/`
8282+Expected: Clean build, no errors.
8383+8484+**Step 4: Commit**
8585+8686+```bash
8787+git add js_top_worker/idl/widget_view.ml js_top_worker/idl/dune
8888+git commit -m "feat: add serializable view types to message library"
8989+```
9090+9191+---
9292+9393+### Task 2: Add View.node JSON encoding to message.ml
9494+9595+Add functions to serialize/deserialize View.node trees as JSON, using the existing `json_of_obj` / `json_string` / `get_string` helpers.
9696+9797+**Files:**
9898+- Modify: `js_top_worker/idl/message.ml`
9999+100100+**Step 1: Add JSON encoding functions after the existing helpers section (~line 141)**
101101+102102+Add after `let get_string_array obj key =` block, before `(** {1 Worker message serialization} *)`:
103103+104104+```ocaml
105105+(** {1 View node JSON encoding} *)
106106+107107+let rec json_of_view_attr (a : Widget_view.attr) =
108108+ match a with
109109+ | Property (k, v) ->
110110+ json_of_obj [("t", json_string "prop"); ("k", json_string k); ("v", json_string v)]
111111+ | Style (k, v) ->
112112+ json_of_obj [("t", json_string "style"); ("k", json_string k); ("v", json_string v)]
113113+ | Class c ->
114114+ json_of_obj [("t", json_string "cls"); ("v", json_string c)]
115115+ | Handler (ev, id) ->
116116+ json_of_obj [("t", json_string "handler"); ("ev", json_string ev); ("id", json_string id)]
117117+118118+and json_of_view_node (n : Widget_view.node) =
119119+ match n with
120120+ | Text s ->
121121+ json_of_obj [("t", json_string "txt"); ("v", json_string s)]
122122+ | Element { tag; attrs; children } ->
123123+ json_of_obj [
124124+ ("t", json_string "el");
125125+ ("tag", json_string tag);
126126+ ("a", json_array (List.map (fun a -> Js.Unsafe.inject (json_of_view_attr a)) attrs));
127127+ ("c", json_array (List.map (fun c -> Js.Unsafe.inject (json_of_view_node c)) children));
128128+ ]
129129+130130+let view_attr_of_json obj : Widget_view.attr =
131131+ let t = get_string obj "t" in
132132+ match t with
133133+ | "prop" -> Property (get_string obj "k", get_string obj "v")
134134+ | "style" -> Style (get_string obj "k", get_string obj "v")
135135+ | "cls" -> Class (get_string obj "v")
136136+ | "handler" -> Handler (get_string obj "ev", get_string obj "id")
137137+ | _ -> failwith ("Unknown attr type: " ^ t)
138138+139139+let rec view_node_of_json obj : Widget_view.node =
140140+ let t = get_string obj "t" in
141141+ match t with
142142+ | "txt" -> Text (get_string obj "v")
143143+ | "el" ->
144144+ let attrs = Array.to_list (Array.map view_attr_of_json (get_array obj "a")) in
145145+ let children = Array.to_list (Array.map view_node_of_json (get_array obj "c")) in
146146+ Element { tag = get_string obj "tag"; attrs; children }
147147+ | _ -> failwith ("Unknown node type: " ^ t)
148148+```
149149+150150+**Step 2: Verify build**
151151+152152+Run: `opam exec -- dune build js_top_worker/idl/`
153153+Expected: Clean build.
154154+155155+**Step 3: Commit**
156156+157157+```bash
158158+git add js_top_worker/idl/message.ml
159159+git commit -m "feat: add View.node JSON encoding to message protocol"
160160+```
161161+162162+---
163163+164164+### Task 3: Add widget message types to protocol
165165+166166+Add WidgetUpdate, WidgetClear (worker→client) and WidgetEvent (client→worker) message variants, with JSON serialization/deserialization.
167167+168168+**Files:**
169169+- Modify: `js_top_worker/idl/message.ml`
170170+171171+**Step 1: Add new variants to worker_msg type (~line 93)**
172172+173173+Add before the closing of the `worker_msg` type:
174174+175175+```ocaml
176176+ | WidgetUpdate of { widget_id : string; view : Widget_view.node }
177177+ | WidgetClear of { widget_id : string }
178178+```
179179+180180+**Step 2: Add new variant to client_msg type (~line 69)**
181181+182182+Add before the closing of the `client_msg` type:
183183+184184+```ocaml
185185+ | WidgetEvent of { widget_id : string; handler_id : string; event_type : string; value : string option }
186186+```
187187+188188+**Step 3: Add JSON serialization for new worker_msg variants**
189189+190190+In `json_of_worker_msg`, add cases before the final `in`:
191191+192192+```ocaml
193193+ | WidgetUpdate { widget_id; view } ->
194194+ json_of_obj [
195195+ ("type", json_string "widget_update");
196196+ ("widget_id", json_string widget_id);
197197+ ("view", Js.Unsafe.inject (json_of_view_node view));
198198+ ]
199199+ | WidgetClear { widget_id } ->
200200+ json_of_obj [
201201+ ("type", json_string "widget_clear");
202202+ ("widget_id", json_string widget_id);
203203+ ]
204204+```
205205+206206+**Step 4: Add JSON deserialization for WidgetEvent**
207207+208208+In `client_msg_of_string`, add a case before the wildcard:
209209+210210+```ocaml
211211+ | "widget_event" ->
212212+ WidgetEvent {
213213+ widget_id = get_string obj "widget_id";
214214+ handler_id = get_string obj "handler_id";
215215+ event_type = get_string obj "event_type";
216216+ value = get_string_opt obj "value";
217217+ }
218218+```
219219+220220+**Step 5: Verify build**
221221+222222+Run: `opam exec -- dune build js_top_worker/idl/`
223223+Expected: Warning about non-exhaustive match in worker.ml (new message variants not handled yet). The idl/ library itself should build clean.
224224+225225+**Step 6: Commit**
226226+227227+```bash
228228+git add js_top_worker/idl/message.ml
229229+git commit -m "feat: add WidgetUpdate/WidgetEvent/WidgetClear to message protocol"
230230+```
231231+232232+---
233233+234234+### Task 4: Create the Widget module
235235+236236+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.
237237+238238+**Files:**
239239+- Create: `js_top_worker/widget/widget.ml`
240240+- Create: `js_top_worker/widget/widget.mli`
241241+- Create: `js_top_worker/widget/dune`
242242+243243+**Step 1: Create dune build file**
244244+245245+`js_top_worker/widget/dune`:
246246+```
247247+(library
248248+ (name widget)
249249+ (public_name js_top_worker-widget)
250250+ (libraries js_top_worker-rpc.message))
251251+```
252252+253253+Note: No dependency on `note` — the Widget module is imperative. Note integration is done in user code via `Note.S.log`.
254254+255255+**Step 2: Create widget.mli**
256256+257257+```ocaml
258258+(** Interactive widget support for the OCaml toplevel.
259259+260260+ Widgets are rendered in the client as HTML elements built from
261261+ {!Widget_view.node} trees. Event handlers in the view are symbolic
262262+ string identifiers — when the user interacts with a widget, the
263263+ client sends the handler ID and input value back to the worker,
264264+ where the registered callback is invoked.
265265+266266+ Typical usage with Note FRP:
267267+ {[
268268+ let e, send = Note.E.create ()
269269+ let s = Note.S.hold 50 e
270270+271271+ let () =
272272+ Widget.display ~id:"my-slider"
273273+ ~handlers:["x", (fun v ->
274274+ send (int_of_string (Option.get v)))]
275275+ (Widget_view.Element { tag = "input";
276276+ attrs = [Property ("type", "range")];
277277+ children = [] })
278278+279279+ (* Wire up automatic updates via Note: *)
280280+ let _logr = Note.S.log
281281+ (Note.S.map (fun v -> ... build view ...) s)
282282+ (Widget.update ~id:"my-slider")
283283+ ]} *)
284284+285285+val display :
286286+ id:string ->
287287+ handlers:(string * (string option -> unit)) list ->
288288+ Widget_view.node ->
289289+ unit
290290+(** [display ~id ~handlers view] registers a widget with the given [id],
291291+ installs [handlers] for routing incoming events, and sends the
292292+ initial [view] to the client. If a widget with this [id] already
293293+ exists, it is replaced. *)
294294+295295+val update : id:string -> Widget_view.node -> unit
296296+(** [update ~id view] sends an updated view for an existing widget.
297297+ The handler map is not changed. *)
298298+299299+val clear : id:string -> unit
300300+(** [clear ~id] removes the widget and its handlers. Sends a
301301+ WidgetClear message to the client. *)
302302+303303+val handle_event :
304304+ widget_id:string -> handler_id:string -> value:string option -> unit
305305+(** [handle_event ~widget_id ~handler_id ~value] routes an incoming
306306+ event to the registered handler. Called by the worker message loop
307307+ when a WidgetEvent is received. *)
308308+309309+val set_sender : (string -> unit) -> unit
310310+(** [set_sender f] installs the function used to send JSON strings to
311311+ the client. Called once by the worker at startup. The function [f]
312312+ should call [Worker.post_message]. *)
313313+```
314314+315315+**Step 3: Create widget.ml**
316316+317317+```ocaml
318318+open Js_top_worker_message.Message
319319+320320+(* --- Send function, injected by worker at startup --- *)
321321+322322+let sender : (string -> unit) ref = ref (fun _ -> ())
323323+324324+let set_sender f = sender := f
325325+326326+let send_msg msg =
327327+ let json = json_of_worker_msg msg in
328328+ !sender json
329329+330330+(* --- Handler registry --- *)
331331+332332+type widget_state = {
333333+ handlers : (string * (string option -> unit)) list;
334334+}
335335+336336+let widgets : (string, widget_state) Hashtbl.t = Hashtbl.create 16
337337+338338+(* --- Public API --- *)
339339+340340+let display ~id ~handlers view =
341341+ Hashtbl.replace widgets id { handlers };
342342+ send_msg (WidgetUpdate { widget_id = id; view })
343343+344344+let update ~id view =
345345+ send_msg (WidgetUpdate { widget_id = id; view })
346346+347347+let clear ~id =
348348+ Hashtbl.remove widgets id;
349349+ send_msg (WidgetClear { widget_id = id })
350350+351351+let handle_event ~widget_id ~handler_id ~value =
352352+ match Hashtbl.find_opt widgets widget_id with
353353+ | None -> ()
354354+ | Some state ->
355355+ match List.assoc_opt handler_id state.handlers with
356356+ | None -> ()
357357+ | Some handler -> handler value
358358+```
359359+360360+**Step 4: Verify build**
361361+362362+Run: `opam exec -- dune build js_top_worker/widget/`
363363+Expected: Clean build.
364364+365365+**Step 5: Commit**
366366+367367+```bash
368368+git add js_top_worker/widget/
369369+git commit -m "feat: add Widget module for interactive toplevel widgets"
370370+```
371371+372372+---
373373+374374+### Task 5: Wire Widget into the worker
375375+376376+Connect the Widget module to the worker's message loop: initialize the sender, handle WidgetEvent messages, and add the widget library as a dependency.
377377+378378+**Files:**
379379+- Modify: `js_top_worker/lib/worker.ml`
380380+- Modify: `js_top_worker/lib/dune` (add widget dependency to `js_top_worker-web`)
381381+382382+**Step 1: Add js_top_worker-widget to js_top_worker-web dependencies**
383383+384384+In `js_top_worker/lib/dune`, in the `js_top_worker-web` library stanza, add `js_top_worker-widget` to the libraries list:
385385+386386+```
387387+ (libraries
388388+ js_top_worker
389389+ js_top_worker-rpc.message
390390+ js_top_worker-widget
391391+ js_of_ocaml-ppx
392392+ ...
393393+```
394394+395395+**Step 2: Initialize Widget sender in worker.ml**
396396+397397+In `worker.ml`, in the `run` function, add after the `Logs.set_level` line (~line 343):
398398+399399+```ocaml
400400+ (* Initialize Widget module sender *)
401401+ Widget.set_sender (fun json ->
402402+ Jslib.log "Widget sending: %s" json;
403403+ Js_of_ocaml.Worker.post_message (Js_of_ocaml.Js.string json));
404404+```
405405+406406+**Step 3: Handle WidgetEvent in worker.ml**
407407+408408+In `handle_message`, add a new case:
409409+410410+```ocaml
411411+ | Msg.WidgetEvent { widget_id; handler_id; value; _ } ->
412412+ Widget.handle_event ~widget_id ~handler_id ~value;
413413+ Lwt.return_unit
414414+```
415415+416416+**Step 4: Verify build**
417417+418418+Run: `opam exec -- dune build js_top_worker/`
419419+Expected: Clean build. The worker now handles widget messages.
420420+421421+**Step 5: Commit**
422422+423423+```bash
424424+git add js_top_worker/lib/dune js_top_worker/lib/worker.ml
425425+git commit -m "feat: wire Widget module into worker message loop"
426426+```
427427+428428+---
429429+430430+### Task 6: Update JS client
431431+432432+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.
433433+434434+**Files:**
435435+- Modify: `js_top_worker/client/ocaml-worker.js`
436436+- Modify: `js_top_worker/client/ocaml-worker.d.ts`
437437+438438+**Step 1: Add widget options to constructor**
439439+440440+In the constructor (~line 143), after `this.onOutputAt = options.onOutputAt || null;`, add:
441441+442442+```javascript
443443+ this.onWidgetUpdate = options.onWidgetUpdate || null;
444444+ this.onWidgetClear = options.onWidgetClear || null;
445445+```
446446+447447+**Step 2: Handle widget messages in _handleMessage**
448448+449449+In `_handleMessage`, add cases before the `default:` in the switch:
450450+451451+```javascript
452452+ case 'widget_update':
453453+ if (this.onWidgetUpdate) {
454454+ this.onWidgetUpdate(msg);
455455+ }
456456+ break;
457457+458458+ case 'widget_clear':
459459+ if (this.onWidgetClear) {
460460+ this.onWidgetClear(msg);
461461+ }
462462+ break;
463463+```
464464+465465+**Step 3: Add sendWidgetEvent method**
466466+467467+Add after the `destroyEnv` method:
468468+469469+```javascript
470470+ /**
471471+ * Send a widget event to the worker.
472472+ * @param {string} widgetId - Widget identifier
473473+ * @param {string} handlerId - Handler identifier within the widget
474474+ * @param {string} eventType - DOM event type (e.g., 'input', 'click')
475475+ * @param {string|null} [value=null] - Input value if applicable
476476+ */
477477+ sendWidgetEvent(widgetId, handlerId, eventType, value = null) {
478478+ this.worker.postMessage(JSON.stringify({
479479+ type: 'widget_event',
480480+ widget_id: widgetId,
481481+ handler_id: handlerId,
482482+ event_type: eventType,
483483+ value: value,
484484+ }));
485485+ }
486486+```
487487+488488+**Step 4: Update TypeScript definitions**
489489+490490+In `ocaml-worker.d.ts`, add to the options interface and class:
491491+492492+```typescript
493493+ onWidgetUpdate?: (msg: { widget_id: string; view: any }) => void;
494494+ onWidgetClear?: (msg: { widget_id: string }) => void;
495495+```
496496+497497+And add the method:
498498+499499+```typescript
500500+ sendWidgetEvent(widgetId: string, handlerId: string, eventType: string, value?: string | null): void;
501501+```
502502+503503+**Step 5: Verify build (no compilation needed for JS, but check syntax)**
504504+505505+Run: `node -c js_top_worker/client/ocaml-worker.js`
506506+Expected: No syntax errors.
507507+508508+**Step 6: Commit**
509509+510510+```bash
511511+git add js_top_worker/client/ocaml-worker.js js_top_worker/client/ocaml-worker.d.ts
512512+git commit -m "feat: add widget support to JS client library"
513513+```
514514+515515+---
516516+517517+### Task 7: Add widget to example worker and create test page
518518+519519+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.
520520+521521+**Files:**
522522+- Modify: `js_top_worker/example/dune` (add js_top_worker-widget to worker deps)
523523+- Create: `js_top_worker/example/widget_test.html`
524524+525525+**Step 1: Add widget dependency to example worker**
526526+527527+In `js_top_worker/example/dune`, in the worker executable stanza, add `js_top_worker-widget` and `note` to libraries:
528528+529529+```
530530+ (libraries js_top_worker-web logs.browser mime_printer tyxml js_top_worker-widget note))
531531+```
532532+533533+Also add `note` to the `jtw opam` rule so it's available as a toplevel package:
534534+535535+```
536536+ (run jtw opam -o _opam str stringext mime_printer note)))
537537+```
538538+539539+**Step 2: Create widget_test.html**
540540+541541+`js_top_worker/example/widget_test.html`:
542542+543543+```html
544544+<!DOCTYPE html>
545545+<html>
546546+<head>
547547+ <meta charset="utf-8">
548548+ <title>Widget Protocol Test</title>
549549+ <style>
550550+ body { font-family: monospace; max-width: 800px; margin: 2em auto; }
551551+ #log { background: #f5f5f5; padding: 1em; white-space: pre-wrap; max-height: 400px; overflow-y: auto; }
552552+ .widget-container { border: 1px solid #ccc; padding: 1em; margin: 1em 0; }
553553+ .widget-container h3 { margin-top: 0; color: #666; }
554554+ button { margin: 0.5em; padding: 0.5em 1em; }
555555+ </style>
556556+</head>
557557+<body>
558558+ <h1>Widget Protocol Test</h1>
559559+ <div id="widgets"></div>
560560+ <h2>Log</h2>
561561+ <div id="log"></div>
562562+563563+ <script type="module">
564564+ import { OcamlWorker } from '../client/ocaml-worker.js';
565565+566566+ const logEl = document.getElementById('log');
567567+ const widgetsEl = document.getElementById('widgets');
568568+ let worker;
569569+570570+ function log(msg) {
571571+ logEl.textContent += msg + '\n';
572572+ logEl.scrollTop = logEl.scrollHeight;
573573+ }
574574+575575+ // --- View.node JSON renderer ---
576576+ function renderNode(node, worker) {
577577+ if (node.t === 'txt') {
578578+ return document.createTextNode(node.v);
579579+ }
580580+ if (node.t === 'el') {
581581+ const el = document.createElement(node.tag);
582582+583583+ // Attributes
584584+ for (const attr of (node.a || [])) {
585585+ switch (attr.t) {
586586+ case 'prop':
587587+ el.setAttribute(attr.k, attr.v);
588588+ break;
589589+ case 'style':
590590+ el.style[attr.k] = attr.v;
591591+ break;
592592+ case 'cls':
593593+ el.classList.add(attr.v);
594594+ break;
595595+ case 'handler':
596596+ el.addEventListener(attr.ev, (event) => {
597597+ const isInput = ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName);
598598+ const value = isInput ? el.value : null;
599599+ const widgetId = el.closest('[data-widget-id]')?.dataset.widgetId;
600600+ if (widgetId) {
601601+ log(`Event: widget=${widgetId} handler=${attr.id} value=${value}`);
602602+ worker.sendWidgetEvent(widgetId, attr.id, attr.ev, value);
603603+ }
604604+ });
605605+ break;
606606+ }
607607+ }
608608+609609+ // Children
610610+ for (const child of (node.c || [])) {
611611+ el.appendChild(renderNode(child, worker));
612612+ }
613613+ return el;
614614+ }
615615+ return document.createTextNode('');
616616+ }
617617+618618+ function renderWidget(widgetId, viewJson) {
619619+ let container = document.getElementById('widget-' + widgetId);
620620+ if (!container) {
621621+ container = document.createElement('div');
622622+ container.id = 'widget-' + widgetId;
623623+ container.className = 'widget-container';
624624+ container.dataset.widgetId = widgetId;
625625+ container.innerHTML = `<h3>${widgetId}</h3>`;
626626+ widgetsEl.appendChild(container);
627627+ }
628628+ // Keep the heading, replace content
629629+ const heading = container.querySelector('h3');
630630+ container.innerHTML = '';
631631+ container.appendChild(heading);
632632+ container.dataset.widgetId = widgetId;
633633+ container.appendChild(renderNode(viewJson, worker));
634634+ }
635635+636636+ async function run() {
637637+ log('Creating worker...');
638638+ worker = new OcamlWorker('_opam/worker.js', {
639639+ onWidgetUpdate: (msg) => {
640640+ log(`WidgetUpdate: id=${msg.widget_id}`);
641641+ renderWidget(msg.widget_id, msg.view);
642642+ },
643643+ onWidgetClear: (msg) => {
644644+ log(`WidgetClear: id=${msg.widget_id}`);
645645+ const container = document.getElementById('widget-' + msg.widget_id);
646646+ if (container) container.remove();
647647+ },
648648+ onOutputAt: (msg) => {
649649+ if (msg.caml_ppf) log(`OutputAt: ${msg.caml_ppf}`);
650650+ },
651651+ });
652652+653653+ log('Initializing...');
654654+ await worker.init({
655655+ findlib_requires: [],
656656+ findlib_index: null,
657657+ });
658658+ log('Worker ready.');
659659+660660+ // Test 1: Static widget (no FRP)
661661+ log('\n--- Test 1: Static widget ---');
662662+ const r1 = await worker.eval(`
663663+ Widget.display ~id:"hello"
664664+ ~handlers:[]
665665+ (Widget_view.Element {
666666+ tag = "div"; attrs = [];
667667+ children = [Widget_view.Text "Hello from OCaml!"]
668668+ });;
669669+ `);
670670+ log(`Eval result: ${r1.caml_ppf}`);
671671+672672+ // Test 2: Interactive counter with Note
673673+ log('\n--- Test 2: Interactive counter ---');
674674+ const r2 = await worker.eval(`
675675+ let inc_e, send_inc = Note.E.create ();;
676676+ let dec_e, send_dec = Note.E.create ();;
677677+ let count =
678678+ let delta = Note.E.select [
679679+ Note.E.map (fun () n -> n + 1) inc_e;
680680+ Note.E.map (fun () n -> n - 1) dec_e;
681681+ ] in
682682+ Note.S.accum 0 delta;;
683683+684684+ let counter_view n =
685685+ let open Widget_view in
686686+ Element { tag = "div"; attrs = [Class "counter"]; children = [
687687+ Element { tag = "button";
688688+ attrs = [Handler ("click", "dec")];
689689+ children = [Text "-"] };
690690+ Element { tag = "span";
691691+ attrs = [Style ("margin", "0 1em")];
692692+ children = [Text (string_of_int n)] };
693693+ Element { tag = "button";
694694+ attrs = [Handler ("click", "inc")];
695695+ children = [Text "+"] };
696696+ ] };;
697697+698698+ Widget.display ~id:"counter"
699699+ ~handlers:[
700700+ "inc", (fun _ -> send_inc ());
701701+ "dec", (fun _ -> send_dec ());
702702+ ]
703703+ (counter_view 0);;
704704+705705+ let _logr = Note.S.log
706706+ (Note.S.map counter_view count)
707707+ (Widget.update ~id:"counter");;
708708+ Note.Logr.hold _logr;;
709709+ `);
710710+ log(`Eval result: ${r2.caml_ppf}`);
711711+712712+ // Test 3: Interactive slider (cross-cell signals)
713713+ log('\n--- Test 3: Slider (cell 1) ---');
714714+ await worker.eval(`
715715+ let x_e, send_x = Note.E.create ();;
716716+ let x = Note.S.hold 50 x_e;;
717717+718718+ let slider_view v =
719719+ let open Widget_view in
720720+ Element { tag = "div"; attrs = []; children = [
721721+ Element { tag = "label"; attrs = [];
722722+ children = [Text (Printf.sprintf "X: %d" v)] };
723723+ Element { tag = "input"; attrs = [
724724+ Property ("type", "range");
725725+ Property ("min", "0");
726726+ Property ("max", "100");
727727+ Property ("value", string_of_int v);
728728+ Handler ("input", "x");
729729+ ]; children = [] };
730730+ ] };;
731731+732732+ Widget.display ~id:"slider"
733733+ ~handlers:["x", (fun v ->
734734+ send_x (int_of_string (Option.get v)))]
735735+ (slider_view 50);;
736736+737737+ let _logr2 = Note.S.log
738738+ (Note.S.map slider_view x)
739739+ (Widget.update ~id:"slider");;
740740+ Note.Logr.hold _logr2;;
741741+ `);
742742+743743+ log('\n--- Test 3b: Derived widget (cell 2, uses x from cell 1) ---');
744744+ await worker.eval(`
745745+ let doubled_view v =
746746+ let open Widget_view in
747747+ Element { tag = "div"; attrs = []; children = [
748748+ Text (Printf.sprintf "2x = %d" (v * 2))
749749+ ] };;
750750+751751+ Widget.display ~id:"doubled"
752752+ ~handlers:[]
753753+ (doubled_view (Note.S.value x));;
754754+755755+ let _logr3 = Note.S.log
756756+ (Note.S.map doubled_view x)
757757+ (Widget.update ~id:"doubled");;
758758+ Note.Logr.hold _logr3;;
759759+ `);
760760+761761+ log('\nAll tests dispatched. Interact with widgets above.');
762762+ }
763763+764764+ run().catch(e => log('Error: ' + e.message));
765765+ </script>
766766+</body>
767767+</html>
768768+```
769769+770770+**Step 3: Add widget_test.html to the default alias**
771771+772772+In `js_top_worker/example/dune`, add `widget_test.html` to the alias deps list.
773773+774774+**Step 4: Build and verify**
775775+776776+Run: `opam exec -- dune build js_top_worker/example/`
777777+Expected: Clean build. The worker now includes Widget and Note modules.
778778+779779+**Step 5: Commit**
780780+781781+```bash
782782+git add js_top_worker/example/dune js_top_worker/example/widget_test.html
783783+git commit -m "feat: add widget test page with counter and slider examples"
784784+```
785785+786786+---
787787+788788+### Task 8: Browser test
789789+790790+Serve the example directory and test the full round-trip in a browser.
791791+792792+**Step 1: Build and serve**
793793+794794+```bash
795795+opam exec -- dune build js_top_worker/example/
796796+cd _build/default/js_top_worker/example
797797+python3 -m http.server 8080
798798+```
799799+800800+**Step 2: Open widget_test.html**
801801+802802+Navigate to `http://localhost:8080/widget_test.html`.
803803+804804+Expected behavior:
805805+1. Log shows "Worker ready."
806806+2. "hello" widget appears with "Hello from OCaml!" text
807807+3. "counter" widget appears with - 0 + buttons
808808+4. Clicking + increments the counter, clicking - decrements
809809+5. "slider" widget appears with a range input at 50
810810+6. "doubled" widget appears with "2x = 100"
811811+7. Dragging the slider updates both the slider label AND the doubled display
812812+813813+**Step 3: Fix any issues found during testing**
814814+815815+If events don't route or views don't render, debug by checking the browser console log (the worker logs all messages).
816816+817817+**Step 4: Commit any fixes**
818818+819819+```bash
820820+git add -A
821821+git commit -m "fix: browser test fixes for widget protocol"
822822+```
823823+824824+---
825825+826826+## Summary
827827+828828+| Task | What | Files |
829829+|------|------|-------|
830830+| 1 | View types in message lib | `idl/widget_view.ml`, `idl/dune` |
831831+| 2 | View.node JSON encoding | `idl/message.ml` |
832832+| 3 | Widget message types | `idl/message.ml` |
833833+| 4 | Widget module | `widget/widget.ml`, `widget.mli`, `dune` |
834834+| 5 | Worker integration | `lib/worker.ml`, `lib/dune` |
835835+| 6 | JS client updates | `client/ocaml-worker.js`, `.d.ts` |
836836+| 7 | Test page + worker deps | `example/dune`, `example/widget_test.html` |
837837+| 8 | Browser test | Manual verification |
838838+839839+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.
+332
experiments/widget-bridge/RESULTS.md
···11+# Widget/FRP Bridge — Experiment Results
22+33+*2026-02-24*
44+55+Both experiments implement an identical counter widget (decrement,
66+display, increment) with the same architecture: FRP-driven view
77+derivation in a Web Worker, serialized via Marshal over postMessage,
88+rendered to DOM by a shared Brr renderer on the main thread.
99+1010+## Measurements
1111+1212+| Metric | Lwd (Exp A) | Note (Exp B) |
1313+|--------------------------------|-------------|--------------|
1414+| Worker lines of OCaml | 42 | 49 |
1515+| Main lines of OCaml | 24 | 24 |
1616+| Worker JS bundle (uncompressed)| 2.2 MB | 2.7 MB |
1717+| Main JS bundle (uncompressed) | 2.7 MB | 2.7 MB |
1818+| Initial render latency | ~0.5 ms | ~0.6 ms |
1919+| Update render latency (median) | ~0.2 ms | ~0.3 ms |
2020+2121+Shared code: `view.ml` (32 lines), `renderer.ml` (29 lines).
2222+2323+Bundle sizes are dominated by js_of_ocaml runtime + stdlib (~2 MB
2424+baseline). The FRP library contribution is small: Lwd adds ~200 KB,
2525+Note adds ~700 KB over the base worker.
2626+2727+Render latencies are effectively identical — both sub-millisecond for
2828+this trivial view tree. The bottleneck would be postMessage
2929+serialization and DOM replacement, not the FRP library.
3030+3131+## Ergonomics
3232+3333+### Lwd
3434+3535+**State model:** Mutable variables (`Lwd.var`) with explicit
3636+get/set/peek. Feels like working with `ref` cells, but with automatic
3737+dependency tracking.
3838+3939+```ocaml
4040+let count = Lwd.var 0
4141+let view =
4242+ Lwd.map (Lwd.get count) ~f:(fun n -> ... view tree ...)
4343+let root = Lwd.observe view
4444+4545+(* Update: explicit set + explicit re-sample *)
4646+Lwd.set count (Lwd.peek count + 1);
4747+let v = Lwd.quick_sample root in
4848+post_message v
4949+```
5050+5151+**Pros:**
5252+- Familiar imperative style — `set`/`peek` on vars
5353+- Pull-based: view is only computed when sampled (explicit control)
5454+- Smaller bundle size
5555+- Simple mental model for workers: update state, sample, send
5656+5757+**Cons:**
5858+- Must manually trigger re-sampling after state changes
5959+- `Lwd.map` uses labeled `~f` argument (minor style preference)
6060+- The `observe`/`quick_sample` ceremony is slightly verbose
6161+- No built-in mechanism to react to changes — must wire it yourself
6262+6363+### Note
6464+6565+**State model:** Events (`E`) and signals (`S`) with automatic
6666+propagation. Classic FRP: define the dataflow graph, then push events
6767+into it.
6868+6969+```ocaml
7070+let inc_e, send_inc = Note.E.create ()
7171+let dec_e, send_dec = Note.E.create ()
7272+let count = Note.S.accum 0 (Note.E.select [
7373+ Note.E.map (fun () n -> n + 1) inc_e;
7474+ Note.E.map (fun () n -> n - 1) dec_e;
7575+])
7676+let view = Note.S.map (fun n -> ... view tree ...) count
7777+7878+(* Observe: automatic push on signal change *)
7979+let logr = Note.S.log view send_to_main in
8080+Note.Logr.hold logr
8181+8282+(* Update: just fire the event, propagation is automatic *)
8383+send_inc ()
8484+```
8585+8686+**Pros:**
8787+- Push-based: signal changes propagate automatically to the logger
8888+- No manual re-sampling — the `S.log` callback fires on every change
8989+- Cleaner separation between event sources and derived state
9090+- Better fit for the worker model: events come in, views go out
9191+ automatically
9292+- `E.select` / `E.map` compose naturally for multiple event sources
9393+9494+**Cons:**
9595+- More boilerplate for event source creation (one `E.create` per
9696+ event type)
9797+- Must remember `Logr.hold` or loggers get GC'd (silent failure)
9898+- `S.accum` takes `('a -> 'a) event` — must map events to functions
9999+ first, which is slightly indirect
100100+- 7 more lines of worker code than Lwd
101101+102102+## View Description (sample output)
103103+104104+Both produce identical view trees. For count=3:
105105+106106+```
107107+<div .counter>
108108+ <button on:click->dec>
109109+ "-"
110110+ </button>
111111+ <span style:margin="0 1em">
112112+ "3"
113113+ </span>
114114+ <button on:click->inc>
115115+ "+"
116116+ </button>
117117+</div>
118118+```
119119+120120+Serialized via `Marshal.to_bytes` — binary OCaml marshalling, not
121121+JSON. For production use, a JSON or structured-clone-safe encoding
122122+would be needed.
123123+124124+## Recommendation
125125+126126+**Note is the better fit for the worker bridge architecture.**
127127+128128+The key differentiator is push-based propagation. In the worker
129129+model, the natural flow is:
130130+131131+1. Event arrives from main thread
132132+2. State updates
133133+3. New view is computed
134134+4. View is sent to main thread
135135+136136+With Note, steps 2–4 happen automatically via the signal graph and
137137+`S.log`. With Lwd, step 4 requires explicit re-sampling after every
138138+state change — easy to forget and a source of bugs as complexity
139139+grows.
140140+141141+For a counter this doesn't matter much. But for Experiment C (multiple
142142+interacting sliders), Note's automatic propagation would compose
143143+better: add a new event source, wire it into the signal graph, and
144144+the view updates automatically. With Lwd, every new event handler
145145+would need to call `send_view()` explicitly.
146146+147147+The bundle size difference (2.2 vs 2.7 MB) is negligible given that
148148+both are dominated by the js_of_ocaml baseline. The 7-line code
149149+difference is also insignificant.
150150+151151+**Next steps if proceeding with Note:**
152152+- Experiment C: Multi-slider widget to validate composition ✓
153153+- Experiment D: TyXML functor integration for type-safe HTML ✓
154154+- Replace `Marshal` with structured-clone-safe encoding
155155+- Investigate `note.brr` for tighter Brr integration on the main
156156+ thread side (reactive DOM updates without full re-render)
157157+158158+---
159159+160160+## Experiment C — Multi-Slider Widget
161161+162162+A two-slider widget (X and Y, both range inputs 0–100) that displays
163163+the product X × Y in real time. Tests Note's signal composition with
164164+`S.hold` and `S.l2`, and validates that the renderer correctly
165165+extracts `value` from `<input>` elements.
166166+167167+### Measurements (Exp C)
168168+169169+| Metric | Note Sliders (Exp C) |
170170+|--------------------------------|---------------------|
171171+| Worker lines of OCaml | 53 |
172172+| Main lines of OCaml | 24 |
173173+| Worker JS bundle (uncompressed)| 2.7 MB |
174174+| Main JS bundle (uncompressed) | 2.7 MB |
175175+| Render latency (median) | ~0.3 ms |
176176+177177+Shared code: `view.ml` (32 lines), `renderer.ml` (38 lines — 9 lines
178178+added for input value extraction).
179179+180180+### Ergonomics
181181+182182+**Signal composition works cleanly.** Each slider's value is a signal
183183+created with `S.hold`:
184184+185185+```ocaml
186186+let x_input, send_x = Note.E.create ()
187187+let x = Note.S.hold 50 x_input
188188+```
189189+190190+The combined view uses `S.l2` to lift a function over two signals:
191191+192192+```ocaml
193193+let view = Note.S.l2 (fun x y -> ... view tree ...) x y
194194+```
195195+196196+Adding a third slider would mean `S.l3` — or, for more signals,
197197+`S.map` over `S.Pair`. The composition scales linearly.
198198+199199+**Input value forwarding** required extending the renderer to extract
200200+`.value` from `<input>`, `<select>`, and `<textarea>` elements. The
201201+`event_msg.value` field (already `string option`) carries the value
202202+from the DOM to the worker, where it's parsed:
203203+204204+```ocaml
205205+match ev.handler_id, ev.value with
206206+| "x", Some v -> send_x (int_of_string v)
207207+| "y", Some v -> send_y (int_of_string v)
208208+```
209209+210210+This is the minimum viable event payload — sufficient for sliders and
211211+text inputs. Richer widgets (checkboxes, multi-selects) would need a
212212+more structured event payload.
213213+214214+**Key takeaway:** Note's push-based propagation composes well. Adding
215215+new event sources and signals requires no changes to the view-sending
216216+machinery — `S.log` handles it automatically, exactly as predicted
217217+after Experiments A/B.
218218+219219+---
220220+221221+## Experiment D — TyXML Functor Integration
222222+223223+Uses TyXML's functor API (`Html_f.Make`) over a custom XML backend
224224+that produces `View.node` values, giving type-safe HTML construction
225225+while maintaining the same serializable output format.
226226+227227+### Measurements (Exp D)
228228+229229+| Metric | Note Sliders (Exp C) | TyXML Sliders (Exp D) |
230230+|--------------------------------|---------------------|----------------------|
231231+| Worker lines of OCaml | 53 | 48 |
232232+| Main lines of OCaml | 24 | 24 |
233233+| Worker JS bundle (uncompressed)| 2.7 MB | 3.9 MB |
234234+| Main JS bundle (uncompressed) | 2.7 MB | 2.7 MB |
235235+| Render latency (median) | ~0.3 ms | ~0.3 ms |
236236+237237+TyXML backend code: `view_xml.ml` (54 lines) + `view_html.ml` (5
238238+lines) = 59 lines of one-time setup.
239239+240240+### Bundle Size Impact
241241+242242+TyXML adds **+1.2 MB** to the worker bundle (2.7 → 3.9 MB). This is
243243+the TyXML functor code and its generated HTML module. The main thread
244244+bundle is unchanged since TyXML is only used in the worker.
245245+246246+For a worker that's already 2.7 MB (js_of_ocaml baseline + Note),
247247+this is a ~44% increase. Whether that's acceptable depends on the
248248+deployment context — for an educational tool where workers are loaded
249249+once and cached, it's likely fine.
250250+251251+### Ergonomics
252252+253253+**The TyXML DSL is more concise.** Compare a slider row:
254254+255255+Manual (Exp C, 8 lines):
256256+```ocaml
257257+Element { tag = "div"; attrs = [Class "slider-row"]; children = [
258258+ Element { tag = "label"; attrs = []; children = [
259259+ Text (Printf.sprintf "%s: %d" label value)
260260+ ] };
261261+ Element { tag = "input"; attrs = [
262262+ Property ("type", "range"); Property ("min", "0");
263263+ Property ("max", "100"); Property ("value", ...);
264264+ Handler ("input", handler_id);
265265+ ]; children = [] };
266266+] }
267267+```
268268+269269+TyXML (Exp D, 5 lines):
270270+```ocaml
271271+div ~a:[a_class ["slider-row"]] [
272272+ label [txt (Printf.sprintf "%s: %d" label_text value)];
273273+ input ~a:[
274274+ a_input_type `Range; a_value (string_of_int value);
275275+ a_oninput handler_id;
276276+ ] ()
277277+] |> toelt
278278+```
279279+280280+**Type safety catches mistakes.** TyXML enforces valid HTML at compile
281281+time — you can't put a `<div>` inside a `<span>`, use an invalid
282282+attribute on an element, or forget required attributes. With manual
283283+`View.node` construction, any string goes.
284284+285285+**The backend is straightforward.** `view_xml.ml` implements TyXML's
286286+`Xml_sigs.T` module type by mapping each operation to `View.node` /
287287+`View.attr` constructors. Event handler attributes strip the "on"
288288+prefix (e.g., `oninput` → `input`) to produce the event name expected
289289+by the renderer. The full backend is 59 lines of mechanical code that
290290+only needs to be written once.
291291+292292+**One friction point:** TyXML's `toelt` is needed to convert typed
293293+HTML elements back to the underlying `View.node` type when mixing
294294+TyXML-constructed subtrees with manually-constructed parent nodes.
295295+This is a minor inconvenience, not a blocker.
296296+297297+### TyXML Recommendation
298298+299299+**Use TyXML for widget authors, keep raw View.node for the runtime.**
300300+301301+The type safety and conciseness gains are worth the +1.2 MB bundle
302302+cost for an educational tool. Widget authors writing in the worker
303303+benefit most from TyXML's compile-time HTML validation. The renderer
304304+and main thread code remain unchanged — they only see `View.node`.
305305+306306+---
307307+308308+## Overall Conclusions
309309+310310+| | Exp A (Lwd) | Exp B (Note) | Exp C (Sliders) | Exp D (TyXML) |
311311+|---|---|---|---|---|
312312+| FRP lib | Lwd | Note | Note | Note |
313313+| View DSL | Manual | Manual | Manual | TyXML |
314314+| Worker LOC | 42 | 49 | 53 | 48 |
315315+| Worker bundle | 2.2 MB | 2.7 MB | 2.7 MB | 3.9 MB |
316316+317317+**Architecture validated.** The worker bridge pattern — FRP-driven
318318+view derivation in a worker, serialized view trees, Brr rendering on
319319+the main thread — works well across all four experiments. Sub-
320320+millisecond render latencies, clean separation of concerns, and
321321+straightforward composition.
322322+323323+**Recommended stack:** Note + TyXML for widget authoring, raw
324324+View.node + Brr for the runtime.
325325+326326+**Remaining work:**
327327+- Replace `Marshal` with structured-clone-safe encoding (JSON or
328328+ custom binary) for production safety
329329+- Investigate `note.brr` for incremental DOM updates (currently the
330330+ entire DOM subtree is replaced on every view change)
331331+- Integrate with the toplevel worker to enable user-authored widgets
332332+- Add CSS styling support beyond inline styles
···11+module Worker = Brr_webworkers.Worker
22+open Brr
33+44+let () =
55+ let container =
66+ match Document.find_el_by_id G.document (Jstr.v "app") with
77+ | Some el -> el
88+ | None -> failwith "No #app element found"
99+ in
1010+ let worker = Worker.create (Jstr.v "../lwd_counter/worker.bc.js") in
1111+ let on_event (ev : View.event_msg) =
1212+ Worker.post worker (Marshal.to_bytes ev [])
1313+ in
1414+ ignore @@ Ev.listen Brr_io.Message.Ev.message
1515+ (fun msg ->
1616+ let msg = Ev.as_type msg in
1717+ let data : bytes = Brr_io.Message.Ev.data msg in
1818+ let view : View.node = Marshal.from_bytes data 0 in
1919+ let t0 = Performance.now_ms G.performance in
2020+ let el = Renderer.render ~on_event view in
2121+ El.set_children container [el];
2222+ let t1 = Performance.now_ms G.performance in
2323+ Console.(log [str "Render:"; (t1 -. t0); str "ms"]))
2424+ (Worker.as_target worker)
+42
experiments/widget-bridge/lwd_counter/worker.ml
···11+open View
22+33+(* --- State --- *)
44+55+let count = Lwd.var 0
66+77+(* --- View derivation --- *)
88+99+let view =
1010+ Lwd.map (Lwd.get count) ~f:(fun n ->
1111+ Element { tag = "div"; attrs = [Class "counter"]; children = [
1212+ Element { tag = "button";
1313+ attrs = [Handler ("click", "dec")];
1414+ children = [Text "-"] };
1515+ Element { tag = "span";
1616+ attrs = [Style ("margin", "0 1em")];
1717+ children = [Text (string_of_int n)] };
1818+ Element { tag = "button";
1919+ attrs = [Handler ("click", "inc")];
2020+ children = [Text "+"] };
2121+ ] })
2222+2323+let root = Lwd.observe view
2424+2525+(* --- Worker loop --- *)
2626+2727+let send_view () =
2828+ let v = Lwd.quick_sample root in
2929+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes (v : node) [])
3030+3131+let handle_event (ev : event_msg) =
3232+ (match ev.handler_id with
3333+ | "inc" -> Lwd.set count (Lwd.peek count + 1)
3434+ | "dec" -> Lwd.set count (Lwd.peek count - 1)
3535+ | _ -> ());
3636+ send_view ()
3737+3838+let () =
3939+ send_view ();
4040+ Js_of_ocaml.Worker.set_onmessage (fun data ->
4141+ let ev : event_msg = Marshal.from_bytes data 0 in
4242+ handle_event ev)
···11+module Worker = Brr_webworkers.Worker
22+open Brr
33+44+let () =
55+ let container =
66+ match Document.find_el_by_id G.document (Jstr.v "app") with
77+ | Some el -> el
88+ | None -> failwith "No #app element found"
99+ in
1010+ let worker = Worker.create (Jstr.v "../note_counter/worker.bc.js") in
1111+ let on_event (ev : View.event_msg) =
1212+ Worker.post worker (Marshal.to_bytes ev [])
1313+ in
1414+ ignore @@ Ev.listen Brr_io.Message.Ev.message
1515+ (fun msg ->
1616+ let msg = Ev.as_type msg in
1717+ let data : bytes = Brr_io.Message.Ev.data msg in
1818+ let view : View.node = Marshal.from_bytes data 0 in
1919+ let t0 = Performance.now_ms G.performance in
2020+ let el = Renderer.render ~on_event view in
2121+ El.set_children container [el];
2222+ let t1 = Performance.now_ms G.performance in
2323+ Console.(log [str "Render:"; (t1 -. t0); str "ms"]))
2424+ (Worker.as_target worker)
+49
experiments/widget-bridge/note_counter/worker.ml
···11+open View
22+33+(* --- Event sources --- *)
44+55+let inc_e, send_inc = Note.E.create ()
66+let dec_e, send_dec = Note.E.create ()
77+88+(* --- Reactive state --- *)
99+1010+let count =
1111+ let delta = Note.E.select [
1212+ Note.E.map (fun () n -> n + 1) inc_e;
1313+ Note.E.map (fun () n -> n - 1) dec_e;
1414+ ] in
1515+ Note.S.accum 0 delta
1616+1717+(* --- View derivation --- *)
1818+1919+let view =
2020+ Note.S.map (fun n ->
2121+ Element { tag = "div"; attrs = [Class "counter"]; children = [
2222+ Element { tag = "button";
2323+ attrs = [Handler ("click", "dec")];
2424+ children = [Text "-"] };
2525+ Element { tag = "span";
2626+ attrs = [Style ("margin", "0 1em")];
2727+ children = [Text (string_of_int n)] };
2828+ Element { tag = "button";
2929+ attrs = [Handler ("click", "inc")];
3030+ children = [Text "+"] };
3131+ ] })
3232+ count
3333+3434+(* --- Worker loop --- *)
3535+3636+let send_view (v : node) =
3737+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes v [])
3838+3939+let () =
4040+ (* Observe view signal and send updates *)
4141+ let logr = Note.S.log view send_view in
4242+ Note.Logr.hold logr;
4343+ (* Handle incoming events *)
4444+ Js_of_ocaml.Worker.set_onmessage (fun data ->
4545+ let ev : event_msg = Marshal.from_bytes data 0 in
4646+ match ev.handler_id with
4747+ | "inc" -> send_inc ()
4848+ | "dec" -> send_dec ()
4949+ | _ -> ())
···11+open Brr
22+33+let rec render ~on_event (node : View.node) : El.t =
44+ match node with
55+ | Text s ->
66+ El.txt (Jstr.v s)
77+ | Element { tag; attrs; children } ->
88+ let brr_attrs = List.filter_map (fun (a : View.attr) ->
99+ match a with
1010+ | Property (k, v) -> Some (At.v (Jstr.v k) (Jstr.v v))
1111+ | Style (k, v) -> Some (At.style (Jstr.v (k ^ ":" ^ v)))
1212+ | Class c -> Some (At.class' (Jstr.v c))
1313+ | Handler _ -> None
1414+ ) attrs in
1515+ let el =
1616+ El.v (Jstr.v tag) ~at:brr_attrs
1717+ (List.map (render ~on_event) children)
1818+ in
1919+ List.iter (fun (a : View.attr) ->
2020+ match a with
2121+ | Handler (event_name, handler_id) ->
2222+ let is_input = match tag with
2323+ | "input" | "select" | "textarea" -> true
2424+ | _ -> false
2525+ in
2626+ ignore @@ Ev.listen
2727+ (Ev.Type.void (Jstr.v event_name))
2828+ (fun _ev ->
2929+ let value =
3030+ if is_input then
3131+ Some (Jstr.to_string (Jv.Jstr.get (El.to_jv el) "value"))
3232+ else None
3333+ in
3434+ on_event { View.handler_id; event_type = event_name; value })
3535+ (El.as_target el)
3636+ | _ -> ()
3737+ ) attrs;
3838+ el
+6
experiments/widget-bridge/renderer/renderer.mli
···11+(** Render a {!View.node} tree to DOM and wire event handlers.
22+33+ Each call to {!render} produces a fresh DOM subtree. The caller is
44+ responsible for replacing the container's children. *)
55+66+val render : on_event:(View.event_msg -> unit) -> View.node -> Brr.El.t
···11+module Worker = Brr_webworkers.Worker
22+open Brr
33+44+let () =
55+ let container =
66+ match Document.find_el_by_id G.document (Jstr.v "app") with
77+ | Some el -> el
88+ | None -> failwith "No #app element found"
99+ in
1010+ let worker = Worker.create (Jstr.v "../sliders/worker.bc.js") in
1111+ let on_event (ev : View.event_msg) =
1212+ Worker.post worker (Marshal.to_bytes ev [])
1313+ in
1414+ ignore @@ Ev.listen Brr_io.Message.Ev.message
1515+ (fun msg ->
1616+ let msg = Ev.as_type msg in
1717+ let data : bytes = Brr_io.Message.Ev.data msg in
1818+ let view : View.node = Marshal.from_bytes data 0 in
1919+ let t0 = Performance.now_ms G.performance in
2020+ let el = Renderer.render ~on_event view in
2121+ El.set_children container [el];
2222+ let t1 = Performance.now_ms G.performance in
2323+ Console.(log [str "Render:"; (t1 -. t0); str "ms"]))
2424+ (Worker.as_target worker)
+53
experiments/widget-bridge/sliders/worker.ml
···11+open View
22+33+(* --- Event sources --- *)
44+55+let x_input, send_x = Note.E.create ()
66+let y_input, send_y = Note.E.create ()
77+88+(* --- Reactive state --- *)
99+1010+let x = Note.S.hold 50 x_input
1111+let y = Note.S.hold 50 y_input
1212+1313+(* --- View derivation --- *)
1414+1515+let slider ~label ~handler_id value =
1616+ Element { tag = "div"; attrs = [Class "slider-row"]; children = [
1717+ Element { tag = "label"; attrs = []; children = [
1818+ Text (Printf.sprintf "%s: %d" label value)
1919+ ] };
2020+ Element { tag = "input"; attrs = [
2121+ Property ("type", "range");
2222+ Property ("min", "0");
2323+ Property ("max", "100");
2424+ Property ("value", string_of_int value);
2525+ Handler ("input", handler_id);
2626+ ]; children = [] };
2727+ ] }
2828+2929+let view =
3030+ Note.S.l2 (fun x y ->
3131+ Element { tag = "div"; attrs = [Class "sliders"]; children = [
3232+ slider ~label:"X" ~handler_id:"x" x;
3333+ slider ~label:"Y" ~handler_id:"y" y;
3434+ Element { tag = "div"; attrs = [Class "result"]; children = [
3535+ Text (Printf.sprintf "X × Y = %d" (x * y))
3636+ ] };
3737+ ] })
3838+ x y
3939+4040+(* --- Worker loop --- *)
4141+4242+let send_view (v : node) =
4343+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes v [])
4444+4545+let () =
4646+ let logr = Note.S.log view send_view in
4747+ Note.Logr.hold logr;
4848+ Js_of_ocaml.Worker.set_onmessage (fun data ->
4949+ let ev : event_msg = Marshal.from_bytes data 0 in
5050+ match ev.handler_id, ev.value with
5151+ | "x", Some v -> (try send_x (int_of_string v) with _ -> ())
5252+ | "y", Some v -> (try send_y (int_of_string v) with _ -> ())
5353+ | _ -> ())
···11+module Xml = View_xml
22+module Svg = Svg_f.Make(View_xml)
33+module Html = Html_f.Make(View_xml)(Svg)
44+55+include (Html : module type of Html with module Xml := Html.Xml)
···11+module Worker = Brr_webworkers.Worker
22+open Brr
33+44+let () =
55+ let container =
66+ match Document.find_el_by_id G.document (Jstr.v "app") with
77+ | Some el -> el
88+ | None -> failwith "No #app element found"
99+ in
1010+ let worker = Worker.create (Jstr.v "../tyxml_sliders/worker.bc.js") in
1111+ let on_event (ev : View.event_msg) =
1212+ Worker.post worker (Marshal.to_bytes ev [])
1313+ in
1414+ ignore @@ Ev.listen Brr_io.Message.Ev.message
1515+ (fun msg ->
1616+ let msg = Ev.as_type msg in
1717+ let data : bytes = Brr_io.Message.Ev.data msg in
1818+ let view : View.node = Marshal.from_bytes data 0 in
1919+ let t0 = Performance.now_ms G.performance in
2020+ let el = Renderer.render ~on_event view in
2121+ El.set_children container [el];
2222+ let t1 = Performance.now_ms G.performance in
2323+ Console.(log [str "Render:"; (t1 -. t0); str "ms"]))
2424+ (Worker.as_target worker)
+48
experiments/widget-bridge/tyxml_sliders/worker.ml
···11+open View
22+open View_html
33+44+(* --- Event sources (same as Experiment C) --- *)
55+66+let x_input, send_x = Note.E.create ()
77+let y_input, send_y = Note.E.create ()
88+99+let x = Note.S.hold 50 x_input
1010+let y = Note.S.hold 50 y_input
1111+1212+(* --- View derivation with TyXML DSL --- *)
1313+1414+let slider_row ~label_text ~handler_id value =
1515+ div ~a:[a_class ["slider-row"]] [
1616+ label [txt (Printf.sprintf "%s: %d" label_text value)];
1717+ input ~a:[
1818+ a_input_type `Range;
1919+ a_value (string_of_int value);
2020+ a_oninput handler_id;
2121+ ] ()
2222+ ] |> toelt
2323+2424+let view =
2525+ Note.S.l2 (fun x y ->
2626+ Element { tag = "div"; attrs = [Class "sliders"]; children = [
2727+ slider_row ~label_text:"X" ~handler_id:"x" x;
2828+ slider_row ~label_text:"Y" ~handler_id:"y" y;
2929+ toelt (div ~a:[a_class ["result"]] [
3030+ txt (Printf.sprintf "X * Y = %d" (x * y))
3131+ ]);
3232+ ] })
3333+ x y
3434+3535+(* --- Worker loop (identical to Experiment C) --- *)
3636+3737+let send_view (v : node) =
3838+ Js_of_ocaml.Worker.post_message (Marshal.to_bytes v [])
3939+4040+let () =
4141+ let logr = Note.S.log view send_view in
4242+ Note.Logr.hold logr;
4343+ Js_of_ocaml.Worker.set_onmessage (fun data ->
4444+ let ev : event_msg = Marshal.from_bytes data 0 in
4545+ match ev.handler_id, ev.value with
4646+ | "x", Some v -> (try send_x (int_of_string v) with _ -> ())
4747+ | "y", Some v -> (try send_y (int_of_string v) with _ -> ())
4848+ | _ -> ())
+1-1
js_top_worker/bin/mk_backend.ml
···2424 (* No longer query library stubs - they are now linked directly into each library's JS file *)
2525 let cmd =
2626 Bos.Cmd.(
2727- js_of_ocaml_cmd % "--toplevel" % "--no-cmis" % "--linkall" % "--pretty")
2727+ js_of_ocaml_cmd % "--toplevel" % "--linkall" % "--pretty")
2828 in
2929 let cmd =
3030 List.fold_right
+24
js_top_worker/client/ocaml-worker.d.ts
···126126 mime_vals: MimeVal[];
127127}
128128129129+export interface WidgetUpdateMessage {
130130+ type: string;
131131+ widget_id: string;
132132+ view: any;
133133+}
134134+135135+export interface WidgetClearMessage {
136136+ type: string;
137137+ widget_id: string;
138138+}
139139+129140export interface OcamlWorkerOptions {
130141 /** Timeout in milliseconds (default: 30000) */
131142 timeout?: number;
132143 /** Callback for incremental output after each phrase */
133144 onOutputAt?: (output: OutputAt) => void;
145145+ /** Callback for widget view updates */
146146+ onWidgetUpdate?: (msg: WidgetUpdateMessage) => void;
147147+ /** Callback for widget clear events */
148148+ onWidgetClear?: (msg: WidgetClearMessage) => void;
134149}
135150136151export class OcamlWorker {
···193208 * @param envId - Environment ID
194209 */
195210 destroyEnv(envId: string): Promise<EnvResult>;
211211+212212+ /**
213213+ * Send a widget event to the worker.
214214+ * @param widgetId - Widget identifier
215215+ * @param handlerId - Handler identifier within the widget
216216+ * @param eventType - DOM event type (e.g., 'input', 'click')
217217+ * @param value - Input value if applicable
218218+ */
219219+ sendWidgetEvent(widgetId: string, handlerId: string, eventType: string, value?: string | null): void;
196220197221 /**
198222 * Terminate the worker.
+31
js_top_worker/client/ocaml-worker.js
···145145 this.worker = new Worker(blobUrl);
146146 this.timeout = options.timeout || 30000;
147147 this.onOutputAt = options.onOutputAt || null;
148148+ this.onWidgetUpdate = options.onWidgetUpdate || null;
149149+ this.onWidgetClear = options.onWidgetClear || null;
148150 this.cellIdCounter = 0;
149151 this.pendingRequests = new Map();
150152 this.readyPromise = null;
···218220 case 'env_created':
219221 case 'env_destroyed':
220222 this._resolveRequest(msg.env_id, msg);
223223+ break;
224224+225225+ case 'widget_update':
226226+ if (this.onWidgetUpdate) {
227227+ this.onWidgetUpdate(msg);
228228+ }
229229+ break;
230230+231231+ case 'widget_clear':
232232+ if (this.onWidgetClear) {
233233+ this.onWidgetClear(msg);
234234+ }
221235 break;
222236223237 default:
···426440 type: 'destroy_env',
427441 env_id: envId,
428442 }, envId);
443443+ }
444444+445445+ /**
446446+ * Send a widget event to the worker.
447447+ * @param {string} widgetId - Widget identifier
448448+ * @param {string} handlerId - Handler identifier within the widget
449449+ * @param {string} eventType - DOM event type (e.g., 'input', 'click')
450450+ * @param {string|null} [value=null] - Input value if applicable
451451+ */
452452+ sendWidgetEvent(widgetId, handlerId, eventType, value = null) {
453453+ this.worker.postMessage(JSON.stringify({
454454+ type: 'widget_event',
455455+ widget_id: widgetId,
456456+ handler_id: handlerId,
457457+ event_type: eventType,
458458+ value: value,
459459+ }));
429460 }
430461431462 /**
···11+val register : unit -> unit
22+(** Register the Leaflet.js map adapter ([kind = "leaflet-map"]).
33+ Call this before using [Widget.display_managed ~kind:"leaflet-map"]. *)
···11+(** Interactive widget support for the OCaml toplevel.
22+33+ Widgets are rendered in the client as HTML elements built from
44+ {!View.node} trees. Event handlers in the view are symbolic string
55+ identifiers — when the user interacts with a widget, the client sends
66+ the handler ID and input value back to the worker, where the registered
77+ callback is invoked.
88+99+ Typical usage with Note FRP:
1010+ {[
1111+ let e, send = Note.E.create ()
1212+ let s = Note.S.hold 50 e
1313+1414+ let () =
1515+ let open Widget.View in
1616+ Widget.display ~id:"my-slider"
1717+ ~handlers:["x", (fun v ->
1818+ send (int_of_string (Option.get v)))]
1919+ (Element { tag = "input";
2020+ attrs = [Property ("type", "range")];
2121+ children = [] })
2222+2323+ (* Wire up automatic updates via Note: *)
2424+ let _logr = Note.S.log
2525+ (Note.S.map (fun v -> ... build view ...) s)
2626+ (Widget.update ~id:"my-slider")
2727+ ]} *)
2828+2929+(** Re-export of {!Js_top_worker_message.Widget_view} for convenient access
3030+ from toplevel code. Use [let open Widget.View in ...] to access
3131+ constructors like [Element], [Text], [Property], [Handler], etc. *)
3232+module View = Js_top_worker_message.Widget_view
3333+3434+val display :
3535+ id:string ->
3636+ handlers:(string * (string option -> unit)) list ->
3737+ View.node ->
3838+ unit
3939+(** [display ~id ~handlers view] registers a widget with the given [id],
4040+ installs [handlers] for routing incoming events, and sends the
4141+ initial [view] to the client. If a widget with this [id] already
4242+ exists, it is replaced. *)
4343+4444+val update : id:string -> View.node -> unit
4545+(** [update ~id view] sends an updated view for an existing widget.
4646+ The handler map is not changed. *)
4747+4848+val clear : id:string -> unit
4949+(** [clear ~id] removes the widget and its handlers. Sends a
5050+ WidgetClear message to the client. *)
5151+5252+val display_managed :
5353+ id:string ->
5454+ kind:string ->
5555+ config:string ->
5656+ handlers:(string * (string option -> unit)) list ->
5757+ unit
5858+(** [display_managed ~id ~kind ~config ~handlers] registers a managed widget.
5959+ The client delegates rendering to the adapter registered for [kind].
6060+ [config] is a JSON string interpreted by the adapter.
6161+ [handlers] route incoming events, same as {!display}. *)
6262+6363+val update_config : id:string -> string -> unit
6464+(** [update_config ~id config] sends an updated config to a managed widget.
6565+ The adapter decides how to reconcile the change (e.g. flyTo, setData). *)
6666+6767+val command : id:string -> string -> string -> unit
6868+(** [command ~id cmd data] sends an imperative command to a managed widget.
6969+ [cmd] is the command name, [data] is a JSON string payload.
7070+ Use for one-shot actions like animations that don't represent state. *)
7171+7272+val register_adapter : kind:string -> js:string -> unit
7373+(** [register_adapter ~kind ~js] sends a JavaScript adapter to the client.
7474+ The JS code must be an IIFE that returns an object with methods:
7575+ - [create(container, config, send)] — creates the widget, returns state
7676+ - [update(state, config)] — reconciles a config change
7777+ - [command(state, cmd, data)] — handles an imperative command
7878+ - [destroy(state)] — cleans up
7979+ where [send(handler_id, value)] sends an event back to the worker. *)
8080+8181+val handle_event :
8282+ widget_id:string -> handler_id:string -> value:string option -> unit
8383+(** [handle_event ~widget_id ~handler_id ~value] routes an incoming
8484+ event to the registered handler. Called by the worker message loop
8585+ when a WidgetEvent is received. *)
8686+8787+val set_sender : (string -> unit) -> unit
8888+(** [set_sender f] installs the function used to send JSON strings to
8989+ the client. Called once by the worker at startup. The function [f]
9090+ should call [Worker.post_message]. *)
+61
odoc-interactive-extension/doc/demo_map.mld
···11+{0 Interactive Map Demo}
22+33+@x-ocaml.universe ./universe
44+@x-ocaml.worker ./universe/worker.js
55+66+This page demonstrates a managed Leaflet map widget with FRP signals
77+and commands.
88+99+{@ocaml[
1010+#require "note";;
1111+#require "js_top_worker-widget";;
1212+#require "js_top_worker-widget-leaflet";;
1313+Widget_leaflet.register ();;
1414+]}
1515+1616+{1 Map Widget}
1717+1818+Click on the map to see coordinates. The click position is captured
1919+as a [Note] event and displayed as a signal:
2020+2121+{@ocaml[
2222+let click_e, send_click = Note.E.create ()
2323+let last_click = Note.S.hold "No clicks yet" click_e
2424+2525+let () =
2626+ Widget.display_managed ~id:"mymap"
2727+ ~kind:"leaflet-map"
2828+ ~config:{| {"center": [51.505, -0.09], "zoom": 13, "height": "400px"} |}
2929+ ~handlers:[
3030+ "click", (fun v ->
3131+ let json = Option.get v in
3232+ send_click (Printf.sprintf "Clicked at: %s" json));
3333+ ]
3434+3535+let click_view text =
3636+ let open Widget.View in
3737+ Element { tag = "div"; attrs = [Style ("padding", "8px")];
3838+ children = [Text text] }
3939+4040+let () = Widget.display ~id:"click-info" ~handlers:[] (click_view "No clicks yet")
4141+let _logr = Note.S.log (Note.S.map click_view last_click)
4242+ (Widget.update ~id:"click-info")
4343+let () = Note.Logr.hold _logr
4444+]}
4545+4646+{1 Fly To Command}
4747+4848+This cell sends a command to the map — clicking the button flies to Paris:
4949+5050+{@ocaml[
5151+let fly_view =
5252+ let open Widget.View in
5353+ Element { tag = "button"; attrs = [Handler ("click", "fly")];
5454+ children = [Text "Fly to Paris"] }
5555+5656+let () = Widget.display ~id:"fly-btn" ~handlers:[
5757+ "fly", (fun _ ->
5858+ Widget.command ~id:"mymap" "flyTo"
5959+ {| {"lat": 48.8566, "lng": 2.3522, "zoom": 14} |})
6060+] fly_view
6161+]}
+125
odoc-interactive-extension/doc/demo_widgets.mld
···11+{0 Widget Demo}
22+33+@x-ocaml.universe ./universe
44+@x-ocaml.worker ./universe/worker.js
55+66+This page demonstrates interactive FRP widgets powered by
77+[Widget] and [Note].
88+99+{@ocaml[
1010+#require "note";;
1111+#require "js_top_worker-widget";;
1212+]}
1313+1414+{1 Static Widget}
1515+1616+A simple widget that renders static HTML:
1717+1818+{@ocaml[
1919+let open Widget.View in
2020+Widget.display ~id:"hello" ~handlers:[]
2121+ (Element { tag = "div"; attrs = [];
2222+ children = [Text "Hello from an OCaml widget!"] })
2323+]}
2424+2525+{1 Counter (FRP with Note)}
2626+2727+A counter driven by [Note] signals. Pressing the buttons sends events
2828+back to the worker, which updates the signal:
2929+3030+{@ocaml[
3131+let inc_e, send_inc = Note.E.create ()
3232+let dec_e, send_dec = Note.E.create ()
3333+3434+let count =
3535+ let delta = Note.E.select [
3636+ Note.E.map (fun () n -> n + 1) inc_e;
3737+ Note.E.map (fun () n -> n - 1) dec_e;
3838+ ] in
3939+ Note.S.accum 0 delta
4040+4141+let counter_view n =
4242+ let open Widget.View in
4343+ Element { tag = "div"; attrs = [Class "counter"]; children = [
4444+ Element { tag = "button";
4545+ attrs = [Handler ("click", "dec")];
4646+ children = [Text "-"] };
4747+ Element { tag = "span";
4848+ attrs = [Style ("margin", "0 1em")];
4949+ children = [Text (string_of_int n)] };
5050+ Element { tag = "button";
5151+ attrs = [Handler ("click", "inc")];
5252+ children = [Text "+"] };
5353+ ] }
5454+5555+let () =
5656+ Widget.display ~id:"counter"
5757+ ~handlers:[
5858+ "inc", (fun _ -> send_inc ());
5959+ "dec", (fun _ -> send_dec ());
6060+ ]
6161+ (counter_view 0)
6262+6363+let _logr = Note.S.log
6464+ (Note.S.map counter_view count)
6565+ (Widget.update ~id:"counter")
6666+let () = Note.Logr.hold _logr
6767+]}
6868+6969+{1 Slider}
7070+7171+An input slider that drives a signal. Moving the slider sends the value
7272+back to the worker:
7373+7474+{@ocaml[
7575+let x_e, send_x = Note.E.create ()
7676+let x = Note.S.hold 50 x_e
7777+7878+let slider_view v =
7979+ let open Widget.View in
8080+ Element { tag = "div"; attrs = []; children = [
8181+ Element { tag = "label"; attrs = [];
8282+ children = [Text (Printf.sprintf "X: %d" v)] };
8383+ Element { tag = "input"; attrs = [
8484+ Property ("type", "range");
8585+ Property ("min", "0");
8686+ Property ("max", "100");
8787+ Property ("value", string_of_int v);
8888+ Handler ("input", "x");
8989+ ]; children = [] };
9090+ ] }
9191+9292+let () =
9393+ Widget.display ~id:"slider"
9494+ ~handlers:["x", (fun v ->
9595+ send_x (int_of_string (Option.get v)))]
9696+ (slider_view 50)
9797+9898+let _logr2 = Note.S.log
9999+ (Note.S.map slider_view x)
100100+ (Widget.update ~id:"slider")
101101+let () = Note.Logr.hold _logr2
102102+]}
103103+104104+{1 Cross-cell Derived Widget}
105105+106106+This widget derives from the slider signal [x] defined above. Moving
107107+the slider updates this widget too:
108108+109109+{@ocaml[
110110+let doubled_view v =
111111+ let open Widget.View in
112112+ Element { tag = "div"; attrs = []; children = [
113113+ Text (Printf.sprintf "2x = %d" (v * 2))
114114+ ] }
115115+116116+let () =
117117+ Widget.display ~id:"doubled"
118118+ ~handlers:[]
119119+ (doubled_view (Note.S.value x))
120120+121121+let _logr3 = Note.S.log
122122+ (Note.S.map doubled_view x)
123123+ (Widget.update ~id:"doubled")
124124+let () = Note.Logr.hold _logr3
125125+]}
+26-1
x-ocaml/src/jtw_client.cppo.ml
···4242let make url =
4343 let effective_url = make_effective_url url in
4444 let client = Jtw.create effective_url in
4545- { client; url; init_config = None; on_message_cb = (fun _ -> ()) }
4545+ let t = { client; url; init_config = None; on_message_cb = (fun _ -> ()) } in
4646+ (* Wire widget rendering: forward widget messages from the worker to the
4747+ DOM renderer, and send widget events back to the worker. *)
4848+ let send_fn ~widget_id ~handler_id ~event_type ~value =
4949+ Jtw.send_widget_event client ~widget_id ~handler_id ~event_type ~value
5050+ in
5151+ Jtw.set_on_widget_update client (fun widget_id view_any ->
5252+ (* view_any is Js.Unsafe.any — coerce to Jv.t (they are the same repr) *)
5353+ let view_jv : Jv.t = Obj.magic view_any in
5454+ Widget_render.update_widget ~send:send_fn widget_id view_jv);
5555+ Jtw.set_on_widget_clear client (fun widget_id ->
5656+ Widget_render.clear_widget widget_id);
5757+ Jtw.set_on_widget_config client (fun widget_id config ->
5858+ Widget_render.config_widget widget_id config);
5959+ Jtw.set_on_widget_command client (fun widget_id cmd data ->
6060+ Widget_render.command_widget widget_id cmd data);
6161+ Jtw.set_on_widget_register_adapter client (fun kind js_code ->
6262+ Widget_render.register_js_adapter ~send:send_fn kind js_code);
6363+ t
46644765let on_message t fn = t.on_message_cb <- fn
4866···165183let post t (req : X_protocol.request) =
166184 match req with
167185 | X_protocol.Eval (id, _line_number, code) ->
186186+ (* Tell widget_render which cell is executing so widgets are placed
187187+ right after this cell's <x-ocaml> element in the DOM. *)
188188+ let doc = Brr.Document.to_jv Brr.G.document in
189189+ let cells = Jv.call doc "querySelectorAll" [| Jv.of_string "x-ocaml" |] in
190190+ let cell_el = Jv.call cells "item" [| Jv.of_int id |] in
191191+ if not (Jv.is_null cell_el) then
192192+ Widget_render.set_active_cell cell_el;
168193 let stream = Jtw.eval_stream t.client code in
169194 Lwt.async (fun () ->
170195 Lwt.catch (fun () ->
+247
x-ocaml/src/widget_render.ml
···11+(** Widget renderer for x-ocaml.
22+33+ Renders view node JSON (from js_top_worker widget protocol) into real DOM
44+ elements using Brr, and wires event handlers back to the worker.
55+66+ Supports two kinds of widgets:
77+ - Element/Text views: declarative DOM trees, fully replaced on each update
88+ - Managed widgets: delegate to registered adapters (e.g. Leaflet maps) that
99+ manage their own DOM and respond to config updates and commands *)
1010+1111+open Brr
1212+1313+(** Type alias for the function that sends widget events back to the worker. *)
1414+type send_fn =
1515+ widget_id:string -> handler_id:string ->
1616+ event_type:string -> value:string option -> unit
1717+1818+(** A managed widget adapter. Registered client-side per [kind].
1919+ All functions receive and return raw Jv.t values (JS objects). *)
2020+type adapter = {
2121+ create : Jv.t -> string -> send_fn -> Jv.t;
2222+ (** [create container config send] creates the widget and returns adapter state *)
2323+ update : Jv.t -> string -> unit;
2424+ (** [update state config] reconciles a config change *)
2525+ command : Jv.t -> string -> string -> unit;
2626+ (** [command state cmd data] handles an imperative command *)
2727+ destroy : Jv.t -> unit;
2828+ (** [destroy state] cleans up *)
2929+}
3030+3131+(** Global adapter registry: kind -> adapter *)
3232+let adapters : (string, adapter) Hashtbl.t = Hashtbl.create 8
3333+3434+(** Register an adapter for the given [kind] string. *)
3535+let register_adapter kind adapter =
3636+ Hashtbl.replace adapters kind adapter
3737+3838+(** Register an adapter from JavaScript code.
3939+ The JS must be an IIFE returning [{create, update, command, destroy}].
4040+ [send] in JS is [send(handler_id, value_string)]. *)
4141+let register_js_adapter ~(send : send_fn) kind js_code =
4242+ let obj = Jv.call Jv.global "eval" [| Jv.of_string js_code |] in
4343+ let adapter = {
4444+ create = (fun container_jv config_str send_fn ->
4545+ let js_send = Jv.repr (fun handler_id value ->
4646+ let hid = Jv.to_string handler_id in
4747+ let v =
4848+ if Jv.is_null value || Jv.is_undefined value then None
4949+ else Some (Jv.to_string value)
5050+ in
5151+ send_fn ~widget_id:"" ~handler_id:hid ~event_type:hid ~value:v
5252+ ) in
5353+ Jv.call obj "create" [| container_jv; Jv.of_string config_str; js_send |]);
5454+ update = (fun state config_str ->
5555+ Jv.call obj "update" [| state; Jv.of_string config_str |] |> ignore);
5656+ command = (fun state cmd data ->
5757+ Jv.call obj "command" [| state; Jv.of_string cmd; Jv.of_string data |] |> ignore);
5858+ destroy = (fun state ->
5959+ Jv.call obj "destroy" [| state |] |> ignore);
6060+ } in
6161+ ignore send; (* send is captured by the adapter's create wrapper at call time *)
6262+ Hashtbl.replace adapters kind adapter
6363+6464+(** Per-widget state *)
6565+type widget_entry = {
6666+ container : El.t;
6767+ widget_id : string;
6868+ managed : (string * Jv.t) option;
6969+ (** For managed widgets: (kind, adapter_state) *)
7070+}
7171+7272+(** Global registry of active widgets *)
7373+let widgets : (string, widget_entry) Hashtbl.t = Hashtbl.create 16
7474+7575+(** The current anchor element — new widget containers are inserted after this.
7676+ Set by [set_active_cell] before each cell eval begins. *)
7777+let active_cell : Jv.t option ref = ref None
7878+7979+(** Set the currently active cell element. Call this before each eval so that
8080+ any widgets created during that eval are placed right after the cell. *)
8181+let set_active_cell (el : Jv.t) = active_cell := Some el
8282+8383+(** Recursively render a view node JSON object to a DOM element.
8484+ [send] is called when an event handler fires. *)
8585+let rec render_node ~widget_id ~(send : send_fn) (node : Jv.t) : El.t =
8686+ let t = Jv.to_string (Jv.get node "t") in
8787+ match t with
8888+ | "txt" ->
8989+ let v = Jv.to_string (Jv.get node "v") in
9090+ El.span [ El.txt' v ]
9191+ | "el" ->
9292+ let tag = Jv.to_string (Jv.get node "tag") in
9393+ let attrs_arr =
9494+ let a = Jv.get node "a" in
9595+ if Jv.is_none a || Jv.is_undefined a then [||]
9696+ else Jv.to_jv_array a
9797+ in
9898+ let children_arr =
9999+ let c = Jv.get node "c" in
100100+ if Jv.is_none c || Jv.is_undefined c then [||]
101101+ else Jv.to_jv_array c
102102+ in
103103+ let el = El.v (Jstr.v tag) [] in
104104+ (* Apply attributes *)
105105+ Array.iter (fun attr ->
106106+ let at = Jv.to_string (Jv.get attr "t") in
107107+ match at with
108108+ | "prop" ->
109109+ let k = Jv.to_string (Jv.get attr "k") in
110110+ let v = Jv.to_string (Jv.get attr "v") in
111111+ El.set_at (Jstr.v k) (Some (Jstr.v v)) el
112112+ | "style" ->
113113+ let k = Jv.to_string (Jv.get attr "k") in
114114+ let v = Jv.to_string (Jv.get attr "v") in
115115+ El.set_inline_style (Jstr.v k) (Jstr.v v) el
116116+ | "cls" ->
117117+ let v = Jv.to_string (Jv.get attr "v") in
118118+ El.set_class (Jstr.v v) true el
119119+ | "handler" ->
120120+ let ev_name = Jv.to_string (Jv.get attr "ev") in
121121+ let handler_id = Jv.to_string (Jv.get attr "id") in
122122+ let ev_type = Ev.Type.create (Jstr.v ev_name) in
123123+ let _listener = Ev.listen ev_type (fun _ev ->
124124+ let is_input =
125125+ let tn = Jstr.to_string (El.tag_name el) in
126126+ tn = "input" || tn = "select" || tn = "textarea"
127127+ in
128128+ let value =
129129+ if is_input then
130130+ Some (Jv.to_string (Jv.get (El.to_jv el) "value"))
131131+ else None
132132+ in
133133+ send ~widget_id ~handler_id ~event_type:ev_name ~value
134134+ ) (El.as_target el) in
135135+ ()
136136+ | _ -> ()
137137+ ) attrs_arr;
138138+ (* Append children *)
139139+ Array.iter (fun child ->
140140+ let child_el = render_node ~widget_id ~send child in
141141+ El.append_children el [ child_el ]
142142+ ) children_arr;
143143+ el
144144+ | _ ->
145145+ El.span []
146146+147147+(** Find or create a widget container. New containers are inserted right after
148148+ the currently active x-ocaml cell element, so widgets appear inline with
149149+ their code. On subsequent updates the existing container is reused in place. *)
150150+let find_or_create_container widget_id =
151151+ match Hashtbl.find_opt widgets widget_id with
152152+ | Some entry -> entry.container
153153+ | None ->
154154+ let container = El.div ~at:[ At.class' (Jstr.v "widget-container") ] [] in
155155+ El.set_at (Jstr.v "data-widget-id") (Some (Jstr.v widget_id)) container;
156156+ (* Insert after the active cell element, or fall back to document.body *)
157157+ (match !active_cell with
158158+ | Some cell_jv ->
159159+ (* Walk past any existing widget-containers that are already siblings
160160+ right after this cell, so multiple widgets from the same cell
161161+ stack in creation order. *)
162162+ let next_sibling = ref (Jv.get cell_jv "nextElementSibling") in
163163+ let insert_after = ref cell_jv in
164164+ while not (Jv.is_null !next_sibling || Jv.is_undefined !next_sibling) &&
165165+ (let cls = Jv.to_jstr (Jv.get !next_sibling "className") in
166166+ Jstr.equal cls (Jstr.v "widget-container")) do
167167+ insert_after := !next_sibling;
168168+ next_sibling := Jv.get !next_sibling "nextElementSibling"
169169+ done;
170170+ Jv.call !insert_after "insertAdjacentElement"
171171+ [| Jv.of_string "afterend"; El.to_jv container |] |> ignore
172172+ | None ->
173173+ (* No active cell — fall back to document.body *)
174174+ let body = El.to_jv (Document.body G.document) in
175175+ Jv.call body "appendChild" [| El.to_jv container |] |> ignore);
176176+ let entry = { container; widget_id; managed = None } in
177177+ Hashtbl.replace widgets widget_id entry;
178178+ container
179179+180180+(** Update (or create) a widget with a new view. *)
181181+let update_widget ~(send : send_fn) widget_id (view_json : Jv.t) =
182182+ let t = Jv.to_string (Jv.get view_json "t") in
183183+ if t = "managed" then begin
184184+ let kind = Jv.to_string (Jv.get view_json "kind") in
185185+ let config = Jv.to_string (Jv.get view_json "config") in
186186+ match Hashtbl.find_opt widgets widget_id with
187187+ | Some entry when entry.managed <> None ->
188188+ (* Already created — just update config *)
189189+ let (_k, state) = Option.get entry.managed in
190190+ (match Hashtbl.find_opt adapters kind with
191191+ | Some adapter -> adapter.update state config
192192+ | None -> ())
193193+ | _ ->
194194+ (* First render — create via adapter *)
195195+ let container = find_or_create_container widget_id in
196196+ (match Hashtbl.find_opt adapters kind with
197197+ | None ->
198198+ (* No adapter registered — render an error message *)
199199+ El.set_children container
200200+ [El.span [El.txt' (Printf.sprintf "No adapter for '%s'" kind)]]
201201+ | Some adapter ->
202202+ (* Wrap send so the adapter doesn't need to know its widget_id *)
203203+ let wrapped_send ~widget_id:_ ~handler_id ~event_type ~value =
204204+ send ~widget_id ~handler_id ~event_type ~value
205205+ in
206206+ let state = adapter.create (El.to_jv container) config wrapped_send in
207207+ let entry = { container; widget_id; managed = Some (kind, state) } in
208208+ Hashtbl.replace widgets widget_id entry)
209209+ end else begin
210210+ (* Existing Element/Text path — full DOM replacement *)
211211+ let container = find_or_create_container widget_id in
212212+ El.set_children container [];
213213+ let dom = render_node ~widget_id ~send view_json in
214214+ El.append_children container [ dom ]
215215+ end
216216+217217+(** Update config for a managed widget. *)
218218+let config_widget widget_id config =
219219+ match Hashtbl.find_opt widgets widget_id with
220220+ | Some { managed = Some (kind, state); _ } ->
221221+ (match Hashtbl.find_opt adapters kind with
222222+ | Some adapter -> adapter.update state config
223223+ | None -> ())
224224+ | _ -> ()
225225+226226+(** Send a command to a managed widget. *)
227227+let command_widget widget_id cmd data =
228228+ match Hashtbl.find_opt widgets widget_id with
229229+ | Some { managed = Some (kind, state); _ } ->
230230+ (match Hashtbl.find_opt adapters kind with
231231+ | Some adapter -> adapter.command state cmd data
232232+ | None -> ())
233233+ | _ -> ()
234234+235235+(** Remove a widget and its container. Calls adapter destroy for managed widgets. *)
236236+let clear_widget widget_id =
237237+ match Hashtbl.find_opt widgets widget_id with
238238+ | Some entry ->
239239+ (match entry.managed with
240240+ | Some (kind, state) ->
241241+ (match Hashtbl.find_opt adapters kind with
242242+ | Some adapter -> adapter.destroy state
243243+ | None -> ())
244244+ | None -> ());
245245+ El.remove entry.container;
246246+ Hashtbl.remove widgets widget_id
247247+ | None -> ()