vim-slime

<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> vim-slime </h1> <p> Mar 26, 2012 </p> </div> </div> <div id="post_content"> <html><body><p>Today I found the awesomeness that is <a href="https://github.com/jpalardy/vim-slime">vim-slime</a>, it's been an exciting day for me. <a href="http://common-lisp.net/project/slime/">Slime</a> is the "The Superior Lisp Interaction Mode for Emacs", I can almost hear the emacs crowd laughing.</p> <p>For those that use vim and haven't used Slime, vim-slime or <a href="https://github.com/vim-scripts/VimClojure">something similar</a>, this is why it's awesome:</p> <p><strong>Text can be sent from any process to the stdin of a <a href="http://www.gnu.org/software/screen/">gnu screen</a> or <a href="http://tmux.sourceforge.net/">tmux</a> session. The process in this case is vim and the screen/tmux session is a terminal</strong>.</p> <p>Screen is a <a href="/journal/oh-screen-where-have-you-been">really neat</a> terminal multiplexer (you can run multiple terminals in a terminal window). The multiplexed shell processes are children of the screen process, which itself is not a child of the terminal window process. This means a screen process and its child processes keep running if you close the terminal window. Later you can re-connect to it, this is what makes vim-slime possible.</p> <p>Here is an screen shot, on the left is me in gVim writing some awful Clojure <a href="#footnote-1">[1]</a>. On the right is a screen buffer in which I started a Clojure REPL. When I want to try run some code I can send any vim text selection to the REPL in a keystroke (or two).</p> <p><img alt="vim slime screenshot" src="screenshot.jpg"/></p> <p>It doesn't have to be a Clojure REPL either, we can send anything to a screen shell. We could run git commands, find, grep, sed, etc. Like with the Clojure REPL we can even interact with any terminal programs that use STDIN.</p> <p>This concept can be taken even further, You can even connect to a tmux session over SSH and share a terminal or a <a href="http://remotepairprogramming.com/remote-pair-programming-with-tmux-and-vim-the">terminal program like vim to do remote pairing</a>!</p> <p>Hopefully remote pairing is the topic of my next post as there are a couple geographically distant people I know who are keen to do some pair hacking. I stand to learn a lot!</p> <p><a name="footnote-1">[1]</a> I learnt almost everything I know about Lisp from <a href="http://www.ccs.neu.edu/home/matthias/BTLS/">The Little Schemer</a>. Great book.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p>&nbsp;</p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p>&nbsp;</p> </div> </body> </html>

Permalink

Swipe Keyboard

<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> <style> #swipe-canvas { position: relative; width: 900px; height: 300px; } #swipe-results { font-size: 30px; padding-left: 50px; padding-left: 50px; } #swipe-results ul { margin: 0px; padding: 0px; } #swipe-results li { float: left; background-color: #DDDDDD; list-style-type: none; padding: 10px; margin: 5px; border-radius: 5px; } #swipe { position: relative; } #swipe-loading { position: absolute; height: 50px; width: 300px; top: 85px; left: 300px; background-color: darkgray; border-radius: 10px; text-align: center; padding-top: 20px; border: black; border-width: 5px; } </style> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> Swipe Keyboard </h1> <p> Apr 06, 2014 </p> </div> </div> <div id="post_content"> <html><body><p>When I first tried a <a href="http://www.swype.com/">Swype</a> keyboard I was impressed how effective it was. Even though I don't use the feature on my phone I was interested in how it could be built, so I <a href="https://github.com/tarnacious/swipe-keyboard">implemented this otherwise useless swipe-able keyboard</a> below. It probably doesn't work on mobile devices, but works on modern browsers with mouse pointers (although I've only really tried Chrome and Firefox).</p> <div id="swipe"> <canvas height="300px" id="swipe-canvas" width="900px"></canvas> <div id="swipe-results"></div> <div style="clear: both"></div> <h2 id="swipe-loading">Loading<noscript>Javascript is Required</noscript></h2> </div> <p>I initially tried to solve this using the technique Peter Norvig famously uses in his <a href="http://norvig.com/spell-correct.html]">spell checker</a>. He takes a sequence of characters and generates a set of word candidates by adding, removing and swapping characters in the original sequence, the generated candidates are removed if they are not found a dictionary. This can work but to be effective too many combinations need to be generated.</p> <p>If the dictionary is indexed into a <a href="http://en.wikipedia.org/wiki/Trie">trie</a> the number of combinations generated can be reduced significantly by traversing the trie and only generating valid letter combinations. This is a pretty bare implementation of that, it requires: </p> <ul> <li>The first and last characters of the initial sequence are used </li> <li>Intermediate characters in the initial sequence can be repeated or discarded </li> <li>No characters are added or swapped</li> </ul> <p>Basically, if you swipe through all the characters in a word in order, then the word will be found if it is in the index regardless how many characters are swiped in between. It is surprisingly quick and effective.</p> <p>This implementation uses <a href="https://raw.github.com/first20hours/google-10000-english/master/google-10000-english.txt">these 10000 words</a>, I intended to use digital books but never got around to it as these words demonstrate the concept well enough.</p> <p>This is the first thing I've written in <a href="https://github.com/clojure/clojurescript">ClojureScript</a> or <a href="https://github.com/clojure/clojurescript">Clojure</a>, so my code my vary from non-idiomatic to shamblolic. I initially used a <a href="http://clojuredocs.org/clojure_core/clojure.zip/zipper">zipper</a> to build the trie with immutable data structures, but found the indexing took to long with my zipper implementation so I <a href="https://github.com/tarnacious/swipe-keyboard/commit/6edd7b26e78121fbe8586b3f0ef54ca8277d9e32">switched to using native Javascript maps</a>.</p> <p>I found that <a href="https://github.com/clojure/core.async">core.async</a> library is really awesome, the <a href="http://docs.closure-library.googlecode.com/git/index.html">Google closure library</a> and <a href="https://developers.google.com/closure/compiler/">compiler</a> integration with <a href="http://leiningen.org/">Leiningen</a> the <a href="https://github.com/emezeske/lein-cljsbuild">cljsbuild plug-in</a> to be impressive. My main pains were the slow JVM start-up time, the advanced closure compiler build of the web worker script fails silently when run (but the main script works fine when compiled with the advanced compiler), and at times I felt some compile time type checking would be nice.</p> <p>I would like to extend this experiment to index the word occurrence counts and proceeding word counts in original text and rank the found words as most likely. Support casing, umlauts, special characters, spelling correction and compound words in the indexing and lookup. I think a live lookup while swiping would also be possible.</p> <p>Overall this was fun, turned out OK I think, and was a great learning experience.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p>&nbsp;</p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p>&nbsp;</p> </div> <script src="swipe.js" type="text/javascript"></script> </body> </html>

Permalink

Clojurists Together Update: September and October 2026

Time for another bi-monthly update on the work that Clojurists Together are funding this year - my maintenance of nREPL, CIDER and friends. You can find the previous one here.

For a change, most of my focus in September and October was on nREPL itself, not on CIDER. The big themes were the nREPL protocol spec and how well the servers and clients out there actually stick to it. For years building an nREPL server or client that plays well with the rest of the ecosystem came down to trial and error, and this time I finally did something about it. It paid off much sooner than I expected.

The big highlights from my perspective:

  • proof, a compatibility suite for nREPL servers and clients, is out, and it’s already found (and helped fix) problems in nREPL itself, Babashka, CIDER, Calva, Conjure and many other servers and clients
  • neorepl, a brand new terminal REPL meant to be something like “CIDER in a terminal” and “CIDER for agents”, went from 0.1 to 0.4 in two weeks
  • nREPL 1.8 is out, and master already has fixes for some of the bugs proof found in it
  • Piggieback 0.7.1 and 0.8.0 fixed a few long-standing problems with ClojureScript REPLs
  • nrepl.org became a proper landing page (with an nREPL server running right in your browser), the manual moved to docs.nrepl.org, and CIDER got a landing page of its own at cider.mx
  • CIDER’s master gained streaming test results and commands for editing the ns form, backed by new cider-nrepl and Orchard releases

Below you’ll find the details, project by project.

proof and the nREPL spec

proof is the big new project this time around, and the one I’m most excited about. I’ve been talking about some automated way to check nREPL compatibility for ages (it made my list of things people hate about nREPL back in 2023), so it feels really great to finally see it become a reality. Good things take a while to happen, they said… Does your server work with CIDER? Until now the only way to find out was to implement the protocol, connect CIDER (or Calva, or whatever your users run) and see what breaks. Now you can point proof at your server and it’ll tell you. It’s a single binary that runs about 80 checks against a server. A check fails only when a real client would break (with a link to the client code that depends on the behavior), and everything else is just a warning. proof also comes with client profiles that send exactly what CIDER, Calva, Conjure, vim-fireplace, REPLy and neorepl send, profiles for the servers I know of, and a GitHub Action for running it in a server’s CI.

Once the server side was in place, I started on the clients as well. proof proxy sits between a client and a server and checks what the client sends, and proof serve is a small server for the test suites of clients, which can mimic the quirks of nREPL, Babashka, jank and others (proof serve -like jank). I wrote about proof in a lot more detail in proof: How Strong Is Your nREPL?.

proof goes hand in hand with the nREPL spec, the draft Phil Hagelberg wrote earlier this year. When the spec and the existing clients disagree, proof goes with the clients (a spec that breaks CIDER isn’t much use to anyone), and every such conflict, along with the gaps proof ran into, ends up on a list of spec changes. The first few of them are already PRs against the spec: how replies relate to their requests, the status values, how interrupts are answered and the fields of lookup replies.

