Skip to content

A browser console, and SVG as a first-class output - #1

Merged
sajonaro merged 9 commits into
mainfrom
web-console
Sep 13, 2026
Merged

sajonaro merged 9 commits into
mainfrom
web-console

Conversation

@sajonaro

Copy link
Copy Markdown
Owner

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

basic/ appends to a display list and never touches a pixel; 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 interpreter. 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, not a converted PNG

The display list is vector to begin with, so raster/svg.ml emits geometry as
geometry:

a rings drawing as PNG 8,916 bytes
the same drawing as SVG 891 bytes, sharp at any zoom

n88 --svg writes one beside the source; 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 — same 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

Three hosts and two renderers are five chances to diverge, and nothing else
compares them — dune test links the library natively and conform.sh drives
the CLI.

  • tools/check_web.js runs the whole 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 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 it needs re-measuring when either moves.
  • tools/check_svg.py rasterises each vector rendering and compares the
    bounding 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 a Version.value
holding "0.1.0", read by exactly one thing — a smoke test asserting it
equalled "0.1.0". Nothing bumped it because nothing else read it, so it sat
at 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

sajonaro and others added 9 commits September 13, 2026 13:36
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
sajonaro merged commit 3f86258 into main Sep 13, 2026
1 check passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant