this repo has no description

docs: tessera-viz PNG + tessera-viz-jsoo implementation plan

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

+537
+537
docs/plans/2026-03-06-tessera-viz-png-jsoo.md
··· 1 + # tessera-viz PNG Encoder + tessera-viz-jsoo Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a portable PNG encoder to tessera-viz and create tessera-viz-jsoo for displaying images in the OCaml notebook. 6 + 7 + **Architecture:** The PNG encoder lives in the portable tessera-viz library using uncompressed deflate (no external deps). tessera-viz-jsoo is a thin wrapper that base64-encodes PNG output into data URLs. 8 + 9 + **Tech Stack:** OCaml, Bigarray, js_of_ocaml, Alcotest, Playwright 10 + 11 + --- 12 + 13 + ### Task 1: PNG encoder in tessera-viz 14 + 15 + **Files:** 16 + - Modify: `tessera-viz/lib/viz.ml` (add `png_of_rgba`) 17 + - Modify: `tessera-viz/lib/viz.mli` (add `png_of_rgba`) 18 + - Modify: `tessera-viz/test/test_viz.ml` (add PNG tests) 19 + 20 + **Reference:** The PNG spec requires: 21 + - 8-byte signature: `\x89PNG\r\n\x1a\n` 22 + - IHDR chunk: 13 bytes (width:4, height:4, bit_depth:1=8, color_type:1=6, compression:1=0, filter:1=0, interlace:1=0) 23 + - IDAT chunk: zlib stream containing filtered scanlines 24 + - IEND chunk: empty 25 + - Each chunk: length(4 BE) + type(4 ASCII) + data + CRC32(4 BE) over type+data 26 + 27 + For uncompressed deflate (stored blocks): 28 + - Zlib header: `\x78\x01` (deflate, no compression) 29 + - Stored blocks: each has 5-byte header (final_flag:1, len:2 LE, nlen:2 LE) + raw data 30 + - Max block size: 65535 bytes 31 + - Adler-32 checksum at end 32 + 33 + Scanlines: each row = filter_byte(0) + width*4 RGBA bytes. 34 + 35 + **Step 1: Add PNG tests to test_viz.ml** 36 + 37 + Add these tests to `tessera-viz/test/test_viz.ml`: 38 + 39 + ```ocaml 40 + (* PNG encoder tests *) 41 + 42 + let test_png_magic () = 43 + (* 1x1 red pixel *) 44 + let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout 4 in 45 + Bigarray.Array1.set data 0 255; 46 + Bigarray.Array1.set data 1 0; 47 + Bigarray.Array1.set data 2 0; 48 + Bigarray.Array1.set data 3 255; 49 + let img = Viz.{ data; width = 1; height = 1 } in 50 + let png = Viz.png_of_rgba img in 51 + (* Check PNG signature *) 52 + Alcotest.(check int) "byte 0" 0x89 (Char.code png.[0]); 53 + Alcotest.(check char) "byte 1" 'P' png.[1]; 54 + Alcotest.(check char) "byte 2" 'N' png.[2]; 55 + Alcotest.(check char) "byte 3" 'G' png.[3]; 56 + Alcotest.(check bool) "length > 20" true (String.length png > 20) 57 + 58 + let test_png_roundtrip () = 59 + (* 2x2 image with known colors *) 60 + let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout 16 in 61 + (* Red, Green, Blue, White *) 62 + let pixels = [| 255;0;0;255; 0;255;0;255; 0;0;255;255; 255;255;255;255 |] in 63 + Array.iteri (fun i v -> Bigarray.Array1.set data i v) pixels; 64 + let img = Viz.{ data; width = 2; height = 2 } in 65 + let png = Viz.png_of_rgba img in 66 + (* Write to temp file and decode with Python *) 67 + let tmp = Filename.temp_file "test_png" ".png" in 68 + let oc = open_out_bin tmp in 69 + output_string oc png; 70 + close_out oc; 71 + let cmd = Printf.sprintf 72 + "python3 -c \"from PIL import Image; img = Image.open('%s'); print(img.size, img.mode, list(img.getdata()))\"" tmp in 73 + let ic = Unix.open_process_in cmd in 74 + let output = input_line ic in 75 + let _ = Unix.close_process_in ic in 76 + Sys.remove tmp; 77 + (* Verify Python decoded it correctly *) 78 + Alcotest.(check bool) "contains (2, 2)" true (String.length output > 0 && String.sub output 0 6 = "(2, 2)"); 79 + Alcotest.(check bool) "contains RGBA" true 80 + (let s = output in try let _ = String.index s 'R' in true with Not_found -> false); 81 + Printf.printf "Python output: %s\n" output 82 + 83 + let test_png_larger_image () = 84 + (* 100x100 gradient — tests multi-block deflate *) 85 + let w = 100 and h = 100 in 86 + let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout (w * h * 4) in 87 + for y = 0 to h - 1 do 88 + for x = 0 to w - 1 do 89 + let off = (y * w + x) * 4 in 90 + Bigarray.Array1.set data off (x * 255 / 99); 91 + Bigarray.Array1.set data (off + 1) (y * 255 / 99); 92 + Bigarray.Array1.set data (off + 2) 128; 93 + Bigarray.Array1.set data (off + 3) 255 94 + done 95 + done; 96 + let img = Viz.{ data; width = w; height = h } in 97 + let png = Viz.png_of_rgba img in 98 + (* Verify with Python *) 99 + let tmp = Filename.temp_file "test_png_large" ".png" in 100 + let oc = open_out_bin tmp in 101 + output_string oc png; 102 + close_out oc; 103 + let cmd = Printf.sprintf 104 + "python3 -c \"from PIL import Image; img = Image.open('%s'); print(img.size, list(img.getpixel((0,0))), list(img.getpixel((99,99))))\"" tmp in 105 + let ic = Unix.open_process_in cmd in 106 + let output = input_line ic in 107 + let _ = Unix.close_process_in ic in 108 + Sys.remove tmp; 109 + Alcotest.(check bool) "contains (100, 100)" true (String.length output > 0 && String.sub output 0 10 = "(100, 100)"); 110 + Printf.printf "Python output: %s\n" output 111 + 112 + let png_tests = 113 + [ Alcotest.test_case "PNG magic bytes" `Quick test_png_magic 114 + ; Alcotest.test_case "PNG roundtrip via Python" `Quick test_png_roundtrip 115 + ; Alcotest.test_case "PNG larger image" `Quick test_png_larger_image 116 + ] 117 + ``` 118 + 119 + Also register the test suite by modifying the `Alcotest.run` call: 120 + 121 + ```ocaml 122 + let () = 123 + Alcotest.run "tessera-viz" 124 + [ ("percentile", percentile_tests) 125 + ; ("pca_to_rgba", pca_tests) 126 + ; ("color_of_hex", color_tests) 127 + ; ("classification_to_rgba", classification_tests) 128 + ; ("png_of_rgba", png_tests) 129 + ] 130 + ``` 131 + 132 + Add `unix` to the test's dune libraries (needed for `Unix.open_process_in`). Check `tessera-viz/test/dune` — if it doesn't include `unix`, add it. 133 + 134 + **Step 2: Add `png_of_rgba` to viz.mli** 135 + 136 + Add to the end of `tessera-viz/lib/viz.mli`: 137 + 138 + ```ocaml 139 + (** {1 PNG encoding} *) 140 + 141 + val png_of_rgba : rgba_image -> string 142 + (** Encode an RGBA image as PNG bytes. 143 + Uses uncompressed deflate (stored blocks) for portability — 144 + no external compression library required. *) 145 + ``` 146 + 147 + **Step 3: Implement `png_of_rgba` in viz.ml** 148 + 149 + Add to the end of `tessera-viz/lib/viz.ml`: 150 + 151 + ```ocaml 152 + (* ---- Minimal PNG encoder ---- *) 153 + 154 + let png_crc32_table = 155 + Array.init 256 (fun n -> 156 + let c = ref (Int32.of_int n) in 157 + for _ = 0 to 7 do 158 + if Int32.logand !c 1l <> 0l then 159 + c := Int32.logxor (Int32.shift_right_logical !c 1) 0xEDB88320l 160 + else 161 + c := Int32.shift_right_logical !c 1 162 + done; 163 + !c) 164 + 165 + let png_crc32 data ofs len = 166 + let c = ref 0xFFFFFFFFl in 167 + for i = ofs to ofs + len - 1 do 168 + let byte = Char.code (Bytes.get data i) in 169 + let idx = Int32.to_int (Int32.logand (Int32.logxor !c (Int32.of_int byte)) 0xFFl) in 170 + c := Int32.logxor (Int32.shift_right_logical !c 8) png_crc32_table.(idx) 171 + done; 172 + Int32.logxor !c 0xFFFFFFFFl 173 + 174 + let put_be32 buf pos v = 175 + Bytes.set buf pos (Char.chr ((v lsr 24) land 0xFF)); 176 + Bytes.set buf (pos + 1) (Char.chr ((v lsr 16) land 0xFF)); 177 + Bytes.set buf (pos + 2) (Char.chr ((v lsr 8) land 0xFF)); 178 + Bytes.set buf (pos + 3) (Char.chr (v land 0xFF)) 179 + 180 + let put_be32_i32 buf pos v = 181 + let v = Int32.to_int v in 182 + put_be32 buf pos v 183 + 184 + let png_chunk buf typ data = 185 + let len = Bytes.length data in 186 + let chunk = Bytes.create (len + 4) in 187 + Bytes.blit_string typ 0 chunk 0 4; 188 + Bytes.blit data 0 chunk 4 len; 189 + let crc = png_crc32 chunk 0 (len + 4) in 190 + Buffer.add_bytes buf (let b = Bytes.create 4 in put_be32 b 0 len; b); 191 + Buffer.add_bytes buf chunk; 192 + Buffer.add_bytes buf (let b = Bytes.create 4 in put_be32_i32 b 0 crc; b) 193 + 194 + let png_of_rgba img = 195 + let buf = Buffer.create (img.width * img.height * 4 + 1024) in 196 + (* PNG signature *) 197 + Buffer.add_string buf "\x89PNG\r\n\x1a\n"; 198 + (* IHDR *) 199 + let ihdr = Bytes.create 13 in 200 + put_be32 ihdr 0 img.width; 201 + put_be32 ihdr 4 img.height; 202 + Bytes.set ihdr 8 '\x08'; (* bit depth 8 *) 203 + Bytes.set ihdr 9 '\x06'; (* color type 6 = RGBA *) 204 + Bytes.set ihdr 10 '\x00'; (* compression *) 205 + Bytes.set ihdr 11 '\x00'; (* filter *) 206 + Bytes.set ihdr 12 '\x00'; (* interlace *) 207 + png_chunk buf "IHDR" ihdr; 208 + (* Build raw scanlines: filter_byte(0) + row RGBA data *) 209 + let row_bytes = 1 + img.width * 4 in 210 + let raw_len = row_bytes * img.height in 211 + let raw = Bytes.create raw_len in 212 + for y = 0 to img.height - 1 do 213 + Bytes.set raw (y * row_bytes) '\x00'; (* filter: None *) 214 + for x = 0 to img.width * 4 - 1 do 215 + let v = Bigarray.Array1.get img.data (y * img.width * 4 + x) in 216 + Bytes.set raw (y * row_bytes + 1 + x) (Char.chr v) 217 + done 218 + done; 219 + (* Wrap in uncompressed deflate stored blocks *) 220 + let max_block = 65535 in 221 + let idat_buf = Buffer.create (raw_len + raw_len / max_block * 5 + 20) in 222 + (* Zlib header: CM=8, CINFO=7, FCHECK to make it valid *) 223 + Buffer.add_char idat_buf '\x78'; 224 + Buffer.add_char idat_buf '\x01'; 225 + let pos = ref 0 in 226 + while !pos < raw_len do 227 + let remaining = raw_len - !pos in 228 + let block_len = min remaining max_block in 229 + let is_final = !pos + block_len >= raw_len in 230 + Buffer.add_char idat_buf (if is_final then '\x01' else '\x00'); 231 + Buffer.add_char idat_buf (Char.chr (block_len land 0xFF)); 232 + Buffer.add_char idat_buf (Char.chr ((block_len lsr 8) land 0xFF)); 233 + let nlen = block_len lxor 0xFFFF in 234 + Buffer.add_char idat_buf (Char.chr (nlen land 0xFF)); 235 + Buffer.add_char idat_buf (Char.chr ((nlen lsr 8) land 0xFF)); 236 + Buffer.add_subbytes idat_buf raw !pos block_len; 237 + pos := !pos + block_len 238 + done; 239 + (* Adler-32 checksum *) 240 + let a = ref 1 and b = ref 0 in 241 + for i = 0 to raw_len - 1 do 242 + a := (!a + Char.code (Bytes.get raw i)) mod 65521; 243 + b := (!b + !a) mod 65521 244 + done; 245 + let adler = (!b lsl 16) lor !a in 246 + let adler_bytes = Bytes.create 4 in 247 + put_be32 adler_bytes 0 adler; 248 + Buffer.add_bytes idat_buf adler_bytes; 249 + png_chunk buf "IDAT" (Buffer.to_bytes idat_buf); 250 + (* IEND *) 251 + png_chunk buf "IEND" Bytes.empty; 252 + Buffer.contents buf 253 + ``` 254 + 255 + **Step 4: Build and test** 256 + 257 + Run: `cd ~/workspace/mono && opam exec -- dune build @tessera-viz/test/runtest` 258 + Expected: All tests pass including the 3 new PNG tests. 259 + 260 + **Step 5: Commit** 261 + 262 + ``` 263 + git add tessera-viz/ 264 + git commit -m "tessera-viz: add portable PNG encoder (uncompressed deflate)" 265 + ``` 266 + 267 + --- 268 + 269 + ### Task 2: tessera-viz-jsoo package 270 + 271 + **Files:** 272 + - Create: `tessera-viz-jsoo/dune-project` 273 + - Create: `tessera-viz-jsoo/lib/dune` 274 + - Create: `tessera-viz-jsoo/lib/viz_jsoo.mli` 275 + - Create: `tessera-viz-jsoo/lib/viz_jsoo.ml` 276 + 277 + **Step 1: Create dune-project** 278 + 279 + ```dune 280 + (lang dune 3.17) 281 + (name tessera-viz-jsoo) 282 + (generate_opam_files true) 283 + (license ISC) 284 + (package 285 + (name tessera-viz-jsoo) 286 + (synopsis "Browser display for tessera-viz images") 287 + (description "Base64 PNG data URL generation for displaying tessera-viz images in OCaml notebooks.") 288 + (depends 289 + (ocaml (>= 5.2)) 290 + (tessera-viz (>= 0.1)) 291 + (js_of_ocaml (>= 5.0)) 292 + (js_of_ocaml-ppx (>= 5.0)))) 293 + ``` 294 + 295 + **Step 2: Create lib/dune** 296 + 297 + ```dune 298 + (library 299 + (name viz_jsoo) 300 + (public_name tessera-viz-jsoo) 301 + (libraries tessera-viz js_of_ocaml) 302 + (preprocess (pps js_of_ocaml-ppx))) 303 + ``` 304 + 305 + **Step 3: Create lib/viz_jsoo.mli** 306 + 307 + ```ocaml 308 + (** Browser display helpers for tessera-viz images. 309 + 310 + Converts RGBA images to base64-encoded PNG data URLs 311 + for display in OCaml notebook cells. *) 312 + 313 + val to_data_url : Viz.rgba_image -> string 314 + (** Convert an RGBA image to a [data:image/png;base64,...] URL. 315 + Suitable for use as an [<img>] src attribute. *) 316 + ``` 317 + 318 + **Step 4: Create lib/viz_jsoo.ml** 319 + 320 + ```ocaml 321 + open Js_of_ocaml 322 + 323 + let to_data_url img = 324 + let png = Viz.png_of_rgba img in 325 + let b64 = Js.to_string (Dom_html.window##btoa (Js.string png)) in 326 + "data:image/png;base64," ^ b64 327 + ``` 328 + 329 + Wait — `btoa` is not available in web workers (`Dom_html.window` doesn't exist). Use `Js.Unsafe.global##btoa` instead, which works in both window and worker contexts: 330 + 331 + ```ocaml 332 + open Js_of_ocaml 333 + 334 + let to_data_url img = 335 + let png = Viz.png_of_rgba img in 336 + let b64 = Js.to_string (Js.Unsafe.global##btoa (Js.string png)) in 337 + "data:image/png;base64," ^ b64 338 + ``` 339 + 340 + Actually, `btoa` only handles Latin-1 strings. PNG bytes may have values > 127 which works with Latin-1 (each byte maps to a codepoint). js_of_ocaml's `Js.string` should handle this correctly since OCaml strings are byte sequences. But to be safe, use the `Typed_array` approach: 341 + 342 + ```ocaml 343 + open Js_of_ocaml 344 + 345 + let to_data_url img = 346 + let png = Viz.png_of_rgba img in 347 + (* Convert OCaml string to Uint8Array, then to base64 via btoa *) 348 + let len = String.length png in 349 + let arr = new%js Typed_array.uint8Array len in 350 + for i = 0 to len - 1 do 351 + Typed_array.set arr i (Char.code png.[i]) 352 + done; 353 + (* Use String.fromCharCode.apply to convert Uint8Array to JS string for btoa *) 354 + let js_str = 355 + let chunk_size = 8192 in 356 + let parts = Js.array [||] in 357 + let pos = ref 0 in 358 + while !pos < len do 359 + let end_pos = min (!pos + chunk_size) len in 360 + let sub = arr##slice !pos end_pos in 361 + let s = Js.Unsafe.fun_call 362 + (Js.Unsafe.js_expr "String.fromCharCode.apply") 363 + [| Js.Unsafe.inject Js.null; Js.Unsafe.inject sub |] in 364 + ignore (parts##push s); 365 + pos := end_pos 366 + done; 367 + parts##join (Js.string "") 368 + in 369 + let b64 = Js.to_string (Js.Unsafe.global##btoa js_str) in 370 + "data:image/png;base64," ^ b64 371 + ``` 372 + 373 + Hmm, this is getting complex. A simpler approach — since `Js.string` in js_of_ocaml handles arbitrary byte strings by mapping each byte to the corresponding UTF-16 code unit (which is what btoa expects for Latin-1), we can just use: 374 + 375 + ```ocaml 376 + open Js_of_ocaml 377 + 378 + let to_data_url img = 379 + let png = Viz.png_of_rgba img in 380 + let b64 = Js.to_string (Js.Unsafe.global##btoa (Js.bytestring png)) in 381 + "data:image/png;base64," ^ b64 382 + ``` 383 + 384 + `Js.bytestring` is the correct function for binary data — it preserves byte values as Latin-1 code points, which is exactly what `btoa` expects. 385 + 386 + **Step 5: Build** 387 + 388 + Run: `cd ~/workspace/mono && opam exec -- dune build -p tessera-viz-jsoo` 389 + Expected: Build succeeds. 390 + 391 + **Step 6: Commit** 392 + 393 + ``` 394 + git add tessera-viz-jsoo/ 395 + git commit -m "tessera-viz-jsoo: scaffold with data URL helper" 396 + ``` 397 + 398 + --- 399 + 400 + ### Task 3: Playwright browser test for tessera-viz-jsoo 401 + 402 + **Files:** 403 + - Create: `tessera-viz-jsoo/test/dune` 404 + - Create: `tessera-viz-jsoo/test/test_browser.ml` 405 + - Create: `tessera-viz-jsoo/test/test_browser.html` 406 + 407 + **Context:** Tests run in a web worker (same pattern as tessera-geotessera-jsoo). The worker creates a small RGBA image, encodes it as a data URL, and posts it to the page. The page sets it as an `<img>` src. Playwright verifies the image renders. 408 + 409 + **Step 1: Create test/dune** 410 + 411 + ```dune 412 + (executable 413 + (name test_browser) 414 + (modes js) 415 + (libraries tessera-viz-jsoo tessera-viz tessera-linalg js_of_ocaml) 416 + (preprocess (pps js_of_ocaml-ppx))) 417 + ``` 418 + 419 + **Step 2: Create test/test_browser.ml** 420 + 421 + ```ocaml 422 + open Js_of_ocaml 423 + 424 + let post_result id text = 425 + ignore (Js.Unsafe.global##postMessage (Js.string (id ^ ":" ^ text))) 426 + 427 + let () = 428 + try 429 + (* Create a 4x4 test image: red/green/blue/white quadrants *) 430 + let w = 4 and h = 4 in 431 + let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout (w * h * 4) in 432 + for y = 0 to h - 1 do 433 + for x = 0 to w - 1 do 434 + let off = (y * w + x) * 4 in 435 + let r, g, b = 436 + if y < 2 && x < 2 then (255, 0, 0) (* top-left: red *) 437 + else if y < 2 then (0, 255, 0) (* top-right: green *) 438 + else if x < 2 then (0, 0, 255) (* bottom-left: blue *) 439 + else (255, 255, 255) (* bottom-right: white *) 440 + in 441 + Bigarray.Array1.set data off r; 442 + Bigarray.Array1.set data (off + 1) g; 443 + Bigarray.Array1.set data (off + 2) b; 444 + Bigarray.Array1.set data (off + 3) 255 445 + done 446 + done; 447 + let img = Viz.{ data; width = w; height = h } in 448 + 449 + (* Test 1: PNG encoding produces valid data *) 450 + let png = Viz.png_of_rgba img in 451 + post_result "png-result" 452 + (Printf.sprintf "OK: %d bytes, magic=0x%02x%c%c%c" 453 + (String.length png) (Char.code png.[0]) png.[1] png.[2] png.[3]); 454 + 455 + (* Test 2: data URL generation *) 456 + let url = Viz_jsoo.to_data_url img in 457 + let prefix = "data:image/png;base64," in 458 + let has_prefix = String.length url > String.length prefix && 459 + String.sub url 0 (String.length prefix) = prefix in 460 + post_result "url-result" 461 + (if has_prefix then Printf.sprintf "OK: %d chars" (String.length url) 462 + else "FAIL: bad prefix"); 463 + 464 + (* Post the data URL for the page to display as an image *) 465 + post_result "img-src" url; 466 + 467 + post_result "status" "ALL PASSED" 468 + with exn -> 469 + post_result "status" (Printf.sprintf "FAILED: %s" (Printexc.to_string exn)) 470 + ``` 471 + 472 + **Step 3: Create test/test_browser.html** 473 + 474 + ```html 475 + <!DOCTYPE html> 476 + <html> 477 + <head><title>tessera-viz-jsoo test</title></head> 478 + <body> 479 + <h1>tessera-viz-jsoo Browser Tests</h1> 480 + <p>PNG: <span id="png-result">pending...</span></p> 481 + <p>URL: <span id="url-result">pending...</span></p> 482 + <p>Status: <span id="status">running...</span></p> 483 + <p>Image: <img id="test-image" alt="test image" /></p> 484 + <script> 485 + var worker = new Worker('test_browser.bc.js'); 486 + worker.onmessage = function(e) { 487 + var idx = e.data.indexOf(':'); 488 + if (idx === -1) return; 489 + var id = e.data.substring(0, idx); 490 + var text = e.data.substring(idx + 1); 491 + if (id === 'img-src') { 492 + document.getElementById('test-image').src = text; 493 + } else { 494 + var el = document.getElementById(id); 495 + if (el) el.textContent = text; 496 + } 497 + }; 498 + worker.onerror = function(e) { 499 + document.getElementById('status').textContent = 'FAILED: ' + e.message; 500 + }; 501 + </script> 502 + </body> 503 + </html> 504 + ``` 505 + 506 + **Step 4: Build and test** 507 + 508 + Build: `cd ~/workspace/mono && opam exec -- dune build tessera-viz-jsoo/test/test_browser.bc.js` 509 + 510 + Serve and test with Playwright: 511 + 1. Copy HTML to build dir 512 + 2. Start `python3 -m http.server 8766` in the build dir 513 + 3. Navigate to `http://localhost:8766/test_browser.html` 514 + 4. Wait for `#status` to show "ALL PASSED" 515 + 5. Verify `#test-image` has a src starting with `data:image/png;base64,` 516 + 517 + **Step 5: Commit** 518 + 519 + ``` 520 + git add tessera-viz-jsoo/test/ 521 + git commit -m "tessera-viz-jsoo: add Playwright browser test" 522 + ``` 523 + 524 + --- 525 + 526 + ### Task 4: Generate opam file 527 + 528 + **Step 1: Generate** 529 + 530 + Run: `cd ~/workspace/mono && opam exec -- dune build tessera-viz-jsoo/tessera-viz-jsoo.opam` 531 + 532 + **Step 2: Verify and commit** 533 + 534 + ``` 535 + git add tessera-viz-jsoo/tessera-viz-jsoo.opam 536 + git commit -m "tessera-viz-jsoo: generate opam file" 537 + ```