What I like the most about proof is how quickly it started paying off:

  • nREPL itself sent the done of an eval interrupted from another connection to the wrong connection (fixed on master), drops the evals waiting behind an interrupted one (#503) and can’t interrupt an eval that hasn’t started yet (#504). Not great!
  • Between proof and neorepl, Babashka got a couple of dozen issue reports from me in October, from test ops that never answered to a session that stopped answering after its client went away in the middle of an eval. Michiel Borkent fixed nearly all of them the same day or the next. You rock, Michiel!
  • CIDER didn’t finish setting up connections to servers without cider-nrepl (fixed), Calva and Conjure didn’t sort the keys of the dictionaries they sent (both fixed within days), vim-fireplace and REPLy never close their sessions, and REPLy’s and Conjure’s interrupts don’t say which eval to stop.
  • Basilisp and jank got a few reports as well (lookup replies, and a request that throws taking its whole connection down).
  • My own toy implementations (nrepl-beam, mezcaml and neat) were the first projects I ran proof against (more on them below).

I was also super happy to see other people using proof already. Tom Waddington has been running it against his nREPL implementations for Janet and Steel Scheme since early October (nrepl-janet even tests its client with proof serve), Jeaye Wilkerson suggested using it for jank’s nREPL server, and Kyle Cesare is already fixing what it found there. That’s exactly what I was hoping for!

neorepl

neorepl is the other brand new project, and it grew up together with proof. It’s a terminal REPL for Clojure (and anything else that runs an nREPL server), and the best way I can describe it is “CIDER in a terminal”: it uses cider-nrepl when it’s around, the same way CIDER does, and falls back to nREPL’s built-in ops when it isn’t. Every lookup the REPL can do is also a command with structured output, and there’s an MCP server and a skill for coding agents, so it’s “CIDER for agents” as well. Five releases in three weeks:

  • neorepl 0.1.0 had the REPL with a proper line editor (highlighting, completion, arglists as you type, history per project, a Ctrl-C that actually interrupts), CIDER-style REPL commands, jack-in for Leiningen, the Clojure CLI, Babashka and shadow-cljs, and commands for evaluating code and looking things up that keep their session between calls. It runs on the JVM and on Babashka.
  • neorepl 0.2.0 added a test runner (Clojure and ClojureScript), a full-screen inspector and a stacktrace browser.
  • neorepl 0.3.0 was all about coding agents: neorepl mcp, a skill that teaches agents to check, load, test and debug Clojure code with the CLI, and neorepl check --fix, which fixes mismatched delimiters the way parinfer does.
  • neorepl 0.4.0 brought tracing, switching the REPL into another named session (e.g. to take a look at what your coding agent is doing), and a huge list of fixes. I wrote about it (and the two releases before it) in neorepl 0.4: Tests, Inspector, Tracing and Agents.
  • neorepl 0.4.1 caught up with cider-nrepl 0.64.

proof checks every server with the exact requests neorepl sends, and neorepl went through proof proxy and every scenario of proof serve before 0.1 was out. Its commands are separate processes sharing a session, so neorepl hits nREPL’s interrupt bugs harder than most clients, and making it work as well on Babashka’s server as with cider-nrepl is where most of the Babashka reports came from. Even JLine got a few bug reports along the way.

By the time I got around to writing this update, neorepl 0.5 was out as well, with a much smarter line editor, a namespace browser, automatic reloading and a CLI that editors can use without a plugin. I wrote about it in neorepl 0.5: CIDER in a Terminal.

nREPL

nREPL 1.8.0 came out on October 2 with the URL-based connections and the TLS improvements I mentioned last time, plus a few session fixes. Sessions survive evaluated code closing *in* or *out*, a race that could split responses between concurrent reads in nrepl.core/client is gone, and Sashko made persistent sessions re-create their thread when a faulty exec thunk kills it. He also moved the Unix sockets to the SocketChannel API.

Master moved on quickly after the release, much of it because of proof and neorepl:

  • the done of an eval interrupted from another connection goes to the eval’s own connection, where the client that sent the eval never got it
  • the server sets TCP_NODELAY on accepted connections, so on Linux an eval’s done no longer waits up to 40ms behind its value
  • Sashko replaced :out-limit with separate :out-quota and :err-quota limits, made the bencode transport turn empty lists into nil for ?-ending keys and made building the middleware stack faster

The interrupt bugs that are still open are next on my list.

Piggieback

  • Piggieback 0.7.1 fixed load-file failing with “No such namespace” when the file requires a foreign lib (e.g. a cljsjs package).
  • Piggieback 0.8.0 fixed :cljs/quit on nREPL 1.3+, where the session stayed in ClojureScript mode, and made evaluation use the same dynamic bindings as cljs.repl. That means the cljs-repl options (e.g. :warnings) and the bindings tools like figwheel-main set up around it finally take effect, which closes an issue from 2018. It also picked up :as-alias keywords and the :cljs branch of data_readers.cljc.

nrepl.org and cider.mx

nrepl.org got a facelift at the end of October. The manual moved to docs.nrepl.org, and nrepl.org itself is now a landing page that explains what nREPL is and how its protocol works, with real exchanges between a client and a server next to every feature. It even has a “Try it” section with an nREPL server running right in your browser, which turned out to be a story of its own. That playground is how nrepl-sci, a small nREPL server for SCI that runs entirely in a web page, came to be, and it even led me to take a closer look at clojure-mode for CodeMirror, which powers the playground’s editor.

CIDER got a landing page of its own at cider.mx, made to look like an Emacs frame (mode line, M-x and all), with mini screencasts of CIDER in action. I had a lot of fun working on both sites!

neat, mezcaml and nREPL on the BEAM

My toy nREPL implementations were the first things I ran proof against, and all of them got a round of fixes in early October:

  • dialtone and repartee, the Erlang and Elixir servers in nrepl-beam, now answer close with session-closed, echo the op in unknown-op replies and return a value for every Erlang form in an eval
  • mezcaml no longer lets late output from one eval end the next one early, interrupts on Ctrl-C, answers stdin requests, closes its session on exit and completes on jank and Basilisp
  • neat shows late and id-less output, reports error statuses that come without err, handles unknown sessions and stdin EOF, and checks describe before using completions, lookup and interrupt

neat’s own integration suite now tests just the client and leaves the servers to proof. None of the three has had a release yet, though.

CIDER

No CIDER release this time (2.1 is still brewing), but master kept moving:

  • Test results now stream in while the tests run (cider-test-stream-results, on by default): the echo area keeps a running count, and failing tests land in the report as soon as they finish. This closes an issue from 2014! cider-test-fail-fast is now off by default to go with it.
  • A family of commands for editing the ns form: cider-ns-add-require, cider-ns-add-import, cider-ns-add-missing (adds what the symbol at point needs), magic requires (typing alias/ adds the require the alias needs) and cider-ns-clean. These are some of clj-refactor’s most popular features, and they’re backed by new cider-nrepl ops that read your code instead of loading it.
  • Connecting to a server without cider-nrepl (e.g. plain nREPL or Babashka) finishes setting up the connection again, where cider-connected-hook and friends never ran. proof found this one.
  • Keys with a nil value are left out of nREPL requests instead of being sent as empty lists, which closes an issue from 2020.
  • Natively compiled builds no longer have broken menu options, Emacs 31 is on CI, and the manual got per-build-tool setup tabs, the refcard and CI checks for its cross references.

cider-nrepl and Orchard

cider-nrepl releases:

  • cider-nrepl 0.63.0 runs tests with orchard.test and streams their progress to the client, which is what CIDER’s test streaming builds on.
  • cider-nrepl 0.63.1 was Sashko’s quick bump to compliment 0.8.2.
  • cider-nrepl 0.64.0 added the cider/resolve-missing, cider/resolve-alias and cider/clean-ns ops behind CIDER’s ns commands (after three alphas), made the test ops always answer and send what tests print with their own responses on nREPL 1.3+, and fixed the inspector and ClojureScript tests on shadow-cljs.

Orchard releases:

  • Orchard 0.45.0 added orchard.test, the clojure.test runner that used to live in cider-nrepl and can now report progress as it goes, plus Sashko’s string analytics in the inspector and his fix for the stack overflows when printing recursive collections.
  • Orchard 0.46.0 and Orchard 0.47.0 added orchard.ns-form, which takes apart an ns form and tells what it requires, refers and imports that the code doesn’t use, all without loading anything, and fixed some Java doc rendering problems.
  • Orchard 0.48.0 made tracing show calls that throw and stopped tracing a function tracing itself uses (say, str) from overflowing the stack.

Sashko also added JDK 27 to the CI of nREPL, cider-nrepl and Orchard. Thanks, Sashko!

refactor-nrepl and clj-refactor

refactor-nrepl 3.15.0 documented the limitations of its AST-based analysis and deprecated the ops cider-nrepl has taken over. Back in the spring I said I’d like to move the most useful refactor-nrepl features to CIDER and cider-nrepl eventually, and the ns editing commands above are the first step in that direction.

clojure-ts-mode

No release, but quite a few fixes on main. I fixed clojure-ts-mode on Emacs 31 (e.g. the ClojureScript mode asking for the jsdoc grammar, M-q indenting only the innermost form) and a pile of refactoring commands that went wrong around metadata, #_ forms and reader conditionals. Jason Jackson sent a round of indentation and alignment fixes (threading macros, reader prefixes, metadata and comments before arguments), and Kyle Cesare kept up with jank’s syntax. Thanks, Jason and Kyle!

MrAnderson and smaller things

MrAnderson 0.7.2 stopped bundling pomegranate’s Maven classes in its jar, where they clashed with any other resolver on the classpath and broke cider-nrepl’s build once tools.build pulled in a newer tools.deps. The artifact went from 8 MB to about 200 KB in the process.

There was also a lot of release and CI maintenance across nREPL, cider-nrepl, Orchard, Piggieback and friends (e.g. release notes taken from the changelogs, the docs of a release published by its release workflow), and I revived the CI of parseclj and parseedn.

Blog posts

Epilogue

Big thanks to Clojurists Together, Nubank and the other organizations and people supporting my Clojure OSS work! None of this would have happened without you. You rock!

As for what’s next - last time I said CIDER 2.1 was the obvious next milestone. It still is, but it got delayed, as most of my time went into nREPL instead. I told you I’m bad at predictions! nREPL 1.8 shipped as planned, and mezcaml and the BEAM servers got a lot more reliable thanks to proof, even if neither got a release. While we’ve made solid progress on CIDER and friends as well, my main focus until the end of the year will likely be the broader nREPL ecosystem: fixing the interrupt bugs proof found in nREPL, turning the list of spec changes into PRs against the spec, and getting more servers and clients to run proof.

Keep hacking!

Permalink

Java: When Life Gives You Problems, Build a ProblemFactory

We've already explored PHP's questionable decisions, JavaScript's complicated relationship with logic, Python's ability to produce confidently incorrect results, and C#'s enterprise-grade surprises.

Now it's Java's turn.

Java is different.

Java is serious.

Java is strongly typed, object-oriented, carefully specified, and famously obsessed with backward compatibility.

This is a language that makes you declare what things are before you're allowed to do anything with them.

Surely, then, Java should be relatively free of surprises?

Well.

Let's talk about that.

1. String Concatenation: Apparently, + Needed a Performance Review

Let's start with something that every Java developer has written thousands of times.

String message = "Hello, " + name + "!";

Perfectly reasonable.

Now consider:

System.out.println("Total: " + 1 + 2);

Output:

Total: 12

But:

System.out.println(1 + 2 + " total");

Output:

3 total

Same numbers. Same operator. Different arithmetic.

Because Java evaluates these expressions from left to right, and once a string enters the expression, + becomes string concatenation.

Fine. We can live with that.

But Java has another surprise waiting.

Meet StringBuilder

Java strings are immutable.

Once created, a String cannot be modified.

So what happens when you repeatedly append characters?

String result = "";

for (int i = 0; i < 10000; i++) {
    result += i;
}

Conceptually, each iteration creates a new string containing the accumulated result plus the new value.

As the string grows, increasingly large amounts of data may need to be deal with by the jvm.

That's potentially a lot of unnecessary work.

Java developers have traditionally solved this problem with StringBuilder.

StringBuilder result = new StringBuilder();

for (int i = 0; i < 10000; i++) {
    result.append(i);
}

String finalResult = result.toString();

One mutable buffer, rather than repeatedly rebuilding an immutable string.

Much better for this kind of workload.

For years, the conventional advice was simple:

Use + for simple concatenation. Use StringBuilder when you're building strings in loops.

But Java being Java, it couldn't leave things quite that straightforward.

Plot Twist: The Compiler Was Already Helping

Before Java 9, the Java compiler typically translated ordinary string concatenation expressions into StringBuilder operations behind the scenes.

So this:

String message = "Hello, " + name + "!";

Wasn't necessarily doing the naive string-building work you might imagine.

Then Java 9 introduced a new concatenation mechanism using invokedynamic.

This allowed the runtime to select and optimize string-concatenation strategies more flexibly, without requiring the compiler to generate an explicit StringBuilder sequence.

So now we've got:

  • Strings that can't be changed.
  • A mutable class specifically for building strings.
  • A compiler that historically generated that class automatically.
  • A newer mechanism that can optimize concatenation differently.
  • Performance characteristics that depend on the expression, loop structure, compiler, and runtime.

Congratulations. We've turned joining two strings into a discussion about bytecode generation.

To be fair, modern Java handles ordinary + concatenation quite well.

The real performance concern remains repeated accumulation into a growing string, particularly in loops. An explicit StringBuilder is still a sensible choice there.

The lesson: Java's simple syntax can hide considerable compiler and runtime sophistication. And performance advice from 2005 doesn't always survive contact with modern JVMs.

2. Two Integers Are Equal. Until They Aren't.

Here's one of Java's most entertaining party tricks.

Integer a = 127;
Integer b = 127;

System.out.println(a == b);

Output:

true

Okay.

Now:

Integer a = 128;
Integer b = 128;

System.out.println(a == b);

On a typical JVM configuration:

false

Wait. What?

Two integers containing the same number can compare equal or unequal depending on the number.

The trick is that Integer isn't the same thing as int.

int is a primitive value.

Integer is an object wrapper around an integer value.

And for objects, == checks reference identity, not numerical equality.

But Java caches certain Integer objects.

Specifically, Integer.valueOf() is guaranteed to cache values from -128 through 127.

Autoboxing uses this mechanism.

So when you write:

Integer a = 127;
Integer b = 127;

Both variables normally refer to the same cached object.

But outside the guaranteed range, Java is allowed to create separate objects.

Hence the surprising result for 128.

There's one more complication: implementations are permitted to cache additional values, so 128 isn't guaranteed to produce false everywhere.

Which makes relying on this behavior an even worse idea.

The correct comparison:

System.out.println(a.equals(b));

Or compare the underlying primitive values when appropriate.

The lesson: In Java, == can mean numerical equality or object identity depending on the types involved. And integer caching makes that distinction particularly easy to overlook.

3. Arrays.asList() Gives You a List. Terms and Conditions Apply.

Here's a perfectly ordinary bit of Java:

String[] names = {"Alice", "Bob", "Charlie"};

List<String> list = Arrays.asList(names);

You now have a list.

And lists are mutable collections, right?

Let's add someone.

list.add("Dave");

Result:

java.lang.UnsupportedOperationException

Excuse me?

We created a list.

We tried to add an item.

And Java objected.

Here's the explanation.

Arrays.asList() returns a fixed-size list backed by the original array.

It isn't a regular, independently resizable ArrayList.

You can replace existing elements:

list.set(0, "Zoe");

That works.

But you can't change the list's size.

No adding.

No removing.

And here's the really interesting part:

System.out.println(names[0]);

Output:

Zoe

Changing the list changed the original array!

Because they're backed by the same storage.

The reverse is true too. Change an element in the array, and the list reflects that change.

So Arrays.asList() gives you something that:

  • Looks like a normal list.
  • Lets you replace elements.
  • Refuses to grow or shrink.
  • Shares its underlying data with the original array.

Java also offers List.of(), which creates an unmodifiable list.

And new ArrayList<>(...), which creates a separate, resizable list.

Three ways to obtain something called a list, with three different sets of rules.

The fix when you want an independent, resizable list:

List<String> list =
    new ArrayList<>(Arrays.asList(names));

The lesson: In Java, knowing that something implements List doesn't tell you everything about what operations it supports.

Sometimes the interface makes a promise that the implementation politely declines to fulfill.

4. Removing an Integer Can Remove the Wrong Integer

This one is genuinely nasty.

Suppose you have a list of numbers:

List<Integer> numbers =
    new ArrayList<>(List.of(1, 20, 55));

Now you want to remove the number 1.

numbers.remove(1);

What happens?

The list becomes:

[1, 55]

You can kind of guess this one.

Our friend List has two overloaded remove() methods.

One removes an element by index.

The other removes an element by value.

Conceptually:

remove(int index)
remove(Object value)

And when you pass the literal integer 1, Java selects the overload accepting a primitive int.

So:

numbers.remove(1);

Means:

"Remove the element at index 1."

Not:

"Remove the integer whose value is 1."

If you actually want to remove the value 1:

numbers.remove(Integer.valueOf(1));

Now Java selects the object-based overload.

Same method name.

Same apparent kind of argument.

Completely different operation.

And because both operations are perfectly valid, the compiler has absolutely nothing to complain about.

The lesson: Method overloading, autoboxing, and collection APIs can combine into a surprisingly effective footgun.

Sometimes Java's type system is so helpful it confidently does the wrong thing you explicitly asked it to do.

5. Two Decimal Numbers Can Be Equal but Not Equal

For our final example, let's involve money.

Java provides BigDecimal for decimal arithmetic where precision matters.

Unlike ordinary binary floating-point numbers, BigDecimal can represent decimal values exactly.

Very useful for financial applications.

Now consider:

BigDecimal a = new BigDecimal("2.0");
BigDecimal b = new BigDecimal("2.00");

System.out.println(a.equals(b));

Output:

false

Seriously?

Two dollars is not equal to two dollars?

Now try:

System.out.println(a.compareTo(b) == 0);

Output:

true

So the numbers compare as equal.

But equals() says they're different.

Why?

Because BigDecimal stores both an unscaled numerical value and a scale.

2.0 and 2.00 represent the same numerical value, but they have different scales.

And BigDecimal.equals() considers scale part of equality.

compareTo() compares their numerical values instead.

That's actually defensible. Scale can matter for certain decimal calculations.

But it creates a wonderfully awkward consequence.

Consider:

Set<BigDecimal> hashSet = new HashSet<>();

hashSet.add(new BigDecimal("2.0"));
hashSet.add(new BigDecimal("2.00"));

System.out.println(hashSet.size());

Output:

2

Now:

Set<BigDecimal> treeSet = new TreeSet<>();

treeSet.add(new BigDecimal("2.0"));
treeSet.add(new BigDecimal("2.00"));

System.out.println(treeSet.size());

Output:

1

Two sets. Same values. Different answers about how many distinct elements exist.

Why?

HashSet uses equals() and hashCode() to determine uniqueness.

TreeSet uses ordering comparisons, which for BigDecimal normally means compareTo().

And BigDecimal explicitly defines those operations differently.

So Java gives you two completely legitimate definitions of uniqueness for the same numerical values.

Imagine discovering that while reconciling financial records.

The lesson: Equality, numerical comparison, and collection uniqueness aren't necessarily interchangeable. With BigDecimal, choosing the wrong one can produce very real business problems.

Java Is Predictable. You Just Need to Predict the Right Things.

Here's what makes Java interesting.

Unlike JavaScript, Java generally doesn't let values wander casually between unrelated types.

Unlike PHP, it doesn't enthusiastically reinterpret everything based on context.

And unlike Python, it tends to insist that you explain exactly what you're trying to do.

Java is explicit.

Java is disciplined.

Java is designed to make large programs easier to reason about.

And yet...

An integer can be identical to another integer until you increase it by one.

A list can refuse to let you add anything.

Removing a number can remove a completely different number.

Two decimal values can be equal according to one method and unequal according to another.

And joining strings can involve compiler transformations, runtime call sites, and an optimization discussion that lasts longer than the code itself.

None of this is random.

There are carefully documented reasons for every behavior.

But that doesn't make the surprises any less entertaining.

In fact, it makes them more interesting.

Java is a language where even the weirdness comes with a specification.

But Wait. Which Java Are We Talking About?

There's one more twist.

We've spent this article discussing Java, the programming language.

But Java is also commonly used to describe an entire development platform.

And that platform has a rather complicated family tree.

For starters, there's the JVM: the Java Virtual Machine.

The thing that executes Java bytecode.

Except there isn't just one implementation of the JVM.

There's HotSpot, the familiar JVM used by most OpenJDK distributions.

There's Eclipse OpenJ9, a separate JVM implementation with different approaches to memory management, startup performance, and garbage collection.

Then there's GraalVM, which builds on HotSpot while offering advanced compilation technology and Native Image, a way to compile Java applications into native executables that don't require a JVM at runtime.

And that's before we get into the different JDK distributions.

Oracle JDK.

Eclipse Temurin.

Amazon Corretto.

Azul Zulu.

Microsoft's Build of OpenJDK.

Those are distributions rather than entirely separate JVM implementations, but their packaging, support, licensing, and release policies can differ.

And then there's the small matter of the languages.

Because the Java Virtual Machine doesn't actually require you to write Java.

You can use Kotlin.

Scala.

Clojure.

And plenty of others.

So now we have one language, multiple JVM implementations, multiple JDK distributions, multiple languages targeting the JVM, and an alternative compilation model that can eliminate the need for a running JVM altogether.

Remember Java's original promise?

Write once, run anywhere.

Turns out the more interesting question is:

Run where, on which JVM, from which vendor, using which language?

And that's a rabbit hole worthy of its own article.

Coming next: the Java ecosystem, where the language is only the beginning of the confusion.

Permalink

Proving What AI Generates: Running Formally Verified Lean 4 Inside Clojure Services

TL;DR:

  • Unit tests only check sample points; formal verification in Lean 4 mathematically guarantees logic for all inputs.
  • The new Ansatz Lean/JVM SDK (org.replikativ/ansatz-lean-jvm:0.2.615) lets you compile Lean into an intermediate artifact (.leanjvm) and interpret it directly on standard OpenJDK 17+ with zero native C/JNI dependencies.
  • This article demonstrates how an AI coding agent can write financial ledger invariants, self-correct via Lean compiler proofs, and deploy proved code into a Clojure service.

Credit & Attribution: This workflow builds on the breakthrough work by the Simmis team introducing the Ansatz Lean/JVM SDK (org.replikativ/ansatz-lean-jvm). The financial settlement walkthrough below illustrates these mechanics applied to autonomous agent engineering.

The LLM Reliability Wall in Production

Every engineering team deploying autonomous AI coding agents eventually runs into the LLM Reliability Wall.

An agent prompted to generate an account transfer or settlement routine writes clean code in seconds. It even produces dozens of passing unit tests. But unit tests only sample finite scenarios. In financial ledgers, the bug that triggers insolvency is never the one tested by a handful of mock transactions—it lurks in the untested corner case where an overdraft limit, a currency conversion edge, and a zero-sum constraint collide.

For decades, formal methods offered the mathematical antidote: prove that code satisfies invariants for all possible inputs using proof assistants like Lean 4. But Lean code was traditionally an island:

  • Native binaries required brittle JNI or Panama bridges.
  • Memory models clashed: Lean frees memory via reference counting, while the JVM uses a garbage collector. Cycles across the boundary leak memory.
  • Deployed servers required the full native Lean toolchain installed.

The release of the Ansatz Lean/JVM SDK (org.replikativ/ansatz-lean-jvm:0.2.615) changes this equation. It provides a pure JVM intermediate-representation (IR) interpreter that runs proved Lean functions directly on stock OpenJDK 17+.

This unlocks an autonomous ground-truth loop: agents synthesize formally verified invariants in Lean, prove them against the Lean compiler, and deploy them directly into production Clojure and Java microservices.

The Architecture: Build-Time Proofs, Run-Time JVM

┌────────────────────────────────────────────────────────┐
│                   BUILD & PROOF STAGE                  │
│               (Linux x86-64 / CI / Docker)             │
│                                                        │
│  AI Agent writes:                                      │
│  • Settlement logic                                    │
│  • Invariant theorems (Conservation & Solvency)        │
│                                                        │
│                     ▼                                  │
│         Lean 4.33.1 Toolchain Check                    │
│    [ Fails: Agent self-corrects proof ]                │
│    [ Passes: Proof erased, IR compiled ]               │
│                     ▼                                  │
│            ledger.leanjvm Artifact                     │
│          + SHA-256 Manifest Fingerprint                │
└────────────────────────┬───────────────────────────────┘
                         │
                         ▼
┌────────────────────────────────────────────────────────┐
│                   PRODUCTION STAGE                     │
│              (Any OpenJDK 17+ Environment)             │
│                                                        │
│  Clojure / JVM Microservice                            │
│  • Loads ledger.leanjvm into pure Java Interpreter     │
│  • Zero native C / JNI / platform dependencies         │
│  • Managed 100% by JVM Garbage Collector               │
│  • Enforces instruction budgets (default: 2M ops)      │
│  • Exposes bidirectional host callbacks (IO)           │
└────────────────────────────────────────────────────────┘

The division of labor is clean:

  1. At build time, native Lean compiles the source, checks the proofs, and strips them away. The compiler packages the intermediate code, types, and layouts into a portable .leanjvm artifact.
  2. At run time, standard Clojure services interpret the intermediate representation. The production container needs no Lean toolchain.

The Domain: A Conservation-of-Value Financial Settlement

Consider an account settlement function. The business requires two non-negotiable invariants:

  1. Solvency: No account may be debited below its configured floor (zero).
  2. Conservation of Value: The total amount debited must equal the total amount credited. No money appears or vanishes.

Let's see how an autonomous agent interacts with this system.

1. The Agent Self-Correction Loop

When an AI agent is instructed to write settlement logic, it often generates subtle bugs. Here is what happens when Lean's type checker acts as the agent's supervisor.

Round 1: The Agent Hallucinates an Edge Case

The agent drafts an initial version in Ledger.lean:

namespace Ledger

def transfer (fromBal toBal amount : Nat) : (Nat × Nat) :=
  -- Naive transfer: subtracts without checking if balance < amount
  (fromBal - amount, toBal + amount)

theorem transfer_conserves_value (fromBal toBal amount : Nat) :
  let (f, t) := transfer fromBal toBal amount
  f + t = fromBal + toBal := by
  unfold transfer
  -- Proof attempt fails when amount > fromBal due to Nat underflow truncation!

In Lean, natural numbers (Nat) truncate subtraction at zero (3 - 5 = 0). Because of this, fromBal - amount breaks conservation of value whenever amount > fromBal!

The Lean compiler rejects the code:

error: unsolved goals
case h
fromBal toBal amount : Nat
⊢ (fromBal - amount) + (toBal + amount) = fromBal + toBal

The agent cannot hallucinate or hand-wave this failure away. The build stops cold.

Round 2: Self-Correction with Machine-Checked Solvency

The agent reads the compiler error, realizes Nat subtraction truncates at zero, and refines the logic to guard the transfer:

namespace Ledger

def settleTransfer (fromBal toBal amount : Nat) : (Nat × Nat) :=
  if amount ≤ fromBal then
    (fromBal - amount, toBal + amount)
  else
    (fromBal, toBal) -- Rejected transfer preserves balances unchanged

/-- Theorem: Transfers strictly conserve total capital. -/
theorem settle_conserves_value (fromBal toBal amount : Nat) :
  let (f, t) := settleTransfer fromBal toBal amount
  f + t = fromBal + toBal := by
  unfold settleTransfer
  split
  · rename_i h
    omega
  · rfl

/-- Theorem: The sender account never drops below zero (solvency guarantee). -/
theorem settle_preserves_solvency (fromBal toBal amount : Nat) :
  let (f, _) := settleTransfer fromBal toBal amount
  f ≤ fromBal := by
  unfold settleTransfer
  split
  · rename_i h
    omega
  · rfl

end Ledger

Both proofs pass. Lean guarantees that no transaction can ever violate solvency or fabricate currency, across all infinite combinations of Nat.

2. Compiling the Verified Artifact

During the CI build (currently supported on Linux x86-64 with prlimit, or via Docker), the agent triggers artifact preparation:

java -cp "$SDK_CLASSPATH" ansatz.lean.jvm.LeanPreparation \
  --source ./ledger \
  --sysroot "$LEAN_SYSROOT" \
  --producer-bundle "$PRODUCER_BUNDLE" \
  --cache "$COMPILER_CACHE" \
  --root-module Ledger \
  --entry Ledger.settleTransfer \
  --output ./ledger.leanjvm

This generates ledger.leanjvm and prints its SHA-256 fingerprint:
sha256:d8a2...3f10

3. Running Inside Clojure at the REPL

In the production service, add the SDK dependency to deps.edn:

{:deps {org.replikativ/ansatz-lean-jvm {:mvn/version "0.2.615"}}}

Load the artifact and invoke the formally proved function directly from the Clojure REPL:

(ns ledger.core
  (:require [ansatz.lean.jvm :as lean]
            [clojure.java.io :as io]))

(defn run-settlement []
  (with-open [runtime (lean/open)
              input   (io/input-stream "ledger.leanjvm")
              project (lean/load-artifact runtime input (System/getenv "EXPECTED_MANIFEST_SHA256"))]
    (let [settle (lean/function project "Ledger.settleTransfer")]
      ;; 1. Valid transfer: $500 from Acc1 ($10,000) to Acc2 ($1,000)
      ;; 2. Overdraft attempt: $15,000 from Acc1 ($10,000) to Acc2 ($1,000)
      {:valid-transfer     (settle 10000 1000 500)
       :overdraft-rejected (settle 10000 1000 15000)})))

;; (run-settlement)
;; => {:valid-transfer     [9500N 1500N]
;;     :overdraft-rejected [10000N 1000N]}

Key runtime observations:

  • Clojure passes standard numbers and receives back persistent vectors containing java.math.BigInteger instances.
  • Zero JNI code or platform native bridges.
  • Memory management is 100% handled by the JVM garbage collector.

4. Inversion of Control: Lean Calling Clojure

Real business operations require dynamic runtime data—like querying fee tiers from a database or configuration file.

Lean declares the external callback:

namespace Ledger

@[extern "ansatz_jvm_account_fee"]
opaque getAccountFee : String → IO Nat

def settleWithFee (fromBal toBal amount : Nat) (tier : String) : IO (Nat × Nat) := do
  let feeRate ← getAccountFee tier
  let fee := (amount * feeRate) / 1000
  let totalDebit := amount + fee
  if totalDebit ≤ fromBal then
    pure (fromBal - totalDebit, toBal + amount)
  else
    pure (fromBal, toBal)

end Ledger

In Clojure, supply the implementation under the declaration's name when loading the artifact:

(def host-bindings
  {"Ledger.getAccountFee"
   {:implementation-id "fees/v1"
    :effect :io
    :fn (fn [tier]
          (case tier
            "vip"      0N     ;; Zero fee
            "standard" 15N    ;; 1.5% fee
            50N))}})          ;; Default 5.0%

(with-open [runtime (lean/open)
              input   (io/input-stream "ledger.leanjvm")
              project (lean/load-artifact runtime input 
                                          (System/getenv "EXPECTED_MANIFEST_SHA256")
                                          host-bindings)]
  ;; IO functions return an IOResult object carrying values or errors
  (let [result (lean/call project "Ledger.settleWithFee" 10000 1000 2000 "standard")]
    (if (.isOk result)
      (.getValue result)
      (throw (ex-info "Lean IO Error" {:cause (.getError result)})))))
;; => [7970N 3000N]  ; Total debit: 2000 + 30 fee = 2030

Mathematical proofs that parameterize over host inputs remain sound, even when values are resolved dynamically by host Clojure code.

5. Architectural Differentiator: Forkable Execution State

Why run Lean through a pure JVM interpreter instead of compiling hot machine code?

The Simmis team highlighted an architectural breakthrough: execution state forkability.

Because Lean's entire execution state (call frames, heap objects, and registers) is stored as standard JVM heap objects, the engine can clone that state mid-computation. Pausing an engine allows branching it into two independent engines that proceed down separate computational paths.

Note: While the core Ansatz engine implements state forking internally today, the public SDK does not expose fork handles yet. It represents groundwork for upcoming releases.

For future AI reasoning pipelines, this capability enables:

  • Counterfactual Branching: An agent pauses a complex financial settlement, branches the execution into two what-if paths (e.g., assessing different liquidity routing strategies), and compares the outcomes.
  • Rollback without Transaction Resets: Discarding a failed branch requires only dropping the cloned JVM object, with zero external database rollback needed.

6. Safety & Operational Guardrails

When executing autonomous agent code in production, defensive constraints are mandatory:

Guardrail Enforcement Mechanism
Infinite Recursion / DOS Invocations run under strict instruction budgets (default: 2,000,000 steps) and wall-clock timeouts (default: 5.0s).
Tampered / Stale Artifacts Artifact loading strictly validates the build-time SHA-256 manifest hash before execution.
Error Isolation Out-of-budget calls fail cleanly with ProjectFailed. The service restarts the engine via project.restart(), discarding corrupted state while preserving verified code handles.
Language Constraints The current release enforces closed, first-order signatures across host boundaries and caps project modules at 128 (beyond Lean's Init).

Conclusion

The conversation around AI coding agents has focused primarily on generating code and generating unit tests. But in high-consequence domains like finance, tests cannot establish absolute correctness.

By uniting Lean 4's proof assistant with the JVM's operational maturity, tools like Ansatz establish a practical verification bridge:

  1. Agents specify and prove invariants rather than guessing implementations.
  2. The compiler enforces ground truth, rejecting flawed edge cases with actionable counter-examples.
  3. The runtime executes natively on standard JVMs, eliminating native operational overhead.

The future of autonomous systems isn't just agents that code faster—it's agents that prove their work before hitting production.

Further Reading

Permalink

proof: How Strong Is Your nREPL?

For as long as I’ve been involved with nREPL, the question I’ve heard most often from people building servers and clients has been some variation of “Will this work with CIDER?”. And for just as long the honest answer was “Try it and see”. Today I have a better answer for you - proof, a compatibility suite for nREPL servers (and clients). I’ve been working on it since the end of September and proof 0.2 came out a couple of days ago, so I guess it’s high time I told you a bit about it.

Try It and See

The nREPL protocol is tiny. A handful of ops, requests and responses that are simple maps, bencode on the wire. You can write a basic nREPL server in a couple of hours, and lots of people have done exactly that. Making it play nice with CIDER, Calva, Conjure and the rest of the clients out there is a very different matter, though. What clients actually expect from a server is spread across the reference implementation, a draft spec, many years of client code and lots of GitHub issues. Which status values mean what? Is ops in describe a map or a list? Where does the end of an interrupted evaluation go? What happens to a session when its client goes away in the middle of an eval? Server authors usually find out one bug report at a time.

Client authors don’t have it any easier, as every server has its own quirks, and for many years the only way to find them was to try your client with every server you could get your hands on. I’ve been on both sides of this conversation for well over a decade (CIDER is a client, nREPL is a server), so believe me - I know the pain.

Bigger Than Clojure

Why bother? Because I’ve always seen nREPL as something bigger than Clojure. Back in 2019 I wrote that nREPL is similar in nature to LSP, only much simpler, and I still believe that. For any language that can evaluate code at runtime, nREPL is a much simpler alternative to LSP. Implement eval, interrupt and load-file, throw in completions and lookups if you feel like it, and every nREPL client out there becomes an editor integration for your language.

That’s not just a dream anymore. These days there are nREPL servers for Clojure dialects running on Python (Basilisp), C++ (jank), .NET (ClojureCLR), Go (let-go) and Node.js (nbb), for Janet and Steel Scheme, and even for Erlang and Elixir (more on those later). Still, “language-agnostic” doesn’t mean much if everyone who builds a server has to check it against CIDER by hand.

Better Late Than Never

The idea of a compatibility suite is not new. When nREPL 1.0 came out in 2022 I wrote that improving the protocol spec and the ability to test against it was where our focus should go next, and a year later the lack of a compatibility test suite made my list of things people hate about nREPL. I even asked if one of you was looking for a fun small OSS project. Nobody volunteered, and the idea spent a few more years on the backburner. This year, thanks to the support of Clojurists Together, I finally made a very focused push on it.

It started with the spec. Late last year Phil Hagelberg wrote a draft of the nREPL protocol spec, which landed in May. A spec is great, but you can’t run a spec against your server, so I started proof. The two feed each other - every time proof finds a place where the spec and the clients disagree, or something the spec doesn’t cover at all, it ends up in a running list, and from there in the spec itself. (A few of those are already pull requests against it.)

When the spec and the clients disagree, proof sides with the clients. For instance, the spec recommends returning the ops in describe as a list, but CIDER and Calva both expect a map. A server that follows the spec to the letter, but that CIDER can’t connect to, is not much use to anyone. That’s also why proof is a compatibility suite and not a conformance suite. You can only conform to an official spec, and nREPL doesn’t have one yet.

Checking Servers

Checking servers is proof’s main job. It’s a single binary1 that talks to your server over a socket, the same way any client would. It knows nothing about the server’s internals and it doesn’t care what language the server is written in. What it does need is a profile - a small TOML file that tells proof how to start your server and gives it a few snippets of code in the server’s language (an idea I borrowed from jupyter_kernel_test):

name = "My server"

[launch]
command = ["my-server", "--port", "0"]
port-pattern = 'nREPL server started on port (\d+)'

[snippets.value]
code = "(+ 1 2)"
value = "3"

Then you install proof and run it:

$ go install github.com/nrepl/proof/cmd/proof@latest
$ proof run my-server.toml

proof starts your server, runs about 80 checks against it and shuts it down afterwards. (You can also grab a binary from the releases, if you don’t have Go around.) Profiles for nREPL, Babashka, nbb, Basilisp, ClojureCLR, jank, let-go, dialtone and repartee come with proof, so proof run babashka just works if you have Babashka installed.

The most important decision in proof’s design is how it grades the checks. A check fails only when some client won’t work properly with your server (or when users will lose data), and the report tells you which client it is and links to the client code in question, pinned to a specific commit. If your server simply does something differently from the reference implementation, you get a warning instead. Here’s a failure from a run against Babashka 1.13.226:

FAIL   interrupt.other-connection   An interrupted eval ends on its own connection
       the interrupted eval's done went to the connection the interrupt came from
       why: neorepl interrupt stops an eval that another neorepl process
       started, and that process waits for the eval's done on its own
       connection forever if it goes anywhere else.
       see: neorepl interrupt stops an eval from another process
       https://github.com/nrepl/neorepl/blob/462a182c.../src/neorepl/main.clj#L374-L385
       see: nrepl/nrepl#498 https://github.com/nrepl/nrepl/issues/498

The idea is that a failure should always be worth fixing and never be a matter of taste. If you think some failure isn’t justified, the links show you exactly what you need to argue with.

Some checks go a step further and send exactly what a particular client sends, the way it sends it. cider.connect sends the requests CIDER sends while connecting, calva.eval evaluates code the way Calva does, and so on for Conjure, vim-fireplace, REPLy, neorepl, clojure-cli.repl and bb repl --connect. That way the report tells you directly whether those clients will work with your server.

Once your server passes everything it can pass, you can list the failures you can’t fix yet as expected failures in its profile and run proof in your CI with its GitHub Action. When an expected failure starts passing, proof fails the build and asks you to remove it from the list, so the list can’t go stale.

Last, but not least, the bundled profiles run every day and their reports end up in a compatibility matrix, which shows where the different servers agree, where they don’t and where the spec needs some work.

Checking Clients

Servers were always proof’s primary focus, but once the foundation was in place people started asking me how they could check their clients with it. That made a lot of sense to me - after all, the same knowledge cuts both ways. proof has two tools for client authors.

proof proxy sits between a client and a server, passes everything along as is and records it on the way. You use your client for a while (or run its test suite through the proxy), press Ctrl-C and proof grades every request the client sent. Here’s a quick neorepl eval going through it:

$ proof proxy -listen 127.0.0.1:7888 babashka
$ neorepl eval --port 7888 '(println "hi") (+ 1 2)'
client
  PASS   client.bencode               Every frame is valid bencode
  PASS   client.dict                  Every request is a dict
  PASS   client.canonical             Frames use canonical bencode
  PASS   client.id                    Every request has an id
  PASS   client.active-id             An id isn't reused while its request is active
  PASS   client.field-types           Request fields have the right types
  PASS   client.required-fields       Requests carry the fields their op needs
  PASS   client.need-input            need-input is answered with stdin in the same session
  WARN   client.close                 Sessions are closed before disconnecting
  PASS   client.unknown-op            Requests use ops the server supports

9 passed, 0 failed, 1 warnings, 0 skipped

The grading rule is the same, just turned around. A client check fails only when some server breaks, and it links to the server code in question. (That warning is fine, by the way. neorepl keeps its session around on purpose, so the next neorepl eval can use it.)

A proxy sees only what goes over the wire, though, so it can’t tell you how your client copes with the replies of different servers. That’s what proof serve is for. It’s a small nREPL server for client test suites, which behaves like nREPL unless you ask it to behave like some other server in some particular way - send only the value of the last form (like Basilisp, jank and dialtone), drop what the code prints to stderr, have no interrupt op, deliver its replies a byte at a time, and so on. None of those scenarios are made up. Every one of them is something a real server does (or something TCP can do to any server’s replies). -like turns on all the quirks of a server at once, so you can run your client’s tests against “jank” without installing jank:

$ proof serve -listen 127.0.0.1:7888 -like jank

proof in Practice

So far, so good. But does it actually find anything? You bet! Here are some of the things proof turned up in the past couple of months:

  • nREPL itself sent the end of an interrupted eval to the connection the interrupt came from (fixed on master), drops the evals waiting behind an interrupted one (#503) and can leave a session that stops sending done after its client goes away in the middle of an eval (#509). Oops!
  • Babashka, nbb, Basilisp, jank and ClojureCLR all had their share of rough edges, e.g. errors without an eval-error status, sessions sharing *1 and the current namespace, and a request that throws taking its whole connection down.
  • CIDER skipped part of its setup when connecting to a server without cider-nrepl, Calva and Conjure didn’t sort the keys of the dicts they sent, vim-fireplace and REPLy never close their sessions, and Conjure’s interrupts don’t say which eval to stop.

Michiel Borkent fixed the Babashka and nbb issues the same day I reported them (or the next one), Conjure fixed its encoder the next day and Calva within a week. That’s the Clojure community for you!

I was also super happy to see other people picking up proof before I even got around to announcing it. Tom Waddington has been running it against his nREPL implementations for Janet and Steel Scheme since early October (nrepl-janet even tests its client with proof serve), Jeaye Wilkerson suggested using it for jank’s nREPL server, and Kyle Cesare is already fixing what it found there. That’s exactly what I was hoping for!

The projects that benefited from proof the most were my own, though. This year I built a few toy nREPL implementations, mostly for fun and to see how well the protocol holds up outside of Clojure:

  • nrepl-beam has nREPL servers for Erlang (dialtone) and Elixir (repartee). proof found that they didn’t answer close with session-closed, didn’t echo unknown ops and returned only the last value of several forms, and fixing those uncovered a race after close and temporary sessions that never stopped.
  • mezcaml, my little OCaml client, read replies as if only one request was ever in flight, so late output from one eval could end the next one early.
  • neat, my language-agnostic client for Emacs, got late output, errors without err and unknown sessions wrong.
  • nrepl-sci, the nREPL server behind the “Try it” section of nrepl.org, was the first server I wrote with proof around from day one, and working on it mostly came down to running proof after every change.

And then there’s neorepl, the CIDER-inspired terminal REPL I’ve been working on lately, which is just as happy to be used by coding agents as by humans. neorepl and proof pretty much grew up together. I ran neorepl through proof proxy and every scenario of proof serve, which caught a few bugs before neorepl 0.1 was out, and proof checks every server with the exact requests neorepl sends. It’s also how I found the interrupt bugs in nREPL above, which hit neorepl harder than most clients. Its commands are separate processes sharing a session, so neorepl interrupt stops an eval that some neorepl eval started, and that neorepl eval used to wait for its done forever.

A Tool for Tool Authors

I guess I should spell out who proof is for. If you’re using nREPL through CIDER or Calva you’ll probably never run proof yourself (although hopefully you’ll feel its effects). proof is a tool for tool authors - people writing nREPL servers, clients and middleware.

I really hope that it will make it a lot easier to grow the nREPL ecosystem. These days anyone can put together an nREPL server or client in no time with the help of tools like Claude. Writing the code is no longer the hard part - knowing whether the result actually works with CIDER, Calva and friends is. proof gives you (and your coding agent) a fast feedback loop and failures that explain themselves: what went wrong, why it matters and which client code depends on it. That’s usually all an agent needs to fix the problem.

Why “proof”?

Naming is hard! Luckily, the nREPL world has a tradition that makes it a bit easier, as many nREPL clients are named after drinks (CIDER, neat, mezcaml, chaser and so on). proof is not a client, so it bends the rule a bit, but it still belongs at the bar. The proof of a spirit is a measure of its strength, which is more or less what this tool measures for nREPL servers. And running it gives you proof that your server works with the existing clients (or a list of the reasons why it doesn’t). Two meanings for the price of one!2

Epilogue

proof is still in its early days, but it already covers the core of the protocol, the exact requests of the most popular clients and the server quirks clients have to deal with. Now it needs more users.

So, if you’re working on an nREPL server or client, please take proof out for a spin and let me know how it went! I’d love to see your server in the compatibility matrix, and if you think some failure isn’t fair, open an issue and let’s argue about it. And if you’re a casual Clojure hacker who’d just like a nice REPL in the terminal, give neorepl a try:

$ bbin install io.github.nrepl/neorepl

Big thanks to Clojurists Together for supporting my work on nREPL, to Phil Hagelberg for getting the spec going, and to the maintainers of all the servers and clients who were so quick to fix the problems proof found. You rock! In the (n)REPL we trust!

That’s all I have for you today. Keep hacking!

  1. Written in Go, of all things. A Clojure tool would have been the obvious choice, but proof shouldn’t favour any of the languages it checks, and a single binary without a runtime is easy to drop into any CI. ↩

  2. “hangover” would have been a good name for what you get when a server forgets to send done, but it’s a hard sell for a project. ↩

Permalink

Make the next marketing euro count

Kettle & Pine, our fictional online homeware shop, has EUR 20,000 for its next campaign. The marketing lead wants that budget to bring in customers the business can serve profitably. Search, social and video all have a place in the plan, and their past results give the team a starting point. Where would another euro do the most good?

To answer that, we need to understand how spending produces sales. The channels can help one another: advertising in one place may create demand that another channel converts. So we look at the whole history of spending, promotions, seasonality and sales together, and ask what each channel adds beyond what might have happened anyway. Marketing mix modelling estimates those contributions, which marketers call incrementality.

We use a Bayesian model because several explanations can fit that history. We keep a range of plausible effects and ask how a spending plan performs across them. Marketing and finance can then compare the expected gain with the uncertainty and agree on a plan with the same evidence in view. In our example, an experiment helps them improve the channel mix, and a simulation of fulfilment helps them choose how much demand to acquire.

Who already budgets this way

Bayesian marketing mix modelling is no longer a research project. PyMC-Marketing is the open-source Python library behind much of this practice, and we contribute to it. Bolt describes using it for budgeting, for example, and PyMC Labs documents an engagement with Nürnberger Versicherung in which upper-funnel advertising creates demand that paid search later converts. After adopting a model of that funnel, Nürnberger reports a reduction in cost per lead of more than 27% over six months.

Those results belong to those teams. What we’d carry into an engagement of our own is the way the model becomes part of the team’s work. The next budget comes round, someone questions an assumption, and the people making the decision can inspect and adapt the model. We want Kettle & Pine’s example to make that process concrete.

EUR 20,000, four weeks, three channels

We gave the shop 156 weeks of revenue, spending on search, social and video, and the demand, promotions and seasonality that move sales alongside advertising. Making up the business lets us check something we couldn’t check in a real shop: what every other budget would have earned. We know the true advertising effects, so we can compare each recommendation with the plan that would have worked best. The model doesn’t get to see that truth.

The decision comes in week 130, with four campaign weeks ahead. We fit the model to those first 130 weeks and keep the remaining 26 weeks’ revenue hidden, so we can later compare its forecasts with what happened. Before asking it to recommend a budget, we want to know whether it can predict sales it hasn’t seen.

Each channel manager has limits: search must receive EUR 1,000–2,600 per week, social EUR 500–3,000 and video up to EUR 2,500. Today the shop spends EUR 2,000, 1,800 and 1,200 respectively. PyMC-Marketing lets the analyst compare allocations within those limits, with uncertainty about what each change would earn.

Suppose search has earned a good return so far. Does that make it the best place for another euro? Only if it still has room to grow. As an audience saturates, each additional euro buys a little less, so the return on the next euro can differ from the average return on all the money spent before it. That next-euro return is the marginal return on ad spend, or marginal ROAS.

We also need to follow what happens after the spending stops. An ad can keep working for a few weeks, an effect practitioners call adstock or carryover. The model combines carryover and saturation into a response curve for each channel. We use those curves to compare what a budget would earn during the campaign and afterwards.

Two versions of Kettle & Pine's 156-week history. Each shows weekly revenue above weekly ad spend on search, social and video. In the first, social and video flights take turns; in the second, they run in the same weeks. In the final 26 shaded weeks, the model's forecast band follows the revenue that actually happened. Two versions of Kettle & Pine's history. In the shaded weeks, the dots show the revenue that actually happened and the orange band shows what PyMC-Marketing forecast without seeing it. Foerster Marketing's forecast is practically identical.

Now consider two versions of the shop’s history. Search runs all year in both. Social and video run in flights, as marketers call bursts of a few weeks followed by a pause, but in the first history they take turns and in the second they overlap. Both versions forecast the hidden weeks well: the actual revenue falls inside the 90% band in 23 of 26 weeks when the flights take turns, and in 22 of 26 when they overlap. We can now examine what each model recommends doing with the money.

Start with the history in which the flights take turns. Each channel has weeks of its own, so the model has evidence for telling their effects apart. At current spend, it estimates that the next euro earns more in social and video than in search. The optimiser therefore moves roughly EUR 600 per week out of search and into the other two. Across the campaign and its carryover, that change is worth an expected EUR 1,300 in additional revenue.

PyMC-Marketing’s recommended weekly spending, in EUR:

PlanSearchSocialVideo
Current2,0001,8001,200
Recommended1,4032,0051,592

We’d take the uncertainty to the budget review alongside that gain. The 90% interval runs from about EUR −800 to EUR +3,200, so in some plausible versions of Kettle & Pine the change would do slightly worse. With both the expected EUR 1,300 gain and its downside in view, marketing and finance can decide whether the change is worth making.

Two overlaid histograms of the revenue gain of Foerster Marketing's closely matching plan, one from PyMC-Marketing and one from Foerster Marketing. They almost coincide, peak around EUR 1,500, and 86 percent of the distribution lies above zero. As a cross-check, each histogram evaluates Foerster Marketing's closely matching plan under one implementation of the model. The two implementations were built independently and agree so closely that their bars blend into one shape.

When two channels always move together

Now switch to the history in which social and video run together. If sales rise after every joint campaign, how much credit should each channel get? One explanation gives most of it to social, another to video, and both can reproduce the sales we saw. That is why forecasting sales well doesn’t settle where to spend the next euro. We need to know what happens when we change one channel without the other.

Statisticians call this an identifiability problem. For the marketing lead, it means the data leave an important budget question open. In this version, the recommendation shifts towards social and keeps video near its current level, while the 90% interval for the gain widens to roughly EUR −1,100 to EUR +4,300. We’d want to understand what supports that shift before acting on it.

Two overlaid histograms of the revenue gain of Foerster Marketing's plan for the overlapping history. They coincide, are wider than in the staggered case, and range from about EUR minus 4,000 to plus 6,000. The same cross-check of Foerster Marketing's plan for the history in which social and video run together. The plan differs from the one above, and its spread of outcomes is wider.

This gives us a purpose for an experiment. A geo-holdout or similar lift test measures what one channel does when only that channel changes, for instance by pausing it in some regions and comparing them with the rest. The model can then use that evidence to distinguish explanations the spending history couldn’t separate. This is known as calibrating the MMM with lift tests.

In the third version of our study, we add one independent video experiment to the overlapping history. Because its effect also carries over, we give the model the experiment’s whole time path, the weeks before, during and after the treatment. A single before-and-after contrast would leave out part of the process we’re trying to learn from.

Since we generated the data, we can score each recommendation against the plan that would truly have been best. With overlapping flights alone, the model moves money into social and leaves video where it is, and that plan realises only about EUR 1,700 of a possible EUR 3,100 gain. With the lift test, the recommendation moves back to EUR 1,355 / 2,000 / 1,645 for search, social and video, close to what the model chose when the flights took turns, and it realises about EUR 2,800. The experiment improves this campaign’s allocation by roughly EUR 1,100 under the hidden truth. The marketing lead has a reason to change the plan that the overlapping spending history alone could not supply.

Notice that the experiment barely narrows the uncertainty interval, which still runs from about EUR −1,100 to EUR +4,000. If we judged it only by the width of that interval, we’d miss its value. It helps the team put the money in a better place. As a cross-check, Foerster Marketing fitted the calibrated model independently and agrees: its own optimiser lands within about EUR 30 a week of PyMC’s plan on every channel, and both libraries put the expected gain of PyMC’s plan at about EUR 1,560.

Weekly spending and true campaign-plus-carryover gains in EUR, using PyMC-Marketing’s recommendations:

PlanSearchSocialVideoTrue gain
Overlapping flights1,3752,4021,2231,739
Overlapping flights + lift test1,3552,0001,6452,836
Best possible (hidden truth)1,0982,0851,8173,114

Before using an experiment this way in a real engagement, we’d also ask whether its market resembles the rest of the business and whether the benefit justifies the cost of the study. Our example doesn’t price that cost.

A budget for the business you have today

The marketing lead now has an allocation to take to finance. The model has been checked, the experiment gives the team a reason to improve the channel mix, and the uncertainty is there to inspect. Operations can help refine the plan further. Nearly 700 orders are waiting to ship, and the team expects capacity to recover in three weeks. How much demand is worth acquiring while those orders move through fulfilment?

In this extension of our fictional business, a staffing shortage cut fulfilment capacity three weeks ago. Delayed orders create compensation and support costs, and some customers cancel. Those costs affect what the campaign can earn. By including them in the planning, the team can choose a spending level that makes better use of the capacity available now and revisit it as fulfilment recovers.

To answer that, we need an estimate of capacity. Imagine two warehouses that both shipped 550 orders last week. The first had only 550 orders available. The second still has hundreds waiting. Would you draw the same conclusion about how much they could ship next week? In the first warehouse, the report tells us little about spare capacity. In the second, it gives us evidence of a bottleneck. So the backlog changes what the shipment count can tell us.

A shipment count is the end of a process. Orders arrive, the warehouse ships up to its capacity, some waiting orders cancel, and the rest carry into the next week. We use Foerster to run those rules under alternative capacity assumptions and compare the resulting shipments with the reports. Each hypothesis has to explain what happened from the state the warehouse was in when the disruption began.

From three weeks of shipment reports, Foerster estimates reduced capacity at about 550 orders a week, give or take 20, compared with the usual 900. The true figure, which we hid from the inference, is 560. We then call the same warehouse function to simulate the next nine weeks from today’s backlog, pairing draws from the marketing and capacity estimates. It counts orders shipped, delayed and cancelled, and we use margin, delay costs and advertising spend to calculate profit.

Campaign profit at weekly budgets from EUR 0 to 7,500, compared with keeping EUR 5,000 per week. With a clear warehouse, profit peaks at EUR 3,750 and is practically level up to EUR 5,000. With the backlog, it peaks at EUR 2,500, about EUR 4,300 above the EUR 5,000 plan, and falls steeply at higher budgets. Profit over the four campaign weeks and five weeks of carryover, compared with keeping EUR 5,000 per week. Lines are posterior means and bands are 90% intervals. Blue starts from an empty warehouse at normal capacity, orange from today's backlog of 681 orders and the reduced capacity inferred from the shipment reports.

With an empty warehouse, any budget between EUR 3,750 and 5,000 a week earns about the same, within EUR 250 over the whole campaign. With today’s backlog, EUR 2,500 a week makes the best use of the campaign budget. Compared with keeping EUR 5,000, it earns about EUR 4,300 more, and the 90% interval of that difference, EUR 3,000 to 5,400, stays well above zero. The larger EUR 7,500 plan earns about EUR 6,800 less than keeping EUR 5,000. And since we wrote the warehouse too, we can check against the truth once more: with the hidden capacity and the true advertising effects, EUR 2,500 is indeed the most profitable budget, beating the EUR 5,000 plan by EUR 4,400.

We’ve kept the marketing evidence the same. Bringing fulfilment into the decision helps the team choose how much demand to buy today. Once capacity recovers, the same models would recommend spending more again. Marketing and operations now have a shared reason to bring the next shipment report back to the budget review: it helps them recognise when more advertising becomes worthwhile.

This is the kind of recurring decision we want simulation to help with. Our Stuttgart demonstrator simulated a whole city to ask what would happen to its shops if a shopping centre moved. Kettle & Pine needs a much smaller simulation, but it has a question that comes back every budget cycle. Following demand through fulfilment lets the team connect its campaign investment to the profit it can earn.

Inference that runs where the business runs

For a Python analytics team, PyMC-Marketing is a practical starting point for measurement and allocation. Its budget optimiser already accepts custom constraints and objectives, and an operational model can be built alongside it. We can help the team check whether its records support the allocation it wants to make, fit and validate the model, and bring experiments into the next budget review.

We kept the warehouse example small enough to check independently. It has a backlog, capacity and fixed rules for cancellations and delay costs, and we could have written a separate analytical model of it. We checked the capacity estimate against an independent numerical calculation, and a separate Python implementation reproduced the profit calculation.

What we want to carry further is the connection to the application. Suppose yours already decides which orders get stock first, whether to split a shipment and when to offer compensation. Those rules affect both what the shipment reports tell you and what another campaign would earn. We’d like inference and forecasts to run that code too, because every rule change otherwise gives you two implementations to keep in step.

Foerster is our probabilistic programming library for Clojure on the JVM. Clojure’s immutable, persistent data structures let different versions share unchanged data while keeping their updates separate. That is a useful foundation for exploring several possible futures of an application. Spindel, the runtime beneath Foerster, manages the execution state and its forks.

Foerster’s particle methods can start from the application’s managed state, execute compatible application logic in separate forks under uncertain assumptions, and compare the resulting histories with observations. The same logic can then evaluate proposed plans. In our synthetic warehouse, both steps call the same fulfilment function and leave the caller’s state unchanged. We haven’t integrated the example with a retailer’s production application.

A separate queue demonstration shows why the starting state matters. Hypotheses about service speed run through the application’s scheduling function, and including work already in flight changes which hypothesis the completion reports support. A future run can explore a different queue without changing the parent’s managed state. External actions such as issuing an actual refund still need to be kept out of speculative runs.

Foerster Marketing supplies the demand model in this JVM setting. We implemented Kettle & Pine’s model in both libraries and checked that they agree, with identical data and priors, independently verified densities and gradients, matching posterior estimates and closely matching recommended plans. PyMC still fits this model faster. We’d use Foerster where we want that model to work with the application’s logic and managed state.

Running the application’s code still leaves us modelling choices to check. In this study, we fit demand and capacity separately and treat them as independent, and we supply the cancellation and cost assumptions. At each candidate budget, we choose the channel mix for revenue, then use the warehouse simulation to compare budget levels. We’d need a further step to optimise channel mix and profit jointly. More application detail would earn its place when it changed the decision or exposed a failure in the simpler model.

Bring us a decision

We start with the investment your team wants to make and the decision it keeps coming back to. Then we look at the spending and outcome history, campaign changes, promotions and any lift experiments. Before fitting a model, we ask what those records can tell us and where another experiment would help. The aim is a review your team can repeat, with a documented model, checks against held-out data, feasible plans and the uncertainty about their differences. Agreeing on how to evaluate the recommendation in practice is part of that work.

To connect the campaign to its business outcome, we work with the people who own fulfilment or other relevant processes to identify the state and rules that affect the decision. A first simulation may be small, like Kettle & Pine’s warehouse. Where compatible application logic already exists, we can help it participate directly in inference and plan evaluation, and check the results against observed operations.

If your team is preparing its next budget, get in touch with the decision, the history you have and the constraints that matter. We can help with PyMC-Marketing in Python, or with Foerster where the analysis needs to run inside your business processes on the JVM.

All data in this article are synthetic. The PyMC-Marketing results use version 1.3.0 with PyMC’s default NUTS sampler and four chains of 4,000 draws. The executable study, its validation records and the performance measurements are in the Foerster Marketing repository.

Permalink

The Pull Newsletter (Nov 5, 2026)

Welcome back to the Pull! Here’s what the Datomic team and community has been up to over the past month.

Datomic Team News

  • Datomic Pro 1.0.7745, featuring several performance improvements and other fixes, is now available.

  • In case you missed it, we’ve launched a new version of datomic.com. Let us know what you think!

  • We also have a brand-new Datomic Query Playground, a read-only interactive environment to try out Datomic queries and read APIs with Clojure. Take it out for a spin and send us any feedback.

  • At the 2026 Clojure Conj, Datomic Developer Success team member João Nascimento Mello led a Datomic Deep Dive workshop. If you weren’t able to attend, you can play along at home with the workshop lab materials.

Community Projects

Speaking of Clojure Conj 2026, speaker Wendy Randolph gave a talk there about "Modeling a Fabric Stash with Datomic," which shows "why Datomic’s attribute-based schema fits sparse, irregular data better than rigid relational tables."

There’s a new video and demo from UnifyBio and the RCRF Pattern Data Commons, which stores molecular and clinical data on rare cancers and is built around Datomic as its primary store and system of record. You can read more about the project on the #datomic channel on Clojurians Slack.

Questions or suggestions? Reach out to the Datomic team and the rest of the community at the #datomic channel on Clojurians Slack.

Permalink

Lean on the JVM: proved functions for Java and Clojure

Imagine a pricing service written in Java. Customers request a discount, and the business has two firm rules: a discount may never exceed the order subtotal, and it may never exceed the policy cap. Unit tests check a few dozen combinations of amounts, but the rules speak about every order. Sooner or later, a refactoring or a new promotion meets an input nobody tested.

Lean, a programming language and proof assistant, lets you state such a rule as a theorem and have the computer check a proof that it holds for every input. But adopting it usually means leaving the JVM. Lean compiles to native code, and connecting that code to a Java service is a project of its own.

The Ansatz Lean/JVM SDK removes that step. You write the function and its proofs in Lean, prepare the compiled code during your build, and call it from Java or Clojure like any other library. The deployed service needs no Lean installation. We are introducing this workflow with release 0.2.615, and this post follows the pricing rule from proof to production, then shows how Lean code can call back into the application.

Write the rule down, then prove it

First, the rule itself. Amounts are whole numbers in the currency’s smallest unit, such as cents. Save this as Pricing.lean:

namespace Pricing

def discount (subtotal requested cap : Nat) : Nat :=
  min subtotal (min requested cap)

theorem discount_le_subtotal (subtotal requested cap : Nat) :
    discount subtotal requested cap ≤ subtotal :=
  Nat.min_le_left _ _

theorem discount_le_cap (subtotal requested cap : Nat) :
    discount subtotal requested cap ≤ cap :=
  Nat.le_trans (Nat.min_le_right _ _) (Nat.min_le_right _ _)

theorem discount_eq_requested {subtotal requested cap : Nat}
    (h₁ : requested ≤ subtotal) (h₂ : requested ≤ cap) :
    discount subtotal requested cap = requested := by
  simp only [discount, Nat.min_eq_left h₂, Nat.min_eq_right h₁]

end Pricing

The definition takes one line: the discount is the smallest of the subtotal, the requested amount and the cap. The theorems are the interesting part. The first says that the discount never exceeds the subtotal, and the second that it never exceeds the cap. But a function that always returns zero would satisfy both bounds, so the third theorem adds that a request within both limits is granted unchanged. Lean checks all three for every possible input when it builds the file.

Of course, a proof is only as good as its statement. Lean guarantees that this definition has these properties; whether they are the right properties remains a business question. The guarantee also covers the Lean code only. The compiler, the JVM runtime and the integration that carry the result into Java are trusted rather than proved. Proofs are erased from the compiled code, so the service never runs a theorem prover on a request.

The example is small on purpose. A team can start with one rule like this, keep its database, HTTP handlers and deployment in Java or Clojure, and move more logic into Lean only where proofs pay off.

Prepare the code once, during the build

Next, add the SDK to an existing Maven project:

<repositories>
  <repository>
    <id>clojars</id>
    <url>https://repo.clojars.org/</url>
  </repository>
</repositories>
<dependencies>
  <dependency>
    <groupId>org.replikativ</groupId>
    <artifactId>ansatz-lean-jvm</artifactId>
    <version>0.2.615</version>
  </dependency>
</dependencies>

It runs on JDK 17 or later and brings Clojure and a JSON library along as ordinary Maven dependencies. It does not need the core Ansatz kernel or a Mathlib store. For the commands below, write the resolved classpath to a file:

mvn dependency:build-classpath -Dmdep.outputFile=target/lean-classpath
export SDK_CLASSPATH="$(cat target/lean-classpath)"

Preparation is the only step that needs native tools. It uses the official Lean 4.33.1 toolchain and a published bundle of helper programs that export what the JVM needs. For now, it runs on Linux x86-64 with prlimit available. Set LEAN_SYSROOT, PRODUCER_BUNDLE and COMPILER_CACHE to the toolchain, the extracted bundle and a cache directory. Then place Pricing.lean in a directory named pricing and prepare the function:

java -cp "$SDK_CLASSPATH" ansatz.lean.jvm.LeanPreparation \
  --source ./pricing \
  --sysroot "$LEAN_SYSROOT" \
  --producer-bundle "$PRODUCER_BUNDLE" \
  --cache "$COMPILER_CACHE" \
  --root-module Pricing \
  --entry Pricing.discount \
  --output ./pricing.leanjvm

The result is a single file, pricing.leanjvm. It contains Lean’s compiled intermediate code, together with the function types, data layouts and initialisation steps that the JVM needs. The command also prints two fingerprints, which are SHA-256 hashes. Keep the manifest hash in your application’s configuration. When the service loads the artifact, it checks the file against that fingerprint, so a changed or swapped file is rejected. One caveat applies to proprietary code: the artifact also contains the Lean source files and compiled modules, so treat it like the source itself. The preparation guide covers the details.

The native Lean build prepares an artifact. A JVM application loads it and calls the Java interpreter without a native Lean toolchain. Lean source + dependencies Official native Lean build Prepared artifact + hash IR, types, initialization Java IR interpreter SDK + Maven dependencies Java / Clojure application Calls, values, host services Native tooling belongs to preparation. The deployed artifact runs with the SDK and its ordinary JVM dependencies.

Call it like any other Java method

Then, in the deployed application, load the artifact and ask for the function by name. Save this as PricingApp.java:

import ansatz.lean.jvm.*;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;

public class PricingApp {
    public static void main(String[] args) throws Exception {
        try (Lean runtime = Lean.open();
             var input = Files.newInputStream(Path.of(args[0]));
             LeanProject project = runtime.loadArtifact(input, args[1])) {
            LeanFunction discount = project.function("Pricing.discount");
            BigInteger amount = (BigInteger) discount.invoke(10_000, 1_500, 1_000);
            System.out.println(amount);
        }
    }
}
java --class-path "$SDK_CLASSPATH" PricingApp.java \
  pricing.leanjvm "$EXPECTED_MANIFEST_SHA256"

It prints 1000, because the requested 1,500 is capped at 1,000. Behind the call, the SDK checks the arguments against the Lean signature and converts the result into a BigInteger. The same works for booleans, strings, lists, arrays, pairs, optional values and success-or-error results, and Lean records and recursive datatypes arrive as immutable Java objects.

Clojure developers resolve the same release:

What is this syntax?
{:deps {org.replikativ/ansatz-lean-jvm {:mvn/version "0.2.615"}}}
{:deps {org.replikativ/ansatz-lean-jvm {:mvn/version "0.2.615"}}}

and can call the function from the REPL:

What is this syntax?
require('[ansatz.lean.jvm :as lean], '[clojure.java.io :as io])

with-open [runtime lean/open(),
           input io/input-stream("pricing.leanjvm"),
           project lean/load-artifact(runtime,
                     input,
                     System/getenv("EXPECTED_MANIFEST_SHA256"))]:
  discount := lean/function(project, "Pricing.discount")
  [discount(10000, 1500, 1000) discount(200, 1500, 1000)]
end
;; => [1000 200]
(require '[ansatz.lean.jvm :as lean]
         '[clojure.java.io :as io])

(with-open [runtime (lean/open)
            input (io/input-stream "pricing.leanjvm")
            project (lean/load-artifact runtime input
                      (System/getenv "EXPECTED_MANIFEST_SHA256"))]
  (let [discount (lean/function project "Pricing.discount")]
    [(discount 10000 1500 1000)
     (discount 200 1500 1000)]))
;; => [1000 200]

The Superficie tab shows the same Clojure forms in another notation. Both run on the same runtime.

Let Lean call the application

So far, information has flowed in one direction, from Java into Lean. Real business logic, however, also needs the application: a configuration value, a database lookup or an exchange rate. This is where running Lean inside the JVM pays off most. If you compile Lean natively instead, you load a platform-specific library through JNI or Panama, start Lean’s runtime and convert every value by hand, as the lean-android-compose demonstration does for an Android app. Calling back into Java then needs C glue code as well. The harder part is memory. Lean frees its objects by reference counting, while the JVM uses a garbage collector, and neither sees the other’s references. A Java object that holds a Lean value must release it when the collector reclaims the Java object, a Lean value that holds a Java object must keep a JNI reference until Lean frees it, and a cycle across the boundary is reclaimed by neither side. With the SDK, both directions use ordinary Java and Clojure values, and the garbage collector manages all of them.

Suppose the cap depends on the customer’s segment and lives in the application’s configuration. Add a declaration without a body to Pricing.lean, together with a function that uses it:

namespace Pricing

@[extern "ansatz_jvm_policy_cap"] opaque policyCap : String → IO Nat

def quote (subtotal requested : Nat) (segment : String) : IO Nat := do
  pure (discount subtotal requested (← policyCap segment))

end Pricing

Prepare it with --entry Pricing.quote --entry Pricing.policyCap. When the application loads the artifact, it supplies the implementation under the declaration’s name:

HostBindings bindings = HostBindings.builder()
    .io("Pricing.policyCap", "config/1", arguments -> {
        Long cap = caps.get((String) arguments.get(0));
        if (cap == null) throw LeanIOError.userError("Unknown segment");
        return BigInteger.valueOf(cap);
    })
    .build();
LeanProject project = runtime.loadArtifact(input, expectedManifestSHA256, bindings);
IOResult<?> result = (IOResult<?>) project.call("Pricing.quote", 10_000, 1_500, "retail");

With caps of 1,000 for retail and 2,500 for partner, the two quotes are 1000 and 1500. An unknown segment reaches Lean as an ordinary IO error and comes back to Java as a LeanException with its message. In Clojure, the binding is a map entry, {"Pricing.policyCap" {:implementation-id "config/1" :effect :io :fn (fn [segment] (get caps segment))}}, passed to lean/load-artifact.

Notice what happens to the proof. discount_le_cap holds for every cap, so it also covers whatever value the configuration returns, and the quote can never exceed the configured cap. A trust boundary appears only when host code replaces a Lean definition, because type checking a binding does not prove that it behaves like the code it replaces. For now, bindings are first-order and synchronous, so they cannot take functions as arguments or run asynchronously. The host-binding guide covers pure bindings, replacements with a Lean fallback and custom result types.

Run it in a service

In production, a few more questions come up. The first is lifetime. Open the Project, as the SDK calls a loaded artifact, once and reuse its function handles. Calls share its code and initialised globals and run one at a time, so use several Projects for parallel work. A new version of the code means loading a new Project; state migration and public execution forks are separate work.

The second is runaway computation. Each call runs under a budget, by default two million interpreter instructions and five seconds. When a workload needs more, raise the budget per call with InvocationOptions.empty().withBudget(instructions, duration) and invokeWith.

The third is failure. Invalid arguments are rejected before anything runs, and an IO function that returns an error reports it as a LeanException. In both cases, the Project stays usable. But when a call fails halfway, for example because it exhausted its budget, the SDK cannot know what state the Lean code left behind. It therefore refuses further calls with ProjectFailed until you call project.restart(). The restart builds a fresh engine from the same verified artifact and keeps your function handles. Module initialisers run again, so globals start over.

Finally, panics behave as in native Lean. panic! and an out-of-bounds xs[i]! print a message to stderr and continue with a default value. If you prefer a hard stop, an option turns a reached panic into a failure instead. The SDK guide covers all four questions.

Bring an existing Lean project

Most teams will not start from a blank file, so we also tried real library code. The insertion-sort module from Functional Programming in Lean uses polymorphic arrays, typeclass comparison, proofs about array size and a termination argument. We load the unchanged module as a dependency of a small Lake project and add one function for the JVM to call:

import Examples.ProgramsProofs.InstrumentedInsertionSort
namespace LakeTutorial

def sortAndOriginal (xs : Array Nat) : IO (Array Nat × Array Nat) :=
  pure (insertionSort xs, xs)

end LakeTutorial

The caller’s Lake configuration requires the pinned fp-lean examples package, which Lake builds first. The SDK’s builtLakeWorkspace() option, or the CLI flag --source-mode built-lake, then takes the built workspace as it is, without fetching or building anything itself. A Java call with List.of(3, 1, 2) returns [[1, 2, 3], [3, 1, 2]] as BigInteger values, and Clojure receives the same pair of vectors. Keeping the original array next to the sorted one also exercises the runtime’s copy-on-write behaviour. Because insertion sort is quadratic, a few hundred elements fit the default budget, while larger inputs need a raised one.

The module proves that sorting preserves the array’s size and terminates, although it does not prove that the result is sorted. It is the work of David Thrane Christiansen and the Functional Programming in Lean contributors. Our corpus pins the revision, and the attribution and CC BY 4.0 source licence document the original work. The Lake guide explains this path, and our native CI also runs selected argument-parsing functions from the unchanged lean4-cli library.

Why the interpreter can fork

Why interpret Lean at all, instead of only calling native code? The answer comes from our own product. Simmis is designed so that people and agents can try a change in a separate branch before anyone accepts it, and that branching should reach running computations as well as stored data. For Clojure, we have already extended SCI, a Clojure interpreter, so that a session can fork into independent histories. Simulations need the same ability. In our Stuttgart city model, a policy question is an intervention evaluated with the same random draws as the baseline, which is easiest when the what-if continues from the very same state.

We are not the first to run Lean’s compiled code on the JVM. Lean4J exports it as JSON and interprets it on GraalVM’s Truffle framework, which compiles hot code to machine code and lets Java, JavaScript and Python pass callbacks in. Its focus is speed and polyglot tooling such as debugging and profiling, it requires GraalVM, and its README states that it is not meant as a faithful drop-in for native Lean. Our SDK makes different trade-offs. It runs on any JDK 17, loads only artifacts that match a trusted fingerprint, takes its Java and Clojure types from Lean’s checked signatures, and refuses unsupported operations before a program starts. It also keeps the whole execution state forkable, which matters most for Simmis. Truffle-style compilation remains a promising route to speed, and we see it as a possible later backend rather than a competing design.

Because the SDK keeps Lean’s entire execution state as JVM objects, it can copy that state. Its engine can already fork a paused computation, at any point in its execution, into two engines that continue independently, and the engine tests check that control state, ownership and output stay separate. The public SDK does not expose forks yet, so this is groundwork rather than a feature of this release.

Internally, we also write most of our verified code in Clojure rather than in Lean’s own syntax. Core Ansatz implements Lean 4’s kernel in Java and offers the same type theory as Clojure forms, so definitions and theorems written in Clojure are checked against the same libraries, such as Mathlib. That lets us add types and proofs to existing Clojure code gradually, one function at a time, and call the results as ordinary Clojure. The SDK described here complements it for code that already exists in Lean. The two can run in the same application, although loading executable code with the SDK does not import its theorems into the Ansatz kernel.

What comes next

This release is a starting point, and two goals guide the next ones. The first is speed. Today the SDK interprets Lean’s compiled code, and the JVM’s just-in-time compiler optimises that interpreter. Our near-term aim is to be competitive with Lean’s own interpreter, lean --run. For workloads that need more, the direction is to compile and specialise Lean code for the JVM, aiming at compiled Lean performance for selected workloads. We have not yet published representative benchmarks. Startup is already cheaper: the SDK’s own Clojure layer ships precompiled, so a fresh JVM pays Clojure’s startup cost but does not compile the SDK.

The second goal is coverage of Lean’s language, standard libraries and runtime services. The current release supports common application types, custom datatypes, explicit IO services and Lean’s Nat, Int and unsigned fixed-width arithmetic. A program that needs an operation outside this profile is refused before it starts, not partway through. Public entry points use closed first-order signatures, and tasks, public forks and Mathlib-heavy projects remain outside this release. A prepared project may contain at most 128 modules besides Lean’s core library, Init. The compatibility ledger records the details.

Lean libraries written partly in C need more work still. Java’s Foreign Function and Memory API, available in JDK 22 and later, offers a route to native libraries. However, Lean’s object representation and ownership rules still need explicit bridges, and this release includes none.

The next useful compatibility target may be your application. Bring us the Lean project and the functions you want to run, together with the JVM environment and workload that matter to you. That tells us which language features, runtime operations and performance requirements to prioritise.

Permalink

Babashka vs cream

I&aposve given a lot of thought to whether cream should affect babashka. Cream is based on Crema, a native-image compatible bytecode interpreter, basically a JVM inside of native-image.

What it concretely would mean to move babashka to Crema is that we could run virtually any Clojure program without changes. This is exciting.

Crema is an impressive piece of engineering. Native-image could not define classes at run time before, which is the reason SCI exists. With Crema, eval, require and Java libraries from jars just work in a native binary, and cream starts as fast as babashka.

But it has serious downsides compared to using SCI.

Load time

The main downside is the load time of Clojure sources. Evaluating a fn in cream runs the Clojure compiler and emits a class, which just takes more work. Compilation accounts for about 1000ms of the load. Defining the 1670 classes takes the rest. SCI interprets the forms and skips both steps.

Here is the time to load malli 0.16.4 from source:

DEPS=&apos{:deps {metosin/malli {:mvn/version "0.16.4"}}}&apos
CP=$(clojure -Spath -Sdeps "$DEPS")
E="(time (require &aposmalli.core &aposmalli.generator))"

bb -cp "$CP" -e "$E"
clojure -Sdeps "$DEPS" -M -e "$E"
./cream -Scp "$CP" -M -e "$E"
bb     ## 0.06s
JVM    ############################ 0.93s
cream  ######################################## 1.34s
requirewhole process
bb55ms0.06s
JVM610ms0.93s
cream1310ms1.34s

Babashka on SCI is just way faster for loading bigger libraries from source and then executing some expressions, which is commonly called "scripting". When loading a bigger lib like malli, you might as well just use a normal JVM instead of cream.

The gap shrinks with smaller libraries, but it stays quite significant. Babashka loads medley 9x faster than cream and camel-snake-kebab 15x faster.

Startup is the same in both babashka and cream: about 10ms.

Cream loads malli in about 330ms from AOT-compiled classes, and a cache could do that for you. But caches are a hassle, and babashka is faster than the AOT case anyway.

Performance

Performance of cream is better than bb for hot JIT-ed code, but worse than a JVM.

bbcreamJVM
malli validate, 1M calls300ms70ms35ms
fib 35, hot1230ms815ms73ms
fib 27, cold26ms46ms5ms
(reduce + (map inc (range 10000000)))157ms725ms80ms

Cream&aposs JIT makes no difference in the last row, it takes 725ms with the JIT off too.

Cream&aposs JIT is still warming up during fib 27. For a script, the code does not run long enough for that JIT to pay off. Since the core feature of bb is scripting with good startup time, bb + SCI wins here.

Binary size and build time

Another major downside of including Crema is the binary size. I built bb with Crema and the binary goes up from 75MB to about 200MB. Cream itself is about 225MB. Maybe this isn&apost that important, but compile times also go up. I expect double or triple in CI, which isn&apost fun at all when you build a binary for several OSes. SCI is just way more light weight here too.

What bb gives up

Code that needs real JVM classes, or a Java library that bb doesn&apost ship, stays out of reach. Pods cover part of that. For the rest there is the JVM, or cream, which sits in the gray area between babashka and JVM Clojure.

Conclusion

This is not a verdict on Crema, which is still experimental and improving fast. A native binary with full Clojure and a working JIT is very interesting and exciting. Most of the load time comes from the Clojure compiler. It&aposs just not the right trade-off for babashka today. Perhaps with an experimental fork of Clojure that reduces the number of emitted classes, this picture will change. I do use a fork of Clojure for cream, but it only has very mild changes to make it work with Crema. I haven&apost changed anything significant in the compiler.

For a while I thought SCI would be replaced with Crema in bb, but it still wins for the scripting use case. And babashka is all about scripting. I will keep improving SCI to make it both load and run faster over time. I was planning to do that anyway. Now that SCI isn&apost going away any time soon, I&aposm even more determined to make babashka the best possible scripting experience for Clojure.

You can view more numbers in the cream README.

Permalink

Babashka vs cream

I&aposve given a lot of thought to whether cream should affect babashka. Cream is based on Crema, a native-image compatible bytecode interpreter, basically a JVM inside of native-image.

What it concretely would mean to move babashka to Crema is that we could run virtually any Clojure program without changes. This is exciting.

Crema is an impressive piece of engineering. Native-image could not define classes at run time before, which is the reason SCI exists. Instead of using eval, SCI allows you to interpret Clojure code inside of a native image. With Crema, eval, require and Java libraries just work in a native binary using the normal Clojure compiler (with mild patches). That&aposs the approach that cream takes.

But it has serious downsides compared to using SCI.

Load time

The main downside is the load time of Clojure sources. Evaluating a fn in cream runs the Clojure compiler and emits a class which then has to be loaded by a classloader. This takes longer than it takes SCI to construct an interpreted function.

Here is the time to load malli 0.16.4 from source:

DEPS=&apos{:deps {metosin/malli {:mvn/version "0.16.4"}}}&apos
CP=$(clojure -Spath -Sdeps "$DEPS")
E="(time (require &aposmalli.core &aposmalli.generator))"

bb -cp "$CP" -e "$E"
clojure -Sdeps "$DEPS" -M -e "$E"
./cream -Scp "$CP" -M -e "$E"
bb     ## 0.06s
JVM    ############################ 0.93s
cream  ######################################## 1.34s
requirewhole process
bb55ms0.06s
JVM610ms0.93s
cream1310ms1.34s

Babashka on SCI is just way faster for loading bigger libraries from source and then executing some expressions, which is commonly called "scripting". When loading a bigger lib like malli, you might as well just use a normal JVM instead of cream.

The gap shrinks with smaller libraries, but it stays quite significant. Babashka loads medley 9x faster than cream and camel-snake-kebab 15x faster.

Startup is the same in both babashka and cream: about 10ms.

With AOT-compiled classes, malli loads in about 300ms in both cream and the JVM. Cream spends 10ms on startup and 290ms on loading classes, the JVM 200ms on startup and 100ms on loading classes. Run-time performance is worse in cream, so if you AOT anyway, the JVM is the better choice. Cream could AOT-compile libraries on first use and cache the classes, but caches are a hassle, and babashka is faster than the AOT case anyway. So it seems something Crema-based in between bb and a JVM doesn&apost offer us much benefit here.

Performance

Performance of cream is better than bb for hot JIT-ed code, but worse than a JVM.

bbcreamJVM
malli validate, 1M calls300ms70ms35ms
fib 35, hot1230ms815ms73ms
fib 27, cold26ms46ms5ms
(reduce + (map inc (range 10000000)))157ms725ms80ms

Cream&aposs JIT makes no difference in the last row, it takes 725ms with the JIT off too.

Cream&aposs JIT is still warming up during fib 27. For a script, the code does not run long enough for that JIT to pay off. Since the core feature of bb is scripting with good startup time, bb + SCI wins here.

Binary size and build time

Another major downside of including Crema is the binary size. Even when I only use Crema for loading Java classes, as described below, the binary goes up from 75MB to about 200MB. Cream itself is about 225MB. Maybe this isn&apost that important, but compile times also go up. I expect double or triple in CI, which isn&apost fun at all when you build a binary for several OSes. SCI is just way more light weight here too.

Combining SCI and Crema

Another option is to combine both approaches: SCI keeps interpreting Clojure and Crema is only used to load Java classes that are not in the image. I tried this on the crema-hybrid branch and it works for many libraries. A tiny script with BouncyCastle, commons-compress or Caffeine takes about 40ms for the whole process, against about 320ms on the JVM. Loading malli is slow in cream because Clojure compiles every form and defines about 1670 classes. A Java library is already compiled, and a single call only loads the classes it needs.

Load time of Clojure code is not the problem here, since SCI still does that. The main downside of this hybrid is that a JDK package that wasn&apost preserved at build time aborts the process instead of throwing an exception, and you only find out by running the library. Of course we can protect users from this with extensive testing.

The binary size and build time downsides are the same as for cream. The 200MB applies to every user, even for scripts that never load a class, because Crema needs a table of all compiled methods and whole preserved JDK packages.

If GraalVM shrinks that table or turns the abort into an exception, I&aposll look at this again.

Babashka + SCI&aposs trade-off

Without Crema, babashka can&apost load JVM classes that aren&apost baked into the native image. However, babashka is extensible via pods and more recently FFI. Babashka code is fully portable with JVM Clojure so it should always be easy to migrate a bb scripting project to a full-blown Clojure JVM project, when a project outgrows the light-weight babashka runtime.

Conclusion

This article is not a verdict on Crema, which is still experimental and improving fast. A native binary with full Clojure and a working JIT is very interesting and exciting. Most of the load time comes from the Clojure compiler. It&aposs just not the right trade-off for babashka today. Perhaps with an experimental fork of Clojure that reduces the number of emitted classes, this picture will change. I do use a fork of Clojure for cream, but it only has very mild changes to make it work with Crema. I haven&apost changed anything significant in the compiler.

The hybrid is more tempting, since it keeps SCI&aposs load time and loads Java libraries faster than a JVM. But today it costs every user about 125MB and slower builds, and a library can still abort the process. So for now it stays on a branch.

For a while I thought SCI would be replaced with Crema in bb, but it still wins for the scripting use case. And babashka is all about scripting. I will keep improving SCI to make it both load and run faster over time. I was planning to do that anyway but the above gives me even more determination to make the babashka + SCI combination the best possible scripting experience for Clojure.

You can view more numbers in the cream README.

Permalink

kmet - Coding Agent Written in Clojure

kmet - Coding Agent Written in Clojure

2026-11-03 - A coding agent written in Clojure, the language I said couldn't do it - my own TUI stack, a Cordis-style kernel where everything is a plugin, a model that writes Clojure inside a sandbox, and a daily driver at the end instead of a retreat
Keywords: clojure, babashka, jolt, coding-agent, agentic-loop, tui, hiccup, cordis, sci

Permalink

Scittlecastr: The Scittling

Arab woman listening to Scittle podcast with earphones and dancing outdoors

This post was entirely written by a human, with no assistance from LLMs or other "AI" technology such as pervert glasses or autonomous weapon systems.

I&aposve mentioned cljcastr here before, but in a very tongue in cheek fashion. Since then, cljcastr has turned into a serious project to create a set of tools for producing and self-hosting podcasts. The project has two simple principles:

  1. No surveillance. Ever. cljcastr runs on your computer and only your computer. It does not connect to any external services, store data on the cloud (AKA other people&aposs computers), or track you in any way, shape, or form.
  2. No AI. All cljcastr code is written by hand, by humans. Nothing in this repository has been AI generated, and no AI generated pull requests will even be considered for inclusion.

cljcastr is hosted on Codeberg at jmglov/cljcastr.

In this blog post, I&aposm going to demonstrate the cljcastr ethos by implementing a simple podcast player in ClojureScript, using the mighty Scittle.

Building the skeleton

To get started, we&aposre going to need a Babashka project set up to serve files from a local webserver. Let&aposs create a cljcastr directory:

mkdir cljcastr

And now pop a bb.edn into it:

{:deps {io.github.babashka/sci.nrepl
        {:git/sha "80ec956af5e6610b040089ba62eecb5d223e9e76"}
        io.github.babashka/http-server
        {:git/sha "b38c1f16ad2c618adae2c3b102a5520c261a7dd3"}}

 :tasks
 {http-server {:doc "Starts http server for serving static files"
               :requires ([babashka.http-server :as http])
               :task (do (http/serve {:port 1341 :dir "resources/public"})
                         (println "Serving static assets at http://localhost:1341"))}

  browser-nrepl {:doc "Start browser nREPL"
                 :requires ([sci.nrepl.browser-server :as bp])
                 :task (bp/start! {})}

  -dev {:depends [browser-nrepl http-server]}

  dev {:doc "Development build. Starts webserver and watches for changes."
       :task (do (run &apos-dev {:parallel true})
                 (deref (promise)))}}
}

Now create a resources/public directory:

mkdir -p resources/public/

Drop an index.html in that directory:

<html xmlns="http://www.w3.org/1999/xhtml">

<head>
  <title>cljcastr episode player</title>

  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js" type="application/javascript"></script>
  <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>
  <script src="https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js" type="application/javascript"></script>

  <script type="application/x-scittle" src="player.cljs"></script>
</head>

<body>
</body>

</html>

And finally, a player.cljs in the same directory that does naught but declare a namespace:

(ns cljcastr.player)

(js/console.log "Ready to play!")

Thus equipped, invoke Babashka from the top-level cljcastr directory thusly:

bb dev

Babashka will helpfully tell you where you can find your player:

Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...

If you browse to localhost:1341, you&aposll see an amazingly blank page titled "cljcastr episode player". If you open the JavaScript console in dev tools (Ctrl + Shift + i in many browsers), you should see the player reporting for duty:

  Ready to play!                                                    core.cljs:80
> 

So let&aposs play!

Interactive development

In addition to the HTTP server serving up static files from our resources/public directory, we have an nREPL server listening on port 1339 and a websocket server on port 1340. When we loaded the index.html webpage in the browser, scittle.nrepl opened a websocket connection to port 1340 and started listening for commands. In order to send it some commands, we will need to connect an nREPL client to the nREPL server on port 1339. Let&aposs do it!

This section assumes that you are a reasonable person and are thus using Emacs with CIDER, but if you&aposve unreasonably chosen to use a lesser editor or one of those new-fangled "IDEs" like Cursive or Calva, you probably know how to operate nREPL in that editor, so just translate accordingly.

First, we open up player.cljs in our editor. Next, we use CIDER&aposs cider-connect-cljs command (C-c M-C) to connect to localhost on port 1339, choosing REPL type nbb when prompted. CIDER should open a REPL buffer that says something like this:

;; Connected to nREPL server - nrepl://localhost:1339
;; CIDER 1.12.0 (Split)
;;
;; ClojureScript REPL type: nbb
;;
nil> 

Now we&aposre all set up for interactive development, so let&aposs interact. First, execute cider-load-buffer (C-c C-k) in the player.cljs buffer. If you&aposre watching the JS console in your browser, you&aposll see something like the following pop up:

  Ready to play!                                                   nrepl.cljs:10
> 

And meanwhile in your terminal, you&aposll see the nREPL server log some output:

:msg "{:value \"nil\", :id \"246\", :session \"e443955d-e285-4d5b-bbde-9b7f21f1561c\", :ns \"cljcastr.player\"}"
:msg "{:status [\"done\"], :id \"246\", :session \"e443955d-e285-4d5b-bbde-9b7f21f1561c\", :ns \"cljcastr.player\"}"

We&aposve now completed the circle of LISP, meaning we can control everything on our player page with ClojureScript! 💥

Let&aposs try some simple arithmetic just to get a sense of the power at our fingertips. In the player.cljs buffer, we&aposll add a Rich comment and some code:

(comment

  (+ 1 2)

  )

Positioning our cursor right after the (+ 1 2) form, we hit C-c C-v f c e to execute cider-pprint-eval-last-sexp-to-comment, and are rewarded by a nice comment being inserted on the next line:

(comment

  (+ 1 2)
  ;; => 3

  )

We can also print this result to the browser console by adding another line inside the (comment) form:

(comment

  ;; ...
  
  (js/console.log (+ 1 2))

  )

If you&aposre using CIDER with an autocomplete setup, you should actually see completion candidates popping up as you type. 🤯

If you position the cursor after the form and evaluate it again with C-c C-v f c e, you&aposll get a comment on the next line saying

  ;; => nil

This is somewhat underwhelming, but if you take a peek at your browser console, you&aposll be more satisfied:

  3                                                                nrepl.cljs:10
> 

Can we have our cake and eat it too? (Note that from here on, I&aposll assume you&aposre whacking C-c C-v f c e after the new form you add, which will produce the ;; => comment on the next line.)

(comment

  ;; ...
  
  (doto (+ 1 2)
    js/console.log)
  ;; => 3

  )

Now we&aposre cooking with biofuel!

Let&aposs try something a little more complex:

(comment

  ;; ...
  
  (doto (map inc (range 5))
    js/console.log)
  ;; => (1 2 3 4 5)

  )

This is great, but what in the world is happening over in the browser console?

  ‣ $APP.gg {J: null, na: null, I: null, F: 32374988, ma: ƒ, …}    nrepl.cljs:10
> 

If we click on the ‣, things get even wilder:

  ‣ $APP.gg {J: null, na: null, I: null, F: 32374988, ma: ƒ, …}    nrepl.cljs:10
      F: 32374988
      I: null
      J: null
      M: 1
      ma: null
    ‣ na: $APP.lg {hc: kg, Zc: $…P.gg, J: null, I: null, F: 31850732, …}
    ‣ [[Prototype]]: Object
>

What we&aposre seeing is a JavaScript representation of a ClojureScript vector. This is of course super cool and all, but not so readable (unless you&aposre Rich Hickey and you don&apost even see the persistent data structures but just the woman in the red dress). Let&aposs see if we can do better:

(comment

  ;; ...
  
  (doto (->> (map inc (range 5))
             clj->js)
    js/console.log)
  ;; => #js [1 2 3 4 5]

  )

The browser console is looking good!

  ‣ (5) [1, 2, 3, 4, 5]                                            nrepl.cljs:10

But life inside our (comment) form is less idyllic. What&aposs up with this nonsense?

  ;; => #js [1 2 3 4 5]

Now we&aposre seeing ClojureScript&aposs JavaScript data literal. 😅

Let&aposs try one more thing:

(comment

  ;; ...
  
  (let [res (map inc (range 5))]
    (js/console.log (clj->js res))
    res)
  ;; => (1 2 3 4 5)

  )

That&aposs much better! Now how about we extract this to a function so we can use it to log stuff?

(ns cljcastr.player)

(defn log [v]
  (js/console.log (clj->js v))
  v)

(comment

  ;; ...

  (log (map inc (range 5)))
  ;; => (1 2 3 4 5)

  )

Good stuff. Thus equipped, we can start sprucing up this empty webpage.

Getting down to brass HTML

Let&aposs add an h1 HTML element to the page to let users know what&aposs up. The obvious way to do this would be to open up index.html and drop in something like this:

<body>
  <h1>cljcastr player</h1>
</body>

However, this would require a page reload for us to see the changes, and that would blow away the state we&aposve carefully built up in our REPL. 😭

Let&aposs see if we can do this in the REPL instead. The basic idea is this:

  1. Create an h1 element.
  2. Set its inner text to "cljcastr player".
  3. Grab the body element that we helpfully put in our HTML page skeleton.
  4. Add the new h1 element as a child of the body element.

And if we were to call an imaginary eng->clj function, that would look something like this:

(comment

  ;; ...
  
  ;; 1. Create an **h1** element.
  (def h1 (js/document.createElement "h1"))
  ;; => #&aposcljcastr.player/h1

  ;; 2. Set its inner text to "cljcastr player".
  (set! (.-textContent h1) "cljcastr player")
  ;; => "cljcastr player"

  ;; 3. Grab the **body** element that we helpfully put in our HTML page skeleton.
  (def body (js/document.querySelector "body"))
  ;; => #&aposcljcastr.player/body

  ;; 4. Add the new **h1** element as a child of the **body** element.
  (.appendChild body h1)
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  )

Et voilà! Our boring page has been transformed into an amazing feast for the eyes!

Before moving on, let&aposs take a brief detour into a new JavaScript literal:

  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

This is a bit opaque, but we can always feed it to our log function to make it nicely inspectable in the console:

(comment

  ;; ...
  
  (log h1)
  ;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]

  )

The console will render this as:

  <h1>cljcastr player</h1>                                         nrepl.cljs:10

Play that funky music

Let&aposs find ourselves the URL of a random podcast episode and add an audio element to the page:

(comment

  ;; ...

  (def audio (js/document.createElement "audio"))
  ;; => #&aposcljcastr.player/audio

  (set! (.-controls audio) true)
  ;; => true

  (set! (.-src audio)
        "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3")

  (->> audio
       (.appendChild body))
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  (log audio)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

)

Now we should see an audio player pop up on screen!

Simple podcast player with a heading and an audio player

And if we click play, we hear the defn theme music.

We could declare victory right here, but we&aposre missing a few things that a podcast player usually has, such as:

  1. Sweet custom controls instead of the ugly HTML5 default audio player.
  2. The ability to play any podcast episode, not just one we&aposve helpfully downloaded ahead of time.
  3. If a transcript is available, display it with clickable timestamps.

Let&aposs see what we can do about that.

Escaping from vanilla

The standard HTML audio control is functional, but a bit boring. In our hubris, we decide we can do way better than that!

Let&aposs look at a typical podcast player for inspiration:

Overcast podcast player

It looks like we&aposll want the following buttons:

  • Play
  • Pause
  • Skip backward
  • Skip forward
  • Playback speed

Let&aposs see if we can find some nice vector graphics for our buttons. SVG Repo is a great place to start, and a search there for "play" turns up a nice looking SVG named Play Circle Fill SVG, which is provided under the MIT Licence. That looks promising!

Better yet, the SVG is part of a collection called [Framework7 Filled Icons] (https://www.svgrepo.com/collection/framework7-filled-icons/). Let&aposs click into that and see if we can find some SVGs for other useful media player buttons. Searching within the collection, we find a nice Pause button, but nothing for the other three we need. 😭

No matter! We widen our search to all of SVG Repo and find nice graphics for the rest. Here&aposs where we land:

Let&aposs create a set of HTML images for each. We&aposll start by defining a map to describe them:

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
             :label "Back {{seek-dur-sec}} seconds"}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #&aposcljcastr.player/playback-controls

  (def seek-dur-sec 15)
  ;; => #&aposcljcastr.player/seek-dur-sec

  )

Now we can map over the map (yeah yeah, I know) to create our img elements:

(comment

  ;; ...

  (def playback-buttons
    (->> playback-controls
         (map (fn [[control-name {:keys [src label]}]]
                (let [el (js/document.createElement "img")]
                  (set! (.-id el) (str "button-" (name control-name)))
                  (set! (.-classList el) "cljcastr playback-icon")
                  (set! (.-src el) src)
                  (set! (.-draggable el) false)
                  el)))
         log))
  ;; => #&aposcljcastr.player/playback-buttons

  )

Note that we&aposre adding a playback-icon class to the element in addition to our standard cljcastr class, and also giving the element an ID so it&aposs easy to grab it with a CSS selector later. We also set the draggable attribute to false to prevent drag-and-drop silliness from happening.

Now, how about a container to hold them?

(comment

  ;; ...

  (def playback-controls-container
    (-> (let [el (js/document.createElement "div")]
          (set! (.-id el) "playback-controls")
          (set! (.-classList el) "cljcastr")
          el)
        log))
  ;; => #&aposcljcastr.player/playback-controls-container

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  )

Finally, let&aposs display the controls!

(comment

  ;; ...

  (.appendChild body playback-controls-container)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with a huge play button

Let&aposs set the width of the images to a more reasonable size:

(comment

  ;; ...

  (doseq [el playback-buttons]
    (set! (.-style el) "width: 35px"))
  ;; => nil

  )

That&aposs much better! However, this black and white page is starting to bum us out.

Rainbows and unicorns

Since our controls are SVGs, we can use the magic of CSS filters to colour them.

Let&aposs start by picking a colour, and what better colour for the sake of this demo than a lovely amber sort of shade called #f7931e. Of course, CSS filters can&apost be specified in something as prosaic as hex colour codes, so we&aposll use a magic CodePen thingy to generate a filter from our hex code. (We could also port the JavaScript from this spectacular StackOverflow answer to ClojureScript, but we kinda don&apost feel like it.)

What we come up with is this:

filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);

We can now add this as a style to our img tags:

(comment

  ;; ...

  (doseq [el playback-buttons]
    (set! (.-style el)
          (str "width: 35px; filter: invert(59%) sepia(92%) saturate(611%) "
               "hue-rotate(343deg) brightness(97%) contrast(99%);")))
  ;; => nil

  )

Better!

Player with a orange playback controls

One thing that&aposs slightly annoying is that we had to remember to include to include the width style when we added the filters. Why don&apost we write some code that adds a style to an existing element whilst preserving existing styles?

First, let&aposs help ourselves out by pulling in clojure.string:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...

(comment

  ;; ...

  ;; What do styles look like?
  (-> playback-buttons
      first
      (.getAttribute "style"))
  ;; => "width: 35px; filter: invert(59%) sepia(92%) saturate(611%)
  ;;     hue-rotate(343deg) brightness(97%) contrast(99%);"

  ;; Parse into a list of styles
  (-> playback-buttons
      first
      (.getAttribute "style")
      (str/split #";\s+"))
  ;; => ["width: 35px"
  ;;     "filter: invert(59%) sepia(92%) saturate(611%)
  ;;      hue-rotate(343deg) brightness(97%) contrast(99%);"]

  ;; Turn it into a map
  (let [styles (-> playback-buttons
                   first
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (map #(let [[k v] (str/split % #":\s+")]
                 [(keyword k) v]))
         (into {})))
  ;; => {:width "35px",
  ;;     :filter "invert(59%) sepia(92%) saturate(611%)
  ;;              hue-rotate(343deg) brightness(97%) contrast(99%);"}

  )

Now we have a thing that we can easily turn into a function!

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn log ...)

(defn get-styles
  "Returns the inline styles of an element as a map of style name to value."
  [el]
  (let [styles (-> el
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (map #(let [[k v] (str/split % #":\s+")]
                 [(keyword k) v]))
         (into {}))))

;; ...

(comment

  ;; ...

  (-> playback-buttons
      first
      get-styles)
  ;; => {:width "35px",
  ;;     :filter "invert(59%) sepia(92%) saturate(611%)
  ;;              hue-rotate(343deg) brightness(97%) contrast(99%);"}

  )

How about going the other direction, i.e. turning a map of styles into a style string?

(comment

  ;; ...

  (->> {:width "35px", :height "35px"}
       (map (fn [[k v]]
              (str (name k) ": " v)))
       (str/join "; "))
  ;; => "width: 35px; height: 35px"

  )

From this, &apostis merely a hop, skip, and a jump to write a function that sets the styles of an element:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn get-styles ...)

(defn set-styles!
  "Sets the styles of an element from a map of style name to value."
  [el styles]
  (let [styles-str (->> styles
                        (map (fn [[k v]]
                               (str (name k) ": " v)))
                        (str/join "; "))]
    (set! (.-style el) styles-str))
  el)

;; ...

(comment

  ;; ...

  ;; Back to black!
  (doseq [el playback-buttons]
    (set-styles! el {:width "35px"}))
  ;; => nil

  )

Finally, let&aposs put this together and create a function that adds a style instead of replacing all the existing ones:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-styles! ...)

(defn add-style!
  "Adds a style to an element"
  [el style-name style-value]
  (let [styles (get-styles el)]
    (set-styles! el (assoc styles (name style-name) style-value)))
  el)

;; ...

(comment

  ;; ...

  ;; Amber is the new black
  (doseq [el playback-buttons]
    (add-style! el :filter
                (str "invert(59%) sepia(92%) saturate(611%) "
                     "hue-rotate(343deg) brightness(97%) contrast(99%)")))
  ;; => nil

  )

Play it again, Sam

Now that we have visually stunning buttons, let&aposs actually make them do something.

Or rather, let&aposs do something that allows us to make them do something. 😅

Remember how we added a cljcastr class to the buttons so we could easier grab them with js/document.querySelector? Well, we didn&apost do this to our poor audio element, which will now cause us a problem:

(comment

  ;; ...

  (-> (js/document.querySelector "audio")
      log)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

Seems OK in the REPL, but if we look at the JS console, things (might not, depending on your browser and which extensions you have installed) look so good:

  <audio class="audio-for-speech" src></audio>                     nrepl.cljs:10

Seems like my browser is set up with a screen reader, which is very nice for accessibility, but not nice for Clojure programmers who didn&apost get enough hammock time. Let&aposs add a class so we can use a more precise selector:

(comment

  ;; ...

  (set! (.-classList audio) "cljcastr")
  ;; => "cljcastr"

  (-> (js/document.querySelector "audio.cljcastr")
      log)
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

And now the console looks more like what we expected:

  <audio class="cljcastr" controls                                 nrepl.cljs:10
  src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3">
  </audio>

Now we can proceed with making our buttons come alive:

(comment

  ;; ...

  ;; Grab the play button
  (def play-button (js/document.querySelector "#button-play"))
  ;; => #&aposcljcastr.player/play-button

  ;; Add a click handler
  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  )

If we click the button, we hear the podcast start playing, and can see the playback position in the standard HTML audio element above.

Player playing an episode

Now we can do the same for the pause button:

(comment

  ;; ...

  ;; Grab the pause button
  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #&aposcljcastr.player/play-button

  ;; Add a click handler
  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  )

That was easy! Skipping forward will be a little more interesting, since there&aposs no JS function to tell the audio object "skip forward x seconds", there&aposs only a currentTime property that we can query and set:

(comment

  ;; ...

  (.-currentTime audio)
  ;; => 29.396358

  (set! (.-currentTime audio) 15.0)
  ;; => 15

  )

Given this, it&aposs not too hard to figure out how to skip forward and backward an arbitrary number of seconds:

(comment

  ;; ...

  ;; Forward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (+ cur seek-dur-sec)))
  ;; => 30

  ;; Backward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (- cur seek-dur-sec)))
  ;; => 15

  )

It&aposs probably a good idea not to skip forward past the end of the episode or backward past the beginning, so let&aposs see if we can put a stop to that silliness:

(comment

  ;; ...

  ;; Forward with guard
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (min (+ cur seek-dur-sec)
                                     (.-duration audio))))
  ;; => 30

  ;; The end is nigh!
  (set! (.-currentTime audio) (- (.-duration audio) 5))
  ;; => 5365.949958

  ;; Don&apost worry, all is well
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (min (+ cur seek-dur-sec)
                                     (.-duration audio))))
  ;; => 5370.949958

  ;; Hitting 88 miles per hour
  (set! (.-currentTime audio) 5)
  ;; => 5

  ;; Backward
  (let [cur (.-currentTime audio)]
    (set! (.-currentTime audio) (max (- cur seek-dur-sec)
                                     0)))
  ;; => 0

  )

