Repository navigation
A browser console, and SVG as a first-class output - #1
Merged
Merged
Conversation
A third host over the same two libraries. `make wc` serves a console at localhost where you type a program and see what it prints AND what it draws -- modelled on c4's, which is the same idea over a different language. The graphics cost nothing to bring, which is the part worth noticing. basic/ appends to a display list and never touches a pixel, and raster/ turns that list into bytes as a pure function, so the page gets drawings without a line of canvas code and without a second renderer. The invariant that keeps basic/ free of I/O -- which exists for the editor's checker -- is what makes this possible at all. SVG IS A SECOND RENDERER OVER THE SAME DISPLAY LIST, not a conversion of the PNG. The list is vector to begin with -- lines, boxes, circles, arcs -- so raster/svg.ml emits geometry as geometry: a drawing that is 8,916 bytes of PNG is 891 bytes of SVG that stays sharp at any zoom. `n88 --svg` writes one beside the source and the web console prefers them. PAINT has no vector form. It is a flood fill, so what it covers depends on every pixel already drawn, and the same goes for tile fills. Those embed the rendered frame instead and encode REPORTS which you got, because silently dropping a fill produces a picture wrong in exactly the way nobody checks. Two new checks, because two renderers and three hosts are three chances to diverge and nothing else compares them: tools/check_web.js runs the whole conformance corpus through the JavaScript build -- 101 of 101, drawings hashed against the same .pnghash files the released binary and the container are checked against. It caught a real bug: the browser host never passed ~on_lp, so POINT read the wrong last point, and only gfx_point_lp in the corpus noticed. It also settles a standing hazard -- js_of_ocaml warns "the generated code might be incorrect" on raster/'s CRC-32 and deflate constants, and every drawing hashes identically, but that is a measurement and needs re-measuring. tools/check_svg.py rasterises each vector rendering and compares the bounding box of the ink to the raster one's. It cannot be a byte comparison -- anti-aliasing differs on half the pixels of any sloped line by construction -- and it is exactly strong enough for what it is for: circles were half a pixel up and to the left when first written, invisible by eye and visible here as a box one short on each far edge. Also: the version moves to basic/version.ml. It lived in bin/main.ml alone, which was fine while the command line was the only host; a string copied per host is a string that goes stale per host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not a gallery at the end: each one sits where it explains something. How it works -- the core performs no I/O and records drawing as a display list, which is why the same interpreter runs in a terminal, an editor and a browser without changing. The VS Code extension -- two paths, and only one runs anything. The squiggles come from the front end compiled to JavaScript and never execute your program; running spawns a real n88. That distinction has now confused two readers, so it gets a picture. In a browser -- everything inside the tab, and the vector/raster split with the reason PAINT cannot be vector. Mauve skin, saved as a diagram-design profile so later diagrams match; the project carries a .diagram-design marker naming it. Three things the drawing caught that the markup did not, each found by rendering the result and looking at it rather than trusting the source: an arrow that cut straight through a zone label, two labels sitting 50px from the strokes they annotate instead of 8, and a label that vanished entirely because the node drawn after it covered the mask. The last is the one worth naming -- it is invisible in the source and obvious in the render. The third diagram lost both its arrow labels rather than gaining room for them. Neither fitted, the dashed stroke already says "conditional", and the node now says when the fallback happens. Deleting was the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three corrections after review. The diagrams now use c4's actual palette rather than a mauve I invented. c4 is Catppuccin Mocha -- base #1e1e2e, surface #313244, text #cdd6f4 -- and its accent is Catppuccin's own mauve, #cba6f7. Dark, not light. The saved profile records that rather than my guess, so later diagrams match the sibling project instead of drifting from it. SVG instead of PNG in the readme: 76 KB of vectors replacing 240 KB of raster, and sharp at any zoom. The only real risk was fonts -- these SVGs position text by fixed coordinate, so a reader without Geist could see it overflow its box. Tested by re-rendering with a deliberately wider serif substituted for every family: the longest node label fills its box and does not escape it. Measured rather than assumed, which is why the PNGs could go. And the browser build had no install instructions at all -- `make wc` with nothing saying what it needs or how to put the thing anywhere. Now: the one opam install, and the four static files to copy, with the point that there is no back end to run. Both verified by executing them. Two in-zone label masks were using the page colour, which shows as a dark chip against a zone's lighter fill on a dark ground. Blended to the composited zone colour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all from review. OPTIONS WERE SCATTERED. Flags were defined wherever they happened to come up -- PREFIX in Installing, --uninstall in Removing it, --svg in a browser section -- and two of them, `n88 -` and --immediate, were not in this readme at all despite being whole modes. There is now one Reference section with every n88 flag, every install.sh flag and variable, every make target, the container, and a pointer to the extension's two settings. The narrative sections keep the prose and stop redefining the options. `make wc` TOLD A LIE. It ran `dune build @web/web 2>/dev/null` and, on any failure at all, printed "the web console needs js_of_ocaml" -- so when the real fault was `dune: command not found` it sent someone to install a library they already had. A message that can only say one thing is wrong whenever the cause is different. scripts/build-web.sh now finds the project's own switch through opam (PATH alone is not enough: dune resolves libraries through the switch environment, so ./_opam/bin/dune would have failed with "Library js_of_ocaml not found" while ocamlfind saw it fine), checks the one genuinely optional dependency by name, and otherwise lets the build's own error through. AND IT NEED NOT BE YOUR PROBLEM AT ALL. When js_of_ocaml is missing and Docker is present, the console is now built in Docker and the four static files copied out -- no OCaml, no opam, no compiler back end on the host. That turned up a real latent bug in the Dockerfile: WORKDIR creates /src as root even after USER opam, which buildx tolerates and the classic builder refuses with "mkdir(_build): Permission denied". Fixed, and CI now builds that stage so it stays fixed. scripts/ HAD NO INDEX AND A NAME COLLISION. Two files called install.sh doing opposite things -- one downloads a release, one builds from source. The latter is now install-from-source.sh, and scripts/README.md lists every script with the rule that separates it from tools/: scripts are things you run, tools are things that check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE SCRIPTS WERE NOT REFACTORED EARLIER, only renamed and indexed, and the question exposed a real bug. Six scripts need dune. Two had their own copy of the logic that finds the opam switch, and FOUR had none at all -- they just called dune and died with "command not found" from a shell where the caller had not run `eval $(opam env)`. That is the same bug that made `make wc` blame js_of_ocaml. scripts/lib/switch.sh is now the only copy, sourced by all six. It records the trap that caused the original misdiagnosis: PATH is not enough, because dune resolves LIBRARIES through the switch environment rather than its own location, so ./_opam/bin/dune finds no js_of_ocaml while ocamlfind lists it happily. Verified by running every script from a stripped environment; all five that produce something now produce it. README NOISE. Cut in three passes, each aimed at a different kind: duplication -- the container one-liner appeared in Quick start and again in its own section; the diagrams' captions were restated in prose beneath them. absences -- "you do not need an OCaml toolchain", "nothing is sent anywhere", "there is no back end to run". Saying what a thing is not is rarely load-bearing. our own history -- why a rule exists, how many fabricated failures a stream-merging bug once cost, what was reported twice. True, and the reader did not ask. The number-typing section went from 40 lines to 18 by leading with the four lines of BASIC that show it. Installing and Quick start merged. Also made the install instructions consistent: README, install.sh's header and the extension guide each said something different, and now all three fetch the script and then run it. Fetch-then-run is better advice than piping a URL into a shell anyway -- it costs one word and lets you read what is about to write to your PATH. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old names described mechanism or nothing at all. build.sh built what?
test.sh tested what, and how is it different from check-invariants.sh?
conform.sh is not even a word. Now the prefix carries the intent and the
directory sorts into three families:
build-interpreter.sh was build.sh
build-web-console.sh was build-web.sh
build-web-console-in-docker.sh was build-web-docker.sh
check-everything.sh was test.sh
check-conformance.sh was conform.sh
check-invariants.sh unchanged -- it was already right
install-from-source.sh unchanged
install-extension.sh unchanged
package-extension.sh unchanged
n88-docker unchanged: it is a shim pretending to be
the binary, so it is named for that
lib/activate-opam-switch.sh was lib/switch.sh
build- produces something, check- verifies something, install-/package-
delivers something. check-conformance.sh sitting beside check-invariants.sh
now reads as what it is -- both are gates, one over the language and one
over the codebase's own habits.
Every reference updated: both workflows, the Makefile, the readmes, and the
scripts' own source lines. Verified by running each from a stripped
environment, checking every path named in the workflows exists, and
grepping for survivors of the old names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`make wc` failed with File "web/bundle.mli", line 1: Error: I/O error: web/bundle.mli: No such file or directory and only `dune clean` recovered it. My fault, and the cause is worth stating plainly: the Docker route delivered the built console into _build/default/web/ with `docker cp`. That directory belongs to dune. It tracks the digest of everything in it and GENERATES bundle.mli and main.mli there, so overwriting it left dune looking for web/main.mli in the source tree, where it has never existed. Convenient-looking, and wrong. Both routes now assemble into ./web-console/, which is ours, gitignored, and the single place `make wc` serves from. Nothing reads out of _build any more either -- the native route copies out of it rather than serving from inside it. Verified by reproducing the reported failure and then running the exact sequence that caused it: docker build, then a native dune build over the top, which now succeeds. Also, while answering "why is python used": tools/check_svg.py is the only thing in this repository's tooling that needs anything beyond the OCaml switch and the Python standard library -- it wants Pillow and rsvg-convert to rasterise an SVG and compare it. It was wired into build-web-console.sh unconditionally, so `make wc` would have died on a machine without them, directly contradicting the readme's claim that Docker alone is enough. It now skips and says why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"make wc hangs". It was not hanging -- it was serving correctly, returning 200 for the page and the 3.5 MB bundle. But the Makefile ran `python3 -m http.server ... 2>/dev/null`, and the request log is the only sign of life a static server gives, so discarding stderr made a working server indistinguishable from a stuck one. The same redirect hid a real failure: a port already in use produced an error nobody could see, which is the same symptom for a different reason. scripts/serve-web-console.sh now checks the port before binding it and says which one to use instead, keeps the log, runs python unbuffered so lines appear as requests arrive rather than when a buffer fills, and states in words that the prompt is not coming back until Ctrl-C. The readme says so too, since "stays running" is the part that was never written down. Verified both paths: requests appear in the log as they are served, and a busy port exits 1 with the alternative spelled out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sajonaro
added a commit
that referenced
this pull request
Sep 13, 2026
The console's editor was a plain textarea, which cannot draw on its own text. It now has a <pre> underneath carrying the colours and a transparent textarea above it, painted from tokens the checker bundle exports as n88basicTokens -- Lexer.tokenize, span for span. Not a regular-expression mode. A second description of the syntax is a second thing to keep in step, and it drifts silently: the colours go on looking plausible while disagreeing with what actually parses. Reserved words this interpreter does not implement are marked invalid by consulting the same table the lexer consults, so the editor says what the Problems list is about to say, sooner. A line the lexer refuses used to lose every token it had already scanned, so one bad character at the end of `OPEN "x" FOR INPUT AS #1` left the whole line uncoloured. It now re-scans the part before the failure -- by construction the part that lexed cleanly -- and marks the failing position. And a bug this uncovered: build-web-console.sh's copy loop never copied n88basic-check.js, so the SHIPPED console had no checker and no diagnostics. Nothing failed; a page loads fine without a script it never calls. The loop now asks index.html what it needs and refuses to finish if something is missing. Verified headlessly against the real page script: markup stripped from the painted layer returns the source character for character -- the invariant the two layers' alignment rests on -- for comparison operators, ampersands in strings, unimplemented words, empty input, trailing blank lines and a file with no final newline. NOT verified in a real browser: Chrome will not launch in this environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A third host over the same two libraries.
make wcserves a console atlocalhost where you type a program and see what it prints and what it
draws — modelled on c4's, which is the same idea over a different language.
The graphics cost nothing to bring
basic/appends to a display list and never touches a pixel;raster/turnsthat list into bytes as a pure function. So the page gets drawings without a
line of canvas code and without a second interpreter. The invariant that keeps
basic/free of I/O — which exists for the editor's checker — is what makesthis possible at all.
SVG is a second renderer, not a converted PNG
The display list is vector to begin with, so
raster/svg.mlemits geometry asgeometry:
n88 --svgwrites one beside the source; the web console prefers them.PAINThas no vector form. It is a flood fill, so what it covers dependson every pixel already drawn — same for tile fills. Those embed the rendered
frame instead, and
encodereports which you got, because silently droppinga fill produces a picture wrong in exactly the way nobody checks.
Two new checks
Three hosts and two renderers are five chances to diverge, and nothing else
compares them —
dune testlinks the library natively andconform.shdrivesthe CLI.
tools/check_web.jsruns the whole corpus through the JavaScript build:101 of 101, drawings hashed against the same
.pnghashfiles the releasedbinary and the container are checked against. It caught a real bug — the
browser host never passed
~on_lp, soPOINTread the wrong last point, andonly
gfx_point_lpnoticed. It also settles a standing hazard: js_of_ocamlwarns "the generated code might be incorrect" on
raster/'s CRC-32 anddeflate constants, and every drawing hashes identically — but that is a
measurement, and it needs re-measuring when either moves.
tools/check_svg.pyrasterises each vector rendering and compares thebounding box of the ink. It cannot be a byte comparison — anti-aliasing
differs on half the pixels of any sloped line by construction — and it is
exactly strong enough for its job: circles were half a pixel up and to the
left when first written, invisible by eye and visible here as a box one short
on each far edge.
Also
The version moves to
basic/version.ml. There was already aVersion.valueholding
"0.1.0", read by exactly one thing — a smoke test asserting itequalled
"0.1.0". Nothing bumped it because nothing else read it, so it satat 0.1.0 five releases later. A constant checked against a copy of itself is
not checked. The smoke test now asserts the shape; the release workflow's
tag comparison is the check with teeth.
🤖 Generated with Claude Code