Armed with this code, let&aposs make some functions:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn skip-forward! [audio-el sec]
  (let [cur (.-currentTime audio-el)]
    (set! (.-currentTime audio-el) (min (+ cur sec)
                                        (.-duration audio-el)))))

(defn skip-backward! [audio-el sec]
  (let [cur (.-currentTime audio-el)]
    (set! (.-currentTime audio-el) (max (- cur sec)
                                        0))))

;; ...

(comment

  ;; ...

  (skip-forward! audio seek-dur-sec)
  ;; => 15

  (skip-backward! audio seek-dur-sec)
  ;; => 0

  )

Now we can wire up the skip buttons as well:

(comment

  ;; ...

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #&aposcljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #&aposcljcastr.player/backward-button

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! audio seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! audio seek-dur-sec)))
  ;; => nil

  )

Look at this! All of our buttons work! (Except the playback speed one, but that&aposs a little more complicated, so let&aposs save it for later. 😉)

The only thing that&aposs obviously wrong with this amazing player is that it has two sets of controls, the standard HTML one and our amazing buttons. There&aposs also something that&aposs not obviously wrong to most readers, unless they happen to be using a screen reader: the accessibility of our buttons sucks! In fact, the only way we know they&aposre buttons is by their appearance, because we&aposre using img elements, which are semantically just images. They could be images of an elephant for all a screen reader knows.

Let&aposs see what we can do about that!

A11y spells ally

From the excellent MDN article on Accessibility:

Accessibility (often abbreviated to A11y — as in, "a", then 11 characters, and then "y") in web development means enabling as many people as possible to use websites, even when those people&aposs abilities are limited in some way.

For many people, technology makes things easier. For people with disabilities, technology makes things possible. Accessibility means developing content to be as accessible as possible, no matter an individual&aposs physical and cognitive abilities and how they access the web.

The W3C Accessibility Initiative puts it in even more inspiring terms:

The Web is fundamentally designed to work for all people, whatever their hardware, software, language, location, or ability. When the Web meets this goal, it is accessible to people with a diverse range of hearing, movement, sight, and cognitive ability.

We certainly want all people to adopt our superior podcast player, so let&aposs make sure it works for them, using the power of ARIA!

Arya Stark after killing the Frey soldier with a knife

No, not that one. 😉

This one: ARIA.

If we wrap each of our img elements in a button element, we&aposre now conveying semantic meaning to a screen reader (or any other user agent, for that matter; imagine a script that is trying to identify all the buttons on a page... such as the one we&aposre in the process of writing). And if we set that button&aposs aria-label attribute, we&aposre now identifying what the button is meant to do.

Remember the :label key we included in our playback-controls map but never used way back when?

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
             :label "Back {{seek-dur-sec}} seconds"}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #&aposcljcastr.player/playback-controls

  )

Turns out we had accessibility in mind all along!

Let&aposs try wrapping a button:

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (js/document.createElement "button")
          img-el (js/document.createElement "img")]
      (set! (.-id button-el) button-id)
      (set! (.-classList button-el) "cljcastr")
      (set! (.-ariaLabel button-el) label)
      (set! (.-id img-el) (str button-id "-icon"))
      (set! (.-classList img-el) "cljcastr playback-icon")
      (set! (.-src img-el) src)
      (set! (.-draggable img-el) false)
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

All of this set! stuff is starting to get annoying. Why don&apost we write a nice function that we can call to give us back a fully baked DOM element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag attrs] :as args}]
  (let [el (js/document.createElement tag)]
    (doseq [[attr v] attrs]
      (.setAttribute el (name attr) v))
    el))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (create-el {:tag "button"
                                :attrs {:id button-id
                                        :class "cljcastr"
                                        :aria-label label}})
          img-el (create-el {:tag "img"
                             :attrs {:id (str button-id "-icon")
                                     :class "cljcastr playback-icon"
                                     :src src
                                     :draggable false}})]
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

This looks pretty reasonable in the JS console:

  ‣ <button class="cljcastr" id="button-play"                      nrepl.cljs:10
            aria-label="Forward {{seek-dur-sec}} seconds">
      <img class="cljcastr playback-icon" id="button-play-icon"
           src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
           draggable="false">
    </button>

Except this bit, that is:

aria-label="Forward {{seek-dur-sec}} seconds"

Looks like we got a bit clever there with some Selmer-style templating to allow for changing the number of seconds we&aposre skipping around. Let&aposs quickly figure out how to interpolate such strings:

(comment

  ;; ...

  (let [s "Something {{something}} something"]
    (str/replace s "{{something}}" "SOMETHING"))
  ;; => "Something SOMETHING something"

  (let [s "Something {{something}} something"
        ctx {:something "SOMETHING"}]
    (str/replace s #"\{\{(something)\}\}" (fn [[_ k]] (get ctx (keyword k)))))
  ;; => "Something SOMETHING something"

  )

This may appear slightly inscrutable at first glance, but if we look at the documentation for clojure.string/replace, we see that the replacement argument can actually be a function, in which case it&aposs passed the match as an argument and whatever it returns is the replacement string. When the match contains a capturing group, the argument to the function is a vector of [match group1 group2 ...]. For example:

(comment

  ;; ...

  (let [s "It&aposs easy as 123, ABC, baby you and me"]
    (str/replace s #"(\d)+.+([A-Z]+)"
                 (fn [match-vector] (str "<<<" (pr-str match-vector) ">>>"))))
  ;; => "It&aposs easy as <<<[\"123, ABC\" \"3\" \"C\"]>>>, baby you and me"

  )

Let&aposs turn this into a proper function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; (defn log ...)

(defn render
  "Given a string `s` and a context map `ctx`, replaces template variables of the
   form {{k}} with the value of the corresponding key in the context map. If
   the key `k` doesn&apost exist in the context map, the template variable won&apost be
   replaced (i.e. \"{{foo}}\" will remain \"{{foo}}\" in the interpolated
   string)."
  [s ctx]
  (str/replace s #"\{\{([^}]+)\}\}"
               (fn [[match k]] (get ctx (keyword k) match))))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (render "foo {{bar}} baz {{blah}} {{barf}}" {:bar 27, :blah 42})
  ;; => "foo 27 baz 42 {{barf}}"

  )

Now we can make use of our new render function in our button-wrapping code:

(comment

  ;; ...

  (let [button-name :play
        {:keys [src label]} {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                             :label "Forward {{seek-dur-sec}} seconds"}]
    (let [button-id (str "button-" (name button-name))
          button-el (create-el {:tag "button"
                                :attrs {:id button-id
                                        :class "cljcastr"
                                        :aria-label (render label {:seek-dur-sec seek-dur-sec})}})
          img-el (create-el {:tag "img"
                             :attrs {:id (str button-id "-icon")
                                     :class "cljcastr playback-icon"
                                     :src src
                                     :draggable false}})]
      (.appendChild button-el img-el)
      (log button-el)))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

Quoth the console:

  ‣ <button class="cljcastr" id="button-forward"                      nrepl.cljs:10
            aria-label="Forward 15 seconds">
      <img class="cljcastr playback-icon" id="button-forward-icon"
           src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
           draggable="false">
    </button>

Now we can extract a nice function to create a button:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))
         button-el (create-el {:tag "button"
                               :attrs {:id button-id
                                       :class (render "cljcastr {{button-class}}" ctx)
                                       :aria-label (render label ctx)}})
         img-el (create-el {:tag "img"
                            :attrs {:id (str button-id "-icon")
                                    :class (render "cljcastr {{img-class}}" ctx)
                                    :src src
                                    :draggable false}})]
     (.addEventListener button-el "click" click-handler)
     (.appendChild button-el img-el)
     button-el)))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (->> (create-button :forward
                      {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
                       :label "Forward {{seek-dur-sec}} seconds"}
                      {:seek-dur-sec seek-dur-sec
                       :button-class "playback-button"
                       :img-class "playback-icon"})
       log)
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

We&aposve pulled another nice thing out of Clojure&aposs bag of tricks: multi-arity functions:


(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src]} ctx]
   ;; ...
  ))

This allows us to call create-button without a context:

(create-button :play {:label "Play", :src "play.svg"})

This will call the version of create-button that takes two arguments, which in turn calls the three-argument version, passing along the button-name and button arguments as is, and adding an empty map as the ctx argument.

OK, now we can use our create-button function to sew new a11y&aposd buttons onto our playback-controls div:

(comment

  ;; ...

  ;; Throw away the current crappy buttons
  (.replaceChildren playback-controls-container)
  ;; => nil

  ;; Let&aposs fix the order of our buttons whilst we&aposre at it
  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> button-el
                      .-firstChild
                      (set-styles! {:width "35px"
                                    :filter "invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%)"}))
                  button-el)))
         log))
  ;; => #&aposcljcastr.player/playback-buttons

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  ;; Grab the new buttons
  (def play-button (js/document.querySelector "#button-play"))
  ;; => #&aposcljcastr.player/play-button

  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #&aposcljcastr.player/play-button

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #&aposcljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #&aposcljcastr.player/backward-button

  ;; Attach the click handlers to them
  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! (js/document.querySelector "audio.cljcastr")
                                      seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! (js/document.querySelector "audio.cljcastr")
                                       seek-dur-sec)))
  ;; => nil

  )

Player with buttons with backgrounds and borders

The only problem with this approach is that our buttons now look like, well, buttons. 😅

Let&aposs restyle them a bit so they look like they did before:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))
         button-el (create-el {:tag "button"
                               :attrs {:id button-id
                                       :class (render "cljcastr {{button-class}}" ctx)
                                       :aria-label (render label ctx)}})
         img-el (create-el {:tag "img"
                            :attrs {:id (str button-id "-icon")
                                    :class (render "cljcastr {{img-class}}" ctx)
                                    :src src
                                    :draggable false}})]
     (set-styles! button-el {:padding 0
                             :margin 0
                             :border 0
                             :background "transparent"
                             :cursor "pointer"})
     (.addEventListener button-el "click" click-handler)
     (.appendChild button-el img-el)
     button-el)))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (.replaceChildren playback-controls-container)
  ;; => nil

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> button-el
                      .-firstChild
                      (set-styles! {:width "35px"
                                    :filter (str "invert(59%) sepia(92%) saturate(611%) "
                                                 "hue-rotate(343deg) brightness(97%) contrast(99%)")}))
                  button-el)))
         log))
  ;; => #&aposcljcastr.player/playback-buttons

  (doseq [el playback-buttons]
    (.appendChild playback-controls-container el))
  ;; => nil

  (def play-button (js/document.querySelector "#button-play"))
  ;; => #&aposcljcastr.player/play-button

  (def pause-button (js/document.querySelector "#button-pause"))
  ;; => #&aposcljcastr.player/play-button

  (def forward-button (js/document.querySelector "#button-forward"))
  ;; => #&aposcljcastr.player/forward-button

  (def backward-button (js/document.querySelector "#button-backward"))
  ;; => #&aposcljcastr.player/backward-button

  (.addEventListener play-button "click"
                     (fn [ev]
                       (log ev)
                       (.play (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener pause-button "click"
                     (fn [ev]
                       (log ev)
                       (.pause (js/document.querySelector "audio.cljcastr"))))
  ;; => nil

  (.addEventListener forward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-forward! audio seek-dur-sec)))
  ;; => nil

  (.addEventListener backward-button "click"
                     (fn [ev]
                       (log ev)
                       (skip-backward! audio seek-dur-sec)))
  ;; => nil

  )

Now our buttons look like they did before! 🎉

However, recreating these buttons is getting a little tedious. Let&aposs do some cleaning up.

Sweeping up the shop

First of all, our log function is a little limited. It would be nice if we could include a log message in addition to whatever data we&aposre logging. In other words, we&aposd like to do this:

(comment

  ;; ...

  (log "Something happened!" {:foo 1, :bar 2})
  ;; => "Something happened!"

  )

But if we look at the console now, we only see the message logged and not the data:

  Something happened!                                                 nrepl.cljs:10

Luckily, the fix is pretty straightforward:

(ns cljcastr.player
  (:require [clojure.string :as str]))

(defn log [& args]
  (apply js/console.log (map clj->js args))
  (last args))

;; (defn render ...)
;; ...

(comment

  ;; ...

  (log "Something happened!" {:foo 1, :bar 2})
  ;; => {:foo 1, :bar 2}

  )

The value returned is what we wanted, and the console looks good as well:

  Something happened!  ‣ {foo: 1, bar: 2}                             nrepl.cljs:10

Next annoyance: all those js/document.querySelector calls. Extraction!

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn render ...)

(defn get-el
  "Returns the element matching the specified CSS selector. If `selector` is
   already an element, just returns the element."
  [selector]
  (if (instance? js/Element selector)
    selector
    (js/document.querySelector selector)))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (get-el "#button-play")
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

Might as well have a function to get a list of elements too:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-el ...)

(defn get-els
  "Returns a sequence containing the elements matching the specified CSS
   selector."
  [selector]
  (->> (js/document.querySelectorAll selector)
       seq))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (get-els "#playback-controls > button")
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )

How about one to return the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-els ...)

(defn get-children [el]
  (-> (get-el el)
      .-childNodes
      seq))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      log)
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]] 
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )

We added another nicety there: by using get-el, our function can take either an element or a CSS selector as its argument. Let&aposs continue to use this pattern as we add a function for adding a child to an element:

But if we look at the console, we see something extremely odd:

                                                                      nrepl.cljs:10
  ‣ he {value: button#backward, Ee: ArrayIterator, Ag: null, F: 8388672, M: 0}

The answers, my friend, are blowing in the wind of get-children:

(defn get-children [el]
  (-> (get-el el)
      .-childNodes
      seq))

We&aposre taking the childNodes attribute of our HTMLDivElement, which is a NodeList object:

(comment

  ;; ...

  (-> (get-el "#playback-controls")
      .-childNodes
      log)
  ;; => #object[NodeList [object NodeList]]

  )

This looks great in the console:

                                                                      nrepl.cljs:10
    NodeList(5) [button#button-backward.cljcastr.playback-button,
    button#button-play.cljcastr.playback-button,
  ‣ button#button-pause.cljcastr.playback-button,
    button#button-forward.cljcastr.playback-button,
    button#button-speed.cljcastr.playback-button]

But we can&apost use it in Clojure all that well:

(comment

  ;; ...

  (-> (get-el "#playback-controls")
      .-childNodes
      count)
  ;; => "#error {:message \"No protocol method ICounted.-count defined for type object: [object NodeList]\", ...}"

  )

That&aposs why get-children adds the call to seq at the end. That lets normal Clojure code deal with the list of children, but as we&aposve seen, it doesn&apost look good in the console when logged.

Let&aposs see what we can do about this:

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      vec
      log)
  ;; => [#object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]]

  )

Now the console is looking quite reasonable!

                                                                      nrepl.cljs:10
    (5) [button#button-backward.cljcastr.playback-button,
    button#button-play.cljcastr.playback-button,
  ‣ button#button-pause.cljcastr.playback-button,
    button#button-forward.cljcastr.playback-button,
    button#button-speed.cljcastr.playback-button]

Let&aposs see if we can apply what we&aposve learned here to our log function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

(defn log [& args]
  (let [seq->vec #(if (seq? %) (vec %) %)]
    (apply js/console.log (map (comp clj->js seq->vec) args)))
  (last args))

  ;; (defn render ...)
  ;; ...

(comment

  ;; ...

  (-> (get-children "#playback-controls")
      log)
  ;; => (#object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]]
  ;;     #object[HTMLButtonElement [object HTMLButtonElement]])

  )
(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-children ...)

(defn add-child! [parent-el el]
  (let [parent-el (get-el parent-el)]
    (-> parent-el
        (.appendChild el))
    parent-el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (add-child! "#playback-controls"
                   (create-el {:tag "span"
                               :text "Some nonsense"}))
       (log "I got a new child"))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Wanna clear all the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-child! ...)

(defn clear-children! [el]
  (let [el (get-el el)]
    (.replaceChildren el)
    el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (clear-children! "#playback-controls")
       (log "Empty nest"))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Set the children of an element?

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn clear-children! ...)

(defn set-children! [parent-el els]
  (let [parent-el (get-el parent-el)]
    (clear-children! parent-el)
    (doseq [el els]
      (add-child! parent-el el))
    parent-el))

;; (defn get-styles ...)
;; ...

(comment

  ;; ...

  (->> (set-children! "#playback-controls" playback-buttons)
       log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Final annoyance: all the repetition in the click handlers. Let&aposs extract a function for that as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn create-el ...)

(defn add-event-listener! [el event-type f]
  (let [el (get-el el)]
    (.addEventListener el event-type
                       (fn [ev]
                         (log (str "Handling " event-type " on")
                              el ev)
                         (if f
                           (f ev)
                           (log "No event handler function defined"))))
    (log "Added listener for" event-type "events to" el)
    el))

;; (defn create-button ...)
;; ...

(comment

  ;; ...

  (add-event-listener! "#button-speed" "click"
                       #(log "Should probably do something with this event, eh?" %))
  ;; => #object[HTMLButtonElement [object HTMLButtonElement]]

  )

And if we now click the playback speed button, the console rewards us with some rich logging:

Console log of event handler

With all these awesome functions, we can finally recreate our buttons much more concisely:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-event-listener! ...)

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))]
     (-> (create-el {:tag "button"
                     :attrs {:id button-id
                             :class (render "cljcastr {{button-class}}" ctx)
                             :aria-label (render label ctx)}})
         (set-styles! {:padding 0
                       :margin 0
                       :border 0
                       :background "transparent"
                       :cursor "pointer"})
         (add-event-listener! "click" click-handler)
         (add-child! (create-el {:tag "img"
                                 :attrs {:id (str button-id "-icon")
                                         :class (render "cljcastr {{img-class}}" ctx)
                                         :src src
                                         :draggable false}}))))))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (def playback-controls
    {:play {:src "https://www.svgrepo.com/show/436852/play-circle-fill.svg"
            :label "Play"
            :click-handler #(.play (get-el "audio.cljcastr"))}
     :pause {:src "https://www.svgrepo.com/show/436829/pause-circle-fill.svg"
             :label "Pause"
             :click-handler #(.pause (get-el "audio.cljcastr"))}
     :forward {:src "https://www.svgrepo.com/show/495321/forward-15-seconds.svg"
               :label "Forward {{seek-dur-sec}} seconds"
               :click-handler #(skip-forward! (get-el "audio.cljcastr") seek-dur-sec)}
     :backward {:src "https://www.svgrepo.com/show/495036/backward-15-seconds.svg"
                :label "Back {{seek-dur-sec}} seconds"
                :click-handler #(skip-backward! (get-el "audio.cljcastr") seek-dur-sec)}
     :speed {:src "https://www.svgrepo.com/show/151785/speed-meter.svg"
             :label "Playback speed"}})
  ;; => #&aposcljcastr.player/playback-controls

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :class "playback-control"})]
                  (-> (get-children button-el)
                      first
                      (set-styles! {:width "35px"
                                    :filter "invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%)"}))
                  button-el)))
         log))
  ;; => #&aposcljcastr.player/playback-buttons

  (set-children! "#playback-controls" playback-buttons)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

OK, so we&aposve fixed the non-obvious (to those of us not using screen readers) problem with the player. Scratching our heads, we try to recall what the obvious problem was. 🤔

Oh yeah! Two sets of controls, right. Let&aposs fix.

Sweeping audio under the carpet

Making our audio element disappear isn&apost that hard. Let&aposs use this as an excuse to create a few more helper functions and spread the get-el pattern a little wider as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn get-els ...)

(defn get-attr
  "Returns the value of the specified attribute."
  [el attr]
  (let [el (get-el el)]
    (.getAttribute el (name attr))))

(defn set-attr!
  "Sets the value of the specified attribute."
  [el attr v]
  (let [el (get-el el)]
    (.setAttribute el (name attr) v)
    el))

(defn set-attrs! [el attrs]
  (let [el (get-el el)]
    (doseq [[k v] attrs]
      (.setAttribute el (name k) v))
    el))

(defn get-id
  "Returns the ID of an element."
  [el]
  (let [el (get-el el)]
    (.-id el)))

(defn set-id!
  "Sets the ID of an element."
  [el id]
  (let [el (get-el el)]
    (when-not (empty? id)
      (set-attr! el :id id))
    el))

(defn get-name
  "Returns the tag name of an element."
  [el]
  (let [el (get-el el)]
    (.-localName el)))

(defn get-text
  "Returns the inner text of an element."
  [el]
  (let [el (get-el el)]
    (or (.-textContent el)
        (.-innerText el))))

(defn set-text! [el text]
  (let [el (get-el el)]
    (when-not (empty? text)
      (set! (.-textContent el) text))
    el))

(defn get-classes
  "Returns the classes of the specified element as a list of class names."
  [el]
  (let [el (get-el el)]
    (->> (str/split (.-classList el) #"\s")
         (remove empty?))))

(defn set-classes!
  "Sets the classes of the specified element. Classes may either be a list or a
   string.

   Example:

     ;; These two are equivalent
     (set-classes el [\"foo\" \"bar\"])
     (set-classes el \"foo bar\")"
  [el classes]
  (let [el (get-el el)]
    (when-not (empty? classes)
      (set! (.-classList el) (if (string? classes)
                               classes
                               (str/join " " classes))))
    el))

;; (defn get-children ...)
;; ...
;; (defn set-children! ...)

(defn get-styles
  "Returns the inline styles of an element as a map of style name to value."
  [el]
  (let [styles (-> (get-el el)
                   (.getAttribute "style")
                   (str/split #";\s+"))]
    (->> styles
         (remove empty?)
         (map #(let [[k v] (str/split % #":\s+")]
                 [(keyword k) v]))
         (into {}))))

(defn set-styles!
  "Sets the styles of an element from a map of style name to value."
  [el styles]
  (let [el (get-el el)]
    (when-not (empty? styles)
      (set! (.-style el) (->> styles
                              (map (fn [[k v]]
                                     (str (name k) ": " v)))
                              (str/join "; "))))
    el))

(defn add-styles!
  "Given a map of style name to value, adds the styles to the element."
  [el styles]
  (-> el
      (set-styles! (merge (get-styles el) styles))))

(defn add-style!
  "Adds a style to an element"
  [el style-name style-value]
  (-> el
      (add-styles! {style-name style-value})))

;; (defn create-el)
;; ...

Now that we have all of this awesomeness, we can streamline create-el by accepting a few more keys:

  • :id "foo" instead of :attrs {:id "foo"}
  • :classes ["foo" "bar"] instead of :attrs {:class "foo bar"}
  • :text to set the text content of the element

We also have to update create-button to take advantage of this.

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag id classes text attrs] :as args}]
  (-> (js/document.createElement tag)
      (set-attrs! attrs)
      (set-id! id)
      (set-classes! classes)
      (set-text! text)))

;; (defn add-event-listener! ...)
;; ...

(defn create-button
  ([button-name button]
   (create-button button-name button {}))
  ([button-name {:keys [label src click-handler]} ctx]
   (let [button-id (str "button-" (name button-name))]
     (-> (create-el {:tag "button"
                     :id button-id
                     :classes ["cljcastr"
                               (render "{{button-class}}" ctx)]
                     :attrs {:aria-label (render label ctx)}})
         (set-styles! {:padding 0
                       :margin 0
                       :border 0
                       :background "transparent"
                       :cursor "pointer"})
         (add-event-listener! "click" click-handler)
         (add-child! (create-el {:tag "img"
                                 :id (str button-id "-icon")
                                 :classes ["cljcastr"
                                           (render "{{img-class}}" ctx)]
                                 :attrs {:src src
                                         :draggable false}}))))))

;; (defn skip-forward! ...)
;; ...

(comment

  ;; ...

  (add-style! audio :display "none")
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  (def playback-buttons
    (->> [:backward :play :pause :forward :speed]
         (map (fn [button-name]
                [button-name (get playback-controls button-name)]))
         (map (fn [[button-name button]]
                (let [button-el (create-button button-name button
                                               {:seek-dur-sec seek-dur-sec
                                                :button-class "playback-button"
                                                :img-class "playback-icon"})]
                  (-> (get-children button-el)
                      first
                      (set-styles! {:width "35px"
                                    :filter (str "invert(59%) sepia(92%) saturate(611%) "
                                                 "hue-rotate(343deg) brightness(97%) contrast(99%)")}))
                  button-el)))
         log))
  ;; => #&aposcljcastr.player/playback-buttons

  (set-children! "#playback-controls" playback-buttons)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with just buttons

Testing our controls, we verify that they still work. However, we&aposve lost three important things:

  1. The progress bar, which gave us a visual indication of where we are and a way to move to an arbitrary point in the episode by clicking or dragging.
  2. The display of the current position in the episode.
  3. The display of the duration of the episode.

Actually, on the third point, we like Overcast&aposs choice to display the time remaining instead of the duration, so let&aposs do that as well.

Having made these bold design decisions, let&aposs get to implementing! We&aposll start with the easy stuff, displaying the time elapsed and remaining:

(comment

  ;; ...

  (def elapsed-time (create-el {:tag "span"
                                :text (str (.-currentTime audio))
                                :classes ["cljcastr"]
                                :id "elapsed-time"}))
  ;; => #&aposcljcastr.player/cur-time

  (def remaining-time (create-el {:tag "span"
                                  :text (str (- (.-duration audio)
                                                (.-currentTime audio)))
                                  :classes ["cljcastr"]
                                  :id "remaining-time"}))
  ;; => #&aposcljcastr.player/remaining-time

  (doseq [el [elapsed-time remaining-time]]
    (add-child! "#playback-controls" el))
  ;; => nil

  )

Times displayed as fractional seconds

Well, this is something, at least. Something incredibly confusing and also ugly. 😭

Let&aposs enumerate the problems:

  1. Times are displayed as fractional seconds, not the more familiar h:mm:ss.
  2. The spans are side by side with no padding, so it&aposs not possible to tell where elapsed time ends and remaining time begins.
  3. The font size is too large compared to the buttons. And maybe black isn&apost the best choice of colour anyway.
  4. We&aposd like the times displayed above the buttons (and the progress bar, when we get around to adding it).

Now that we know what&aposs wrong, let&aposs tackle things one at a time.

Hours, minutes, and seconds, oh my!

How can we convert from seconds to h:mm:ss?

(comment

  ;; ...

  (set! (.-currentTime audio) 3645)
  ;; => 3645

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)]
    (str (int h) ":" (int mm) ":" (int ss)))
  ;; => "1:0:45"

  )

OK, not a bad start. Let&aposs zero-pad the minutes and seconds, though, because "1:00:45" looks way better than "1:0:45". 😉

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (int h) ":" (zero-pad mm) ":" (zero-pad ss)))
  ;; => "1:00:45"

  (set! (.-currentTime audio) 46)
  ;; => 46

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (int h) ":" (zero-pad mm) ":" (zero-pad ss)))
  ;; => "0:00:46"

  )

Hrm, maybe one more tweak. It&aposs conventional to omit the hour(s) portion of timestamps less than an hour:

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (if (pos? (int h))
           (str (int h) ":")
           "")
         (zero-pad mm) ":" (zero-pad ss)))
  ;; => "00:46"

  )

Nice! Now let&aposs turn this into a function:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn render ...)

(defn secs->ts
  "Given a number of seconds, returns a timestamp of the form h:mm:ss."
  [secs]
  (let [h (-> secs (/ 3600) int)
        mm (-> secs (/ 60) (- (* h 60)) int)
        ss (-> secs (mod 60) int)
        zero-pad #(-> % int str (str/replace #"^(\d)$" "0$1"))]
    (str (if (pos? (int h))
           (str (int h) ":")
           "")
         (zero-pad mm) ":" (zero-pad ss))))

;; (defn get-el ...)
;; ...

(comment

  ;; ...

  ;; Elapsed time
  (secs->ts (.-currentTime audio))
  ;; => "00:46"

  ;; Remaining time
  (secs->ts (- (.-duration audio) (.-currentTime audio)))
  ;; => "1:28:44"

  (-> elapsed-time
      (set-text! (secs->ts (.-currentTime audio)))
      log)
  ;; => #object[HTMLSpanElement [object HTMLSpanElement]]

  (-> remaining-time
      (set-text! (str "-" (secs->ts (- (.-duration audio) (.-currentTime audio)))))
      log)
  ;; => #object[HTMLSpanElement [object HTMLSpanElement]]
  
  )

OK, we&aposve solved problem number 1. 🎉

Problems 2 and 3 are just styling, so the fixes are pretty straightforward:

(comment

  ;; ...

  (doseq [el [elapsed-time remaining-time]]
    (add-styles! el {:margin "0 5px 0 5px"
                     :font-size "0.7rem"
                     :color "grey"}))
  ;; => nil

  )

Timestamps displayed in smaller, grey text

Whilst we&aposre at it, let&aposs fix another annoyance: with our current create-el function, we either have to call add-styles! (or set-styles!) afterwards, or do the stringifying of the styles ourselves:

(create-el {:tag "div"
            :attrs {:id "whatever"
                    :style "margin: 0 5px 0 5px; font-size: 0.7rm; color: grey"}})

We also have the same issue with classes and ID. 🙁

But instead of wallowing in misery, let&aposs modify create-el to take a :styles key:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn add-style! ...)

(defn create-el [{:keys [tag id classes styles text attrs] :as args}]
  (-> (js/document.createElement tag)
      (set-attrs! attrs)
      (set-id! id)
      (set-classes! classes)
      (set-styles! styles)
      (set-text! text)))

;; (defn add-event-listener! ...)
;; ...

(comment

  ;; ...

  (let [el (-> (create-el {:tag "span"
                           :id "elapsed-time"
                           :classes ["cljcastr"]
                           :text (secs->ts 123)
                           :styles {:margin "0 5px 0 5px"
                                    :font-size "0.7rem"
                                    :color "grey"}})
               log)]
    (add-child! "#playback-controls" el))
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Now, let&aposs address the last problem.

Laying out the law

It is now time to employ the most awesome technology known to humankind to figure out our layout: ASCII art!

+-----------------------------------+
|            Podcast title          |
+-----------------------------------+
|            Episode title          |
+-----------------------------------+
|            Release date           |
+-----------------------------------+
|            Episode art            |
+-----------------------------------+
|     Elapsed time | Remaining time |
+-----------------------------------+
|            Progress bar           |
+-----------------------------------+
| Backward | Play | Pause | Forward |
+-----------------------------------+
|           Playback speed          |
+-----------------------------------+

We&aposve been using the body element to hold our player, but we should probably deploy a div at this point so we can style things according to our liking. We can also group similar things together in divs, something like this:

+ episode-container ----------------------------+
|                                               |
| + episode-intro ----------------------------+ |
| |                Podcast title              | |
| +-------------------------------------------+ |
| |                Episode title              | |
| +-------------------------------------------+ |
| |                Release date               | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+
|                  Episode art                  |
+-----------------------------------------------+
|                Audio (hidden)                 |
+-----------------------------------------------+
|                                               |
| + progress ---------------------------------+ |
| |       Elapsed time | Remaining time       | |
| +-------------------------------------------+ |
| |                Progress bar               | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+
|                                               |
| + playback-controls ------------------------+ |
| | Backward | Play | Pause | Forward | Speed | |
| +-------------------------------------------+ |
|                                               |
+-----------------------------------------------+

Let&aposs start by creating the new nested containers (we already have one for playback controls) and populating them with some hard-coded data:

(comment

  ;; ...

  (def episode-container (create-el {:tag "div"
                                     :id "episode-container"
                                     :classes ["cljcastr"]
                                     :styles {:width "100%"
                                              :display "flex"
                                              :flex-direction "column"
                                              :align-items "center"}}))
  ;; => #&aposcljcastr.player/episode-container

  (def episode-info
    (-> (create-el {:tag "div"
                    :id "episode-info"
                    :classes ["cljcastr"]
                    :styles {:max-width "420px"
                             :text-align "center"}})
        (set-children! [(create-el {:tag "h3"
                                    :id "podcast-title"
                                    :classes ["cljcastr"]
                                    :text "defn"})
                        (create-el {:tag "h2"
                                    :id "episode-title"
                                    :classes ["cljcastr"]
                                    :text "876b4c306172 Cora Sutton"})
                        (create-el {:tag "span"
                                    :id "episode-date"
                                    :classes ["cljcastr"]
                                    :styles {:color "grey"}
                                    :text "July 9, 2025"})])
        log))
  ;; => #&aposcljcastr.player/episode-info

  (def episode-art
    (-> (create-el {:tag "div"
                    :id "episode-art-container"
                    :classes ["cljcastr"]})
        (add-child! (create-el {:tag "img"
                                :id "episode-art"
                                :classes ["cljcastr"]
                                :styles {:width "400px"
                                         :border-radius "5%"
                                         :padding "10px"}
                                :attrs {:src "https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png"}}))
        log))
  ;; => #&aposcljcastr.player/episode-art

  (def progress (create-el {:tag "div"
                            :id "progress"
                            :classes ["cljcastr"]
                            :styles {:width "390px"
                                     :margin "1.5em auto 0 auto"}}))
  ;; => #&aposcljcastr.player/progress

  (def playback-controls-container (-> (create-el {:tag "div"
                                                   :id "playback-controls"
                                                   :classes ["cljcastr"]})
                                       (set-children! playback-buttons)
                                       log))
  ;; => #&aposcljcastr.player/playback-controls-container

  (def playback-speed (create-el {:tag "div"
                                  :id "playback-speed"
                                  :classes ["cljcastr"]}))
  ;; => #&aposcljcastr.player/playback-speed

  (-> episode-container
      (set-children! [episode-info
                      episode-art
                      (-> audio
                          (set-styles! {:display "none"}))
                      progress
                      playback-controls-container
                      playback-speed])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  (set-children! "body" [episode-container])
  ;; => #object[HTMLBodyElement [object HTMLBodyElement]]

  )

Player with defn episode

This is looking really good (relatively speaking), but there&aposs one major component missing.

Slipping and sliding

We have no idea where in the episode we are! Let&aposs see about getting that progress bar in place.

Luckily, HTML has got us covered! There just so happens to be a progress element designed exactly for what we want to do!

(comment

  ;; ...

  (def progress-bar
    (create-el {:tag "progress"
                :id "progress-bar"
                :classes ["cljcastr"]
                :attrs {:min 0
                        :max (.-duration audio)
                        :value (.-currentTime audio)}
                :styles {:appearance "none"
                         :height "8px"
                         :width "100%"
                         :border "0"
                         :border-radius "4px"
                         :margin "0"
                         :overflow "hidden"
                         :background-color "lightgray"
                         :color "#f7931e"}}))
  ;; => #&aposcljcastr.player/progress-bar

  )

Now that we have the progress bar, let&aposs add it to our #progress div, along with the current and elapsed time spans we created before:

(comment

  ;; ...

  (-> progress
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-bar])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Progress bar added

Hurrah! I mean, it&aposs a bit ugly, but at least it&aposs there! 😅

Let&aposs click the play button, then skip forward a few times. Now, we can try setting the value of the progress bar to the current time of the audio element:

(comment

  ;; ...

  (set! (.-value progress-bar) (.-currentTime audio))
  ;; => 370.277385

  )

We should see the progress bar move along a bit.

This is great and all, but it would be lovely to have a way to actually move forward and backward in the episode by clicking or dragging on the progress bar. You know, the way every audio player ever works?

Sadly, the progress bar itself isn&apost really designed for this. We could certainly hack it up with some "click" event listeners and a lot of arithmetic, but surely there must be a better way, right?

And indeed there is! HTML has an input element of type range that is designed for exactly this case! Let&aposs try it out:

(comment

  ;; ...

  (def progress-slider
    (create-el {:tag "input"
                :id "progress-slider"
                :classes ["cljcastr"]
                :attrs {:type "range"
                        :min 0
                        :max (.-duration audio)
                        :value (.-currentTime audio)
                        :step "any"
                        :aria-label "Seek"
                        :aria-valuetext (str (secs->ts (.-currentTime audio))
                                             " of "
                                             (secs->ts (.-duration audio)))
                        :aria-valuemax (.-duration audio)}
                :styles {:appearance "none"
                         :cursor "pointer"
                         :height "28px"
                         :width "100%"
                         :border "0"
                         :margin "0"
                         :touch-action "manipulation"}}))
  ;; => #&aposcljcastr.player/progress-slider

  (-> progress
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-slider
                      progress-bar])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

OK, a blue dot has appeared. Let&aposs skip forward in the episode a bit more, then update the value of the slider to the current playback position. Oh yeah, might as well update the value of the progress bar as well, whilst we&aposre at it:

(comment

  ;; ...

  (doseq [el [progress-slider progress-bar]]
    (set! (.-value el) (.-currentTime audio)))
  ;; => nil

  )

Now, how about setting the playback position in the episode when we click or drag the slider?

(comment

  ;; ...

  (add-event-listener! progress-slider "input"
                       #(->> % .-target .-value (set! (.-currentTime audio))))
  ;; => #object[HTMLInputElement [object HTMLInputElement]]

  )

We now have a pretty good representation of playback position:

  • A span showing the elapsed time
  • A span showing the remaining time
  • A slider letting us control the playback position
  • A progress bar giving us a visual indication of the playback position

Let&aposs see if we can keep all of these in sync. First, some code to update them all in one go:

(comment

  ;; ...

  (let [secs (.-currentTime audio)
        duration-secs (.-duration audio)]
    (set-text! elapsed-time (secs->ts secs))
    (set-text! remaining-time (str "-" (secs->ts (- duration-secs secs))))
    (.setAttribute progress-slider "aria-valuetext"
                   (str (secs->ts secs) " of " (secs->ts duration-secs)))
    (doseq [el [progress-slider progress-bar]]
      (set! (.-value el) (.-currentTime audio))))
  ;; => nil

  )

Looks like that works, so let&aposs extract it into a function. And whilst we&aposre at it, maybe we can extract functions for the attribute and value setting so we don&apost have to expose the raw DOM interactions. And for the sake of completeness, we can add getters as well:

(ns cljcastr.player
  (:require [clojure.string :as str]))

;; ...
;; (defn set-text! ...)

(defn get-value
  "Returns the value attribute of the specified element."
  [el]
  (let [el (get-el el)]
    (.-value el)))

(defn set-value!
  "Sets the value attribute of the specified element."
  [el v]
  (let [el (get-el el)]
    (set! (.-value el) v)))

;; (defn set-classes! ...)
;; ...
;; (defn skip-backward! ...)

(defn update-playback-position-indicators!
  "Given a current playback time and duration (both in seconds) and a map of
   elements, updates the elements to reflect the current playback position. The
   element map should contain the following keys:
   `:elapsed-time` - text element (for example: label, span, etc.)
   `:remaining-time` - text element (for example: label, span, etc.)
   `:progress-slider` - input element of type range
   `:progress-bar` - progress element"
  [current-secs duration-secs
   {:keys [elapsed-time remaining-time progress-slider progress-bar]}]
  (set-text! elapsed-time (secs->ts current-secs))
  (set-text! remaining-time (str "-" (secs->ts (- duration-secs current-secs))))
  (set-attr! progress-slider :aria-valuetext
             (str (secs->ts current-secs) " of " (secs->ts duration-secs)))
  (doseq [el [progress-slider progress-bar]]
    (set-value! el current-secs)))

;; ...

(comment

  ;; ...

  (update-playback-position-indicators! (.-currentTime audio) (.-duration audio)
                                        {:elapsed-time elapsed-time
                                         :remaining-time remaining-time
                                         :progress-slider progress-slider
                                         :progress-bar progress-bar})
  ;; => nil

  )

There&aposs one final touch we should apply here before we move on. It&aposs getting a bit silly to have to manually update these elements, right? How about we update them automatically as the episode plays? For this, we can use the timeupdatechange event of our audio element:

(comment

  ;; ...

  (add-event-listener! audio "timeupdate"
                       #(update-playback-position-indicators!
                         (.-currentTime audio) (.-duration audio)
                         {:elapsed-time elapsed-time
                          :remaining-time remaining-time
                          :progress-slider progress-slider
                          :progress-bar progress-bar}))
  ;; => #object[HTMLAudioElement [object HTMLAudioElement]]

  )

Now things are running smoothly, but there&aposs another thing that&aposs bothering us. The progress slider that we use to control the playback position is above the progress bar, which just looks weird, and also goes against the expectations of our hordes of users that there should be a handle on the progress bar itself that they can drag around. You know, like this:

Overcast web player

Getting into position

What we&aposd really like is to set our slider down right on top of the progress bar. This will give us the seeking we seek (sorry) as well as a visual indication of the current playback position. In order to do this, we&aposre going to have to learn a bit about CSS positioning.

Basically, we&aposll need to create a new container to hold the progress bar and the slider, and set its position style to "relative". Then, we can add our progress bar and slider as children of it, setting their position to "absolute" (relative to their parent container). Yes, I know, this is enough to make our heads explode, but I guess it makes sense once you achieve enlightenment or something?

We also need to set the z-indexes of the two elements. We set the progress bar to 0 to put it at the bottom of the stack, and the slider to 1 so it&aposs on top. That means we need to make the background colour of the slider transparent so the progress bar can shine through in all its glory.

Finally, we position both elements 0 pixels from the left of the enclosing container, then the slider 0 pixels from the top and the progress bar 10 pixels from the top. Why 10 pixels? Well, because:

  • The slider is 28 pixels high.
  • The progress bar is 8 pixels high.
  • We want to vertically centre the progress bar underneath the slider.
  • 20 - 8 is 10.

With that advanced mathematics out of the way, let&aposs make it happen!

(comment

  ;; ...

  (def progress-container
    (create-el {:tag "div"
                :id "progress-container"
                :classes ["cljcastr"]
                :styles {:position "relative"
                         :height "28px"
                         :clear "both"}}))
  ;; => #&aposcljcastr.player/progress-container

  (-> progress-container
      (set-children! [(-> progress-bar
                          (add-styles! {:position "absolute"
                                        :top "10px"
                                        :left "0px"
                                        :z-index 0}))
                      (-> progress-slider
                          (add-styles! {:position "absolute"
                                        :top "0px"
                                        :left "0px"
                                        :z-index 1
                                        :background-color "transparent"}))])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  (-> progress
      (add-styles! {:position "relative"})
      (set-children! [(-> elapsed-time
                          (add-styles! {:float "left"}))
                      (-> remaining-time
                          (add-styles! {:float "right"}))
                      progress-container])
      log)
  ;; => #object[HTMLDivElement [object HTMLDivElement]]

  )

Player with a nice progress bar and slider

This is looking pretty good! There are a few more things we might want to do, but just as we&aposre about to start doing some of those things, a bone-chilling thought occurs to us: what if we reload the page? We&aposve been building our player up in the REPL one s-expression at a time, but our HTML file still has a body that looks like this:

<body>
</body>

Should a feline friend prance across the keyboard, stepping on the Ctrl and r keys simultaneously (or Apple-r, if you&aposre one of those too-cool-for-school types), all will be lost!

Before giving in to despair, we realise that we have the awesome power of ClojureScript at our fingertips, and thus can make a permanent record of what we&aposve done so far. And that, my dear readers, we shall endeavour to do in the new post in this series, which shall be called Scittlecastr: All You Need is Persistence.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.