
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.
Last time on Scittlecastr: The Scittling, we scittled up a podcast player, using the awesome power of the ClojureScript REPL.
What we found great about this approach was the ability to create HTML elements on the fly and update the page without having to reload, but at the end of our session, we were struck by the fear that if we do reload, we will lose all the state of our running program.
Before we grit our teeth and test our hypothesis, I present to you a
OK, with that housekeeping out of the way, let's screw our courage to the sticking point and reload the page:

😭😭😭
The Buddha spake wisely when he claimed that life is a vale of tears. However, we have achieved satori through Scittle, so we discard our fragile ego and expand our consciousness to encompass all things, and discover wisdom in the infinite.
This wisdom was that of having done our interactive development in a Rich comment, thus allowing us build our interface back up by evaluating our player.cljs buffer, then stepping through the Rich comment, evaluating each form one by one (you can use cider-eval-last-sexp (C-c C-e) for this, since you don't need the comment inserted again). Let's rebuild our initial ugly player, using our slick new functions.
Make sure to evaluate the entire buffer after a page reload (C-c C-k), or you'll get an error similar to this the first time you try to evaluate a form in the REPL:
(comment
;; ...
(get-el "body")
;; => "#error {:message \"Unable to resolve symbol: get-el\",
;; :data {:type :sci/error, :line 1, :column 1, ...}}
)
Having evaluated the buffer, let's get ugly!
(comment
;; ...
(set-children! "body"
[(create-el {:tag "h1"
:classes ["cljcastr"]
:text "cljcastr player"})
(create-el {:tag "audio"
:classes ["cljcastr"]
:attrs {:controls true
:src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}})])
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
)
That is quite a pain, though. Is there a way to effectively export the running program as HTML so we could save it to our index.html? Well, let's give it a go.
What we'd like is to turn the body DOM node into an HTML fragment. We can actually already do this in a clunky way. We log the element:
(comment
;; ...
(-> (get-el "body")
log)
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
)
and then look at the console:

Unfortunately, we can't directly use the console output in the REPL. We could of course just copy and paste it from the console window, but that feels too efficient, and we'd rather spend some time shaving those yaks, so let's think about how we might generate our own HTML from the DOM tree anchored at the body element.
First, we need to do a depth-first traversal of the children of body. This is quite easy for the time being, since body only has children and no grandchildren or great-grandchildren or whatever. Let's try to count those children:
(comment
;; ...
(def children (-> (get-children "body")
log))
;; => #'cljcastr.player/children
(count children)
;; => 3
)
Wait, what's going on here? The JS console actually holds the key. If we look in the console at the previously logged line and expand it, we see:
▼ (2) [h1.cljcastr, audio.cljcastr, protonpass-root-64f9] ℹ nrepl.cljs:10
‣ 0: h1.cljcastr
‣ 1: audio.cljcastr
‣ 2: protonpass-root-64f9
length: 2
‣ [[Prototype]]: Array(0)
Looks like my Proton Pass extension is adding an element to the body. 😬
But luckily, we added the cljcastr class to all our elements, so we can use a CSS
selector to limit the children to the ones we actually put there ourselves:
(comment
;; ...
(def children (-> (js/document.querySelectorAll "body > .cljcastr")
seq
log))
;; => #'cljcastr.player/children
(count children)
;; => 2
)
Excellent! Now let's see what the element names of the children are:
(comment
;; ...
(->> children
(map get-name)
log)
;; => ("h1" "audio")
)
And their attributes?
(comment
;; ...
(->> children
(map #(.-attributes %))
log)
;; => (#object[NamedNodeMap [object NamedNodeMap]]
;; #object[NamedNodeMap [object NamedNodeMap]])
)
Ah yes, thank you for not using a POJSO (Plain 'Ol JavaScript Object, ofc) there. I guess we'll need some sort of function to turn a NamedNodeMap into a Clojure map. No matter, we can write some simple Clojure to fix this:
(comment
;; ...
(->> children
(map (fn [el]
(let [attrs (.-attributes el)]
(->> (range (.-length attrs))
(map #(let [attr (.item attrs %)]
[(-> (.-name attr) keyword) (.-value attr)]))
(into {})))))
log)
;; => ({:class "cljcastr"}
;; {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"})
)
Um. This is... yeah. I think it's time to write a few functions to decomplect this mess. Let's head back up to the top of the file and add a few little functions:
(ns cljcastr.player)
;; ...
;; (defn get-attr ...)
(defn attr->clj
"Given an entry in an attribute map (i.e. `Attr` in a `NamedNodeMap` that is
the value of the `.-attributes` property of an element), returns a vector of
the name (as a keyword) and value of the attribute."
[attr]
[(-> (.-name attr) keyword) (.-value attr)])
(defn get-attrs
"Returns the attributes of an element as a map of attribute names (as keywords)
to attribute values."
[el]
(let [el (get-el el)
attrs (.-attributes el)]
(->> (range (.-length attrs))
(map #(->> % (.item attrs) attr->clj))
(into {}))))
;; (defn set-attr! ...)
;; ...
(comment
;; ...
(->> children
(map get-attrs)
log)
;; => ({:class "cljcastr"}
;; {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"})
)
Now that we know how to get both the name of the tag and its attributes, let's combine them into a function that we can use to map over a list of elements and transform them into a nice Clojurey data structure.
Climbing into our hammock ever so briefly, we sketch out the following:
{:tag "{{tag-name}}"
:attrs {:k1 "{{v1}}"
:k2 "{{v2}}"}}
Given this data structure and the functions we've already written, it's not too difficult to write the code:
(comment
;; ...
(->> children
(map (fn [el]
{:tag (get-name el)
:attrs (get-attrs el)}))
log)
;; => ({:tag "h1", :attrs {:class "cljcastr"}}
;; {:tag "audio",
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
The only thing missing from this is the inner text for the h1 element. Let's add a :text key to our map for elements that have text:
(comment
;; ...
(->> children
(map (fn [el]
(let [text (get-text el)]
(merge {:tag (get-name el)
:attrs (get-attrs el)}
(when-not (empty? text)
{:text text})))))
log)
;; => ({:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"}
;; {:tag "audio",
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
Let's turn this into a nice function, and whilst we're at it, add all of the keys that create-el takes. We also want to handle the case where the function is called with a Clojure representation of an element (i.e. a map), in which case it should just return the map:
(ns cljcastr.player)
;; ...
;; (defn add-style! ...)
(defn el->clj
"Given an HTML element, returns a map containing :tag, :id, :classes, :styles,
:text, and :attrs."
[el]
(if (map? el)
el
(let [el (get-el el)]
(->> {:tag (get-name el)
:id (get-id el)
:classes (get-classes el)
:styles (get-styles el)
:text (get-text el)
:attrs (get-attrs el)}
(remove (fn [[_ v]] (empty? v)))
(into {})))))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> children
(map el->clj)
log)
;; => ({:tag "h1",
;; :classes ("cljcastr"),
;; :text "cljcastr player",
;; :attrs {:class "cljcastr"}}
;; {:tag "audio",
;; :classes ("cljcastr"),
;; :attrs {:controls "true",
;; :src "https://media.zencastr.com/audio-files/...",
;; :class "cljcastr"}})
)
I wonder if we can roundtrip an element to a map back to an element?
(comment
;; ...
(->> (create-el {:tag "button"
:id "button-play"
:classes ["cljcastr" "playback-button"]
:styles {:padding "0px"
:margin "0px"
:border "0px"
:background "transparent"
:cursor "pointer"}
:attrs {:aria-label "Play"}})
el->clj
create-el
log)
;; => #object[HTMLButtonElement [object HTMLButtonElement]]
)
Looking good! From this, it's pretty straightforward to turn this into an HTML string:
(comment
;; ...
(->> children
(map el->clj)
(map (fn [{:keys [tag attrs text]}]
(let [attrs-str (if (empty? attrs)
""
(str " "
(->> attrs
(map (fn [[k v]] (str (name k) "=\"" v "\"")))
(str/join " "))))]
(str "<" tag attrs-str ">" text "</" tag ">"))))
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
That looks pretty reasonable, so let's extract a few functions:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->clj ...)
(defn attrs->str
"Given a map of attributes, returns a string of k=\"v\"..."
[attrs]
(->> attrs
(map (fn [[k v]] (str (name k) "=\"" v "\"")))
(str/join " ")))
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)]
(str "<" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">"
text
"</" tag ">")))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> children
(map el->html)
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
Just as we're about to paste our HTML into index.html, a thought occurs to us. We wrote some mucky DOM-tacular JS-interop code to create elements, but now we have an elegant Clojure data structure to describe our elements, and we can turn that into HTML, so why not into DOM nodes as well?
Well, given the way we've built up our functions for dealing with the DOM, it turns out we can do this easily!
(comment
;; ...
(-> (create-el {:tag "h1", :attrs {:class "cljcastr"}, :text "cljcastr player"})
log)
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
)
Looking at the browser console, we're convinced we're onto something:
<h1 class="cljcastr">cljcastr player</h1> nrepl.cljs:10
Looks great! Now that we have a nicer way to build up elements interactively, as well as a way to dehydrate elements into HTML, let's think about what it would take to dehydrate the entire DOM tree rooted at our body tag.
We pretty much have everything we need:
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "<h1 class=\"cljcastr\">cljcastr player</h1>\n
;; <audio controls=\"true\"
;; src=\"https://media.zencastr.com/audio-files/...\"
;; class=\"cljcastr\"></audio>"
)
This looks great, so let's build the full podcast player we had before back up in our REPL and try dehydrating that into HTML. Might as well reload the page to make sure we're starting out with a pristine REPL. Remember to eval the buffer (C-c C-k) after reloading!
To rebuild our player, we could just step through the entire Rich comment from the beginning, evaluating each form one by one, but this sounds like a lot of work! Let's add a new add-attrs! function and another adornment to our create-el function to make life easier on ourselves:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn set-attrs! ...)
;; ...
(defn add-attrs! [el attrs]
(let [el (get-el el)]
(set-attrs! el (merge (get-attrs el) attrs))))
;; (defn get-id ...)
;; ...
;; (defn el->html ...)
(defn create-el [{:keys [tag id classes styles text attrs children] :as args}]
(-> (js/document.createElement tag)
(set-attrs! attrs)
(set-id! id)
(set-classes! classes)
(set-styles! styles)
(set-children! children)
(set-text! text)))
;; (defn add-event-listener! ...)
;; ...
(comment
;; ...
(->> (create-el {:tag "div"
:id "episode-container"
:classes ["cljcastr"]
:styles {:width "100%"
:display "flex"
:flex-direction "column"
:align-items "center"}
:children
[
;; episode-info
(create-el {:tag "div"
:id "episode-info"
:classes ["cljcastr"]
:styles {:max-width "420px"
:text-align "center"}
: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"})]})
;;episode-art
(create-el {:tag "div"
:id "episode-art-container"
:classes ["cljcastr"]
:children
[(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"}})]})
;;audio
(-> (create-el {:tag "audio"
:classes ["cljcastr"]
:attrs {:controls true
:src "https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"}
:styles {:display "none"}})
(add-event-listener! "timeupdate"
(fn [ev]
(let [audio (.-target ev)]
(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
{:elapsed-time "#elapsed-time"
:remaining-time "#remaining-time"
:progress-slider "#progress-slider"
:progress-bar "#progress-bar"})))))
;;progress
(create-el {:tag "div"
:id "progress"
:classes ["cljcastr"]
:styles {:width "390px"
:margin "1.5em auto 0 auto"}
:children
[(create-el {:tag "span"
:text (secs->ts 0)
:classes ["cljcastr"]
:id "elapsed-time"
:styles {:margin "0 5px 0 5px"
:font-size "0.7rem"
:color "grey"
:float "left"}})
(create-el {:tag "span"
:text (secs->ts 0)
:classes ["cljcastr"]
:id "remaining-time"
:styles {:margin "0 5px 0 5px"
:font-size "0.7rem"
:color "grey"
:float "right"}})
(create-el {:tag "div"
:id "progress-container"
:classes ["cljcastr"]
:styles {:position "relative"
:height "28px"
:clear "both"}
:children
[(create-el {:tag "progress"
:id "progress-bar"
:classes ["cljcastr"]
:attrs {:min 0
:max 0
:value 0}
:styles {:appearance "none"
:height "8px"
:width "100%"
:border "0"
:border-radius "4px"
:margin "0"
:overflow "hidden"
:background-color "lightgray"
:color "#f7931e"
:position "absolute"
:top "10px"
:left "0px"
:z-index 0}})
(create-el {:tag "input"
:id "progress-slider"
:classes ["cljcastr"]
:attrs {:type "range"
:min 0
:max 0
:value 0
:step "any"
:aria-label "Seek"
:aria-valuetext (str (secs->ts 0)
" of "
(secs->ts 0))
:aria-valuemax 0}
:styles {:appearance "none"
:cursor "pointer"
:height "28px"
:width "100%"
:border "0"
:margin "0"
:touch-action "manipulation"
:position "absolute"
:top "0px"
:left "0px"
:z-index 1
:background-color "transparent"}})]})]})
;;playback-controls
(create-el {:tag "div"
:id "playback-controls"
:classes ["cljcastr"]
:children
(->> [: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)})]})
log
list
(set-children! "body"))
;; => #object[HTMLBodyElement [object HTMLBodyElement]]
(let [audio (get-el "audio.cljcastr")]
(set-text! "#elapsed-time" (secs->ts (.-currentTime audio)))
(set-text! "#remaining-time" (secs->ts (- (.-duration audio)
(.-currentTime audio))))
(add-attrs! "#progress-bar" {:max (.-duration audio)
:value (.-currentTime audio)})
(add-attrs! "#progress-slider" {:max (.-duration audio)
:value (.-currentTime audio)
:aria-valuetext (str (secs->ts (.-currentTime audio))
" of "
(secs->ts (.-duration audio)))
:aria-valuemax (.-duration audio)}))
;; => #object[HTMLInputElement [object HTMLInputElement]]
)
Much like Lazarus, our podcast player hath now been resurrected! And also like Lazarus, the method of resurrection required divine powers. But fear not! for this shall be the last time we rely on the divine to rebuild our player.
Let's get back to dehydration, shall we? We can use the same method as before, when we had a really simple player:
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\"
;; style=\"width: 100%; display: flex; flex-direction: column;
;; align-items: center;\">
;; defn876b4c306172 Cora SuttonJuly 9, 202500:001:29:30</div>"
)
Hrm, that's not very helpful. First of all, we're only getting the direct children of our body element, which in this case is a single child: the episode-container div. What we'd actually like is the entire tree of elements rooted at body.
Let's see if we can figure out how to do that.
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map (fn [el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close)))))
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n
;; <div id=\"episode-art-container\" class=\"cljcastr\"></div>\n
;; <audio controls=\"true\" src=\"https://media.zencastr.com/audio-files/...\" class=\"cljcastr\" style=\"display: none;\"></audio>\n
;; <div id=\"progress\" class=\"cljcastr\" style=\"...\">00:37-1:28:53</div>\n
;; <div id=\"playback-controls\" class=\"cljcastr\"></div>\n
;; </div>"
)
OK, this is progress, but we're only grabbing the first level of children. In order to include grandchildren and great-grandchildren and great-great-grandchildren (and so on to the last syllable of recorded time), we'll need to include our new code in el->html itself:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close))))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map el->html)
(str/join "\n")
log)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
)
Well, that doesn't look promising. Let's see if we can figure this out.
Looking back out at the HTML we generated in previously, we get a hint about which element might be the culprit:
;; => "<div id=\"episode-container\" class=\"cljcastr\" style=\"...\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"...\">defn876b4c306172 Cora SuttonJuly 9, 2025</div>\n
Our #episode-info div looks a bit odd, doesn't it. Let's take a closer look:
(comment
;; ...
(get-children "#episode-info")
;; => (#object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[HTMLSpanElement [object HTMLSpanElement]])
)
OK, so #episode-info has three children: two headings and a span. Let's feed the first child to el->html and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
el->html)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
(-> (get-children "#episode-info")
first
get-children
log)
;; => (#object[Text [object Text]])
)
Ah yes, there it is. So there seems to be something special about a Text node that causes el->html to blow up. Let's see what we can actually do with this type of object. Reading the documentation, we see that it inherits from the CharacterData class, which has a handy data attribute that is "string representing the textual data contained in this object". Let's try that out:
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
log)
;; => #object[Text [object Text]]
(-> (get-children "#episode-info")
first
get-children
first
(.-data))
;; => "defn"
)
Armed with this knowledge, we should be able to fix get-text to handle Text nodes:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-name ...)
(defn get-text
"Returns the inner text of an element."
[el]
(let [el (get-el el)]
(if (instance? js/CharacterData el)
(.-data el)
(or (.-textContent el)
(.-innerText el)))))
;; (defn set-text! ...)
;; ...
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
)
Hrm, we're back to this. Let's trace through get-text line by line to see if we can figure out what's going on:
(let [el (get-el el)]
...)
So we'd be calling (get-el) with our Text node. Let's try that and see what happens:
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-el)
;; => "#error {:message \"Failed to execute 'querySelector' on 'Document':
;; '[object Text]' is not a valid selector.\",
;; ...}"
)
Aha, so the problem is in get-el, which looks like this:
(defn get-el [selector]
(if (instance? js/Element selector)
selector
(js/document.querySelector selector)))
What we're trying to do here is just return the selector arg if we're passed an element, otherwise assume selector is a string and use Document.querySelector to look up the element. But wait, why should we assume it's a string? Why not actually check? In fact, why not actually write the function in the same way we would describe it in English?
If
selectoris a string, returns the element matching CSS selector. Otherwise, assume it's already an element and just return it.
And whilst we're at it, why not update the docstring of the function to say exactly this?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn secs->ts ...)
(defn get-el
"If `selector` is a string, returns the element matching CSS selector.
Otherwise, assume it's already an element and just return it."
[selector]
(if (string? selector)
(js/document.querySelector selector)
selector))
;; (defn get-els ...)
;; ...
(comment
;; ...
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "defn"
)
Much better!
However, we still have that annoying word "assume" in our docstring. 😬
Returns the element matching the specified CSS selector. If
selectoris already an element, just returns the element.
How about we stop assuming, and actually throw an exception when we get an argument we can't handle? Sounds good, but the reason we're in this mess is because we were treating anything that wasn't an Element object as a string, and we discovered that Text isn't a subclass of Element. So what we actually want is a common ancestor of both Text and Element, right? And luckily, there is just such a subclass: Node! 🎉
Let's use that:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn secs->ts ...)
(defn get-el
"If `selector` is a string, returns the element matching CSS selector.
If `selector` is a `Node`, returns it. Otherwise, throws an exception."
[selector]
(cond
(string? selector)
(js/document.querySelector selector)
(instance? js/Node selector)
selector
:else
(let [cls (-> selector .-constructor .-name)]
(throw (ex-info (str "Expecting a string or a Node; got: " cls)
{:dom/error :dom/error.invalid-class
:js/class cls})))))
;; (defn get-els ...)
;; ...
(comment
;; ...
(get-el "#podcast-title")
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
(get-el (js/document.querySelector "#podcast-title"))
;; => #object[HTMLHeadingElement [object HTMLHeadingElement]]
(get-el 42)
;; => "#error {:message \"Expecting a string or a Node; got: Number\", ...}"
(try
(get-el 42)
(catch js/Error e
(ex-data e)))
;; => {:dom/error :dom/error.invalid-class, :js/class "Number"}
(-> (get-children "#episode-info")
first
get-children
first
get-text)
;; => "defn"
)
Let's try turning the #episode-info div into HTML:
(comment
;; ...
(el->html "#episode-info")
;; => "#error {:message \"Could not find instance method: getAttribute\", ...}"
)

Let's slow down for a second and actually think this through. What we're dealing with is HTML like this:
<h3>defn</h3>
which is represented by a DOM node that we can create like this:
(create-el {:tag "h3", :text "defn"})
And if we currently try to turn this into HTML, we get the "Could not find instance method: getAttribute" error, which makes sense, because neither Text nor any of its parent classes (CharacterData and Node) have a .getAttribute() method, and el->html calls el->clj, which in turn will try to get all sorts of attributes from the poor Text object. 😭
We should be able to solve this by making sure el->html can handle a Text node:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
[el]
(if (instance? js/CharacterData el)
(get-text el)
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map el->html children)
[tag-close]))
(str tag-open text tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html (js/Text. "foo"))
;; => "foo"
(-> (create-el {:tag "h3", :text "defn"})
el->html)
;; => " <h3>\ndefn\n</h3>"
)
That seems somewhat reasonable, so let's see what happens when we turn the entire #episode-info div into HTML now:
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
;; </div>"
)
Hey, not bad!
Feeling confident, we go ahead and dump the entire DOM to HTML:
(comment
;; ...
(-> (el->html "html")
log)
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n\n
;; \n
;; <title>\ncljcastr episode player\n</title>\n\n
;; \n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n\n\n
;; \n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n\n
;; \n
;; <script>\nvar SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n</script>\n\n
;; \n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n\n\n\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n\n\n</head>\n\n\n\n
;; <body>\n
;; <div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
;; <div class=\"header-wrapper\">
;; <div class=\"header-controls\">
;; ...
;; </div>
;; <span class=\"translate-button-mtz hidden_translate blue\"></span>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\ndefn\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n876b4c306172 Cora Sutton\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\nJuly 9, 2025\n</span>\n
;; </div>\n
;; ...
;; <button aria-label=\"Playback speed\" id=\"button-speed\" class=\"cljcastr playback-button\" style=\"padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;\">\n
;; <img src=\"https://www.svgrepo.com/show/151785/speed-meter.svg\" draggable=\"false\" id=\"button-speed-icon\" class=\"cljcastr playback-icon\" style=\"width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);\"></img>\n
;; </button>\n
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>"
Now that's more like it! Better yet, if we look in the browser console, we see some HTML:

Let's copy this HTML from the console and paste it into our index.html:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>
cljcastr episode player
</title>
<link rel="stylesheet" href="main.css"></link>
<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>
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
<h3 id="podcast-title" class="cljcastr">
defn
</h3>
<h2 id="episode-title" class="cljcastr">
876b4c306172 Cora Sutton
</h2>
<span id="episode-date" class="cljcastr" style="color: grey;">
July 9, 2025
</span>
</div>
<div id="episode-art-container" class="cljcastr">
<img src="https://media.zencastr.com/image-files/5d9048546bacd9001de88f1c/a5b922ac-7294-4d58-9a33-4308f5d4f50b.png" id="episode-art" class="cljcastr" style="width: 400px; border-radius: 5%; padding: 10px;"></img>
</div>
<audio controls="true" src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3" class="cljcastr" style="display: none;"></audio>
<div id="progress" class="cljcastr" style="width: 390px; margin: 1.5em auto 0px;">
<span id="elapsed-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: left;">
00:00
</span>
<span id="remaining-time" class="cljcastr" style="margin: 0px 5px; font-size: 0.7rem; color: grey; float: right;">
1:29:30
</span>
<div id="progress-container" class="cljcastr" style="position: relative; height: 28px; clear: both;">
<progress min="0" max="5370.949958" value="0" id="progress-bar" class="cljcastr" style="color: rgb(247, 147, 30); top: 10px; overflow: hidden; background-color: lightgray; width: 100%; z-index: 0; appearance: none; position: absolute; border: 0px; border-radius: 4px; height: 8px; left: 0px; margin: 0px;"></progress>
<input min="0" value="0" aria-valuemax="5370.949958" type="range" style="top: 0px; background-color: transparent; width: 100%; cursor: pointer; z-index: 1; appearance: none; position: absolute; border: 0px; touch-action: manipulation; height: 28px; left: 0px; margin: 0px;" aria-valuetext="00:00 of 1:29:30" max="5370.949958" id="progress-slider" class="cljcastr" aria-label="Seek" step="any"></input>
</div>
</div>
<div id="playback-controls" class="cljcastr">
<button aria-label="Back 15 seconds" id="button-backward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/495036/backward-15-seconds.svg" draggable="false" id="button-backward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Play" id="button-play" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/436852/play-circle-fill.svg" draggable="false" id="button-play-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Pause" id="button-pause" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/436829/pause-circle-fill.svg" draggable="false" id="button-pause-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Forward 15 seconds" id="button-forward" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/495321/forward-15-seconds.svg" draggable="false" id="button-forward-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
<button aria-label="Playback speed" id="button-speed" class="cljcastr playback-button" style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;">
<img src="https://www.svgrepo.com/show/151785/speed-meter.svg" draggable="false" id="button-speed-icon" class="cljcastr playback-icon" style="width: 35px; filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);"></img>
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>
</html>
Saving the file, we take a deep breath and refresh the page in the browser. Et voilà, our page reappears just as we'd left it! 🎉
This is wonderful and all, but that HTML that we produced is a bit of an eyesore:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>
cljcastr episode player
</title>
<link rel="stylesheet" href="main.css"></link>
...
</head>
<body>
<audio class="audio-for-speech" src=""></audio>
<div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
<div class=\"header-wrapper\">
<div class=\"header-controls\">
<span class=\"sound-translate\"></span>
<span class=\"copy blue\"></span>
<span class=\"settings-translator\"></span>
</div>
<div class=\"header-title\">translator</div>
<div class=\"translate-icons\">
<img class=\"from\" src=\"\"></img>
<img class=\"translate-arrow\" src=\"chrome-extension://bebmphofpgkhclocdbgomhnjcpelbenh/images/right-arrow.png\"></img>
<div class=\"translate_to_dropdown\">
<button class=\"dropbtn dropdown-toggle\" type=\"button\" id=\"dropdownMenuButton1\" aria-expanded=\"false\"></button>
<ul class=\"dropdown-content-wrapper languageSelector blue\">
<li>
<a class=\"dropdown-item\" data-lang=\"af\">Afrikaans</a>
</li>
<li>
<a class=\"dropdown-item\" data-lang=\"sq\">Albanian - shqipe</a>
</li>
<li>
<a class=\"dropdown-item\" data-lang=\"ar\">Arabic - العربية</a>
</li>
...
<li>
<a class=\"dropdown-item\" data-lang=\"yi\">Yiddish - יידיש</a>
</li>
</ul>
</div>
</div>
</div>
<div class=\"translated-text\">
...
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
...
</button>
</div>
</div>
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
</body>
</html>
In addition to all this whitespace, what's that weird stuff we didn't add to the DOM?
<audio class="audio-for-speech" src=""></audio>
<div class=\"translate-tooltip-mtz blue sm-root translate hidden_translate\">
<div class=\"header-wrapper\">
<div class=\"header-controls\">
...
<protonpass-root-c873 data-protonpass-role="root" data-protonpass-theme="os"></protonpass-root-c873>
It seems that extensions occasionally shove stuff into the DOM, so maybe we should give ourselves a way to filter out nodes we don't want. Let's start by adding a new arity to get-children that lets us filter and remove children using an options map:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn set-classes! ...)
(defn get-children
"Returns the children of the specified element. Accepts an `opts` map with the
following keys:
`:filter-children` - predicate passed to `filter` on list of children
`:remove-children` - predicate passed to `remove` on list of children
`filter` is called before `remove`."
([el]
(get-children el {}))
([el {:keys [filter-children remove-children]
:or {filter-children (constantly true)
remove-children (constantly false)} :as opts}]
(->> (get-el el)
.-childNodes
seq
(filter filter-children)
(remove remove-children))))
;; (defn add-child! ...)
;; ...
(comment
;; ...
(->> (get-children "#episode-info")
log)
;; => (#object[Text [object Text]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[Text [object Text]]
;; #object[HTMLHeadingElement [object HTMLHeadingElement]]
;; #object[Text [object Text]]
;; #object[HTMLSpanElement [object HTMLSpanElement]]
;; #object[Text [object Text]])
(->> (get-children "#episode-info"
{:filter-children (partial instance? js/HTMLHeadingElement)})
log
(map get-text))
;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n")
(->> (get-children "#episode-info"
{:remove-children (partial instance? js/Text)})
log
(map get-text))
;; => ("\ndefn\n" "\n876b4c306172 Cora Sutton\n" "\nJuly 9, 2025\n")
)
Now we need a way to pass those options in to get-children from el->html:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el opts]
(if (instance? js/CharacterData el)
(get-text el)
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el opts)]
(if (seq children)
(str/join "\n"
(concat [tag-open]
(map #(el->html % opts) children)
[tag-close]))
(str tag-open text tag-close))))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n\n
;; \n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n\n
;; \n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n\n
;; \n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\n
;; July 9, 2025\n\n</span>\n\n\n
;; </div>"
;; Remove empty text nodes
(el->html "#episode-info"
{:remove-children #(empty? (str/replace (get-text %) #"[\n\s]+" ""))})
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">\n\ndefn\n\n</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">\n\n876b4c306172 Cora Sutton\n\n</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">\n\nJuly 9, 2025\n\n</span>\n
;; </div>"
)
Now we have what we need to clean up our HTML a little bit:
(comment
;; ...
(let [empty-text? #(and (instance? js/Text %)
(empty? (str/replace (get-text %) #"[\n\s]+" "")))]
(el->html "html"
{:remove-children (fn [el]
(or (empty-text? el)
;; Bye-bye Proton Pass!
(str/starts-with? (or (get-name el) "")
"protonpass-")
;; Be gone, Translator!
(some #(str/starts-with? % "translate-")
(get-classes el))
(and (= "audio" (get-name el))
(not (some #{"cljcastr"} (get-classes el))))))}))
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>\n\ncljcastr episode player\n\n</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>\n\n
;; var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;\n\n
;; </script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; ...
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>"
)
This is cool and all, but the :remove-children function is getting a bit hairy, and we're haunted by the fact that we'll need to keep adding to it every time we install a new extension that inserts nodes into the DOM. This is the classic allowlist / denylist problem, and we can take a page out of the secure development notebook and deny all nodes by default, keeping only the ones we actually want.
We know that this means using :filter-children instead of :remove-children, but what should we allow? How about this:
Let's start by adding a function to check whether an element has a specific class:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-classes ...)
(defn has-class?
"Returns true if the element's list of classes contains the specified class."
[el cls]
(-> (get-classes el)
set
(contains? cls)))
;; (defn set-classes! ...)
;; ...
(comment
;; ...
(has-class? "#button-backward" "cljcastr")
;; => true
)
Now, how about a function that checks whether one element is a descendant of another?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn get-els ...)
(defn descendant?
"Returns truthy if the specified element is an ancestor of the element named by
the specified CSS selector. NOTE: `ancestor-sel` must be a string containing
a CSS selector, not an element!"
[el ancestor-sel]
(-> (get-el el)
(.closest ancestor-sel)))
;; (defn get-attr ...)
;; ...
(comment
;; ...
(descendant? "#button-backward" "#episode-container")
;; => #object[HTMLDivElement [object HTMLDivElement]]
(descendant? "#button-backward" "#episode-info")
;; => nil
)
Given this, we should be able to write the :filter-children function of our dreams:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn ??? ...)
;; (defn ??? ...)
;; ...
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => " <html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; ...
;; </div>\n
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
This is definite progress! We're still not loving that indentation, though.
Let's think about how we'd like our HTML indented:
<head> and <body> should not be indented<head> and <body> should reset indentationIn other words, we'd like our HTML to look like this:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>cljcastr episode player</title>
...
</head>
<body>
<div id="episode-container" class="cljcastr" style="width: 100%; display: flex; flex-direction: column; align-items: center;">
<div id="episode-info" class="cljcastr" style="max-width: 420px; text-align: center;">
<h3 id="podcast-title" class="cljcastr">defn</h3>
<h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
<span id="episode-date" class="cljcastr" style="color: grey;">July 9, 2025</span>
</div>
...
</body>
</html>
Let's start with rule #4. First of all, what does the content of one of our text nodes look like?
(comment
;; ...
(->> (get-children "#podcast-title")
first
get-text)
;; => "\ndefn\n"
)
OK, it starts with a newline and ends with two newlines. How about we get rid of those?
(comment
;; ...
(->> (get-children "#podcast-title")
first
get-text
str/trim)
;; => "defn"
)
Great! Now we can plug this into el->html. We also need to stop putting newlines between the children (this will mess up our formatting, but we'll address that as we work our way through the rest of the rules in our list).
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el opts]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs text]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
tag-open (str " <" tag (if (empty? attrs-str) "" (str " " attrs-str)) ">")
tag-close (str "</" tag ">")
children (get-children el opts)]
(if (seq children)
(str/join ""
(concat [tag-open]
(map #(el->html % opts) children)
[tag-close]))
(str tag-open text tag-close))))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#podcast-title")
;; => " <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>"
)
Great! That's rule #4 ticked off the list. Of course, this has made a bit of a mess of other things:
(comment
;; ...
(el->html "#episode-info")
;; => " <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\"> <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3> <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2> <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span></div>"
)
Note the extreme lack of newlines there. We can address this by adding some indentation rules to our opts map:
:indent-level - number of levels to indent:indent-str - string to use when indentingNow for a little helper function:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn attrs->str ...)
(defn indent
"Returns `indent-str` repeated `indent-level` times."
[indent-level indent-str]
(->> (repeat indent-level indent-str)
(str/join "")))
;; (defn el->html ...)
;; ...
(comment
;; ...
(->> (range 3)
(map #(str (indent % " ") "indent " %)))
;; => ("indent 0" " indent 1" " indent 2")
)
Let's plug this into el->html:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(el->html "#episode-info")
;; => "<div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n"
)
Biting our bottom lip to keep the fear at bay, we unleash el->html on the entire DOM:
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; ...
;; </head>\n
;; <body>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
This looks great! Or rather, almost great, because we forgot about rules #2 and #3:
2. `<head>` and `<body>` should not be indented
3. `<head>` and `<body>` should reset indentation
Turns out we can implement both of these rules with a one-line addition to el->html! Well, maybe two lines, because a comment seems appropriate.
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs-str (attrs->str attrs)
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
In case the two new lines where hard to spot, here they are again:
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
Now let's try again with the HTML dumping:
(comment
;; ...
(el->html "html"
{:filter-children (fn [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))})
;; => "<html xmlns=\"http://www.w3.org/1999/xhtml\">\n
;; <head>\n
;; <title>cljcastr episode player</title>\n
;; <link rel=\"stylesheet\" href=\"main.css\"></link>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.js\" type=\"application/javascript\"></script>\n
;; <script>var SCITTLE_NREPL_WEBSOCKET_PORT = 1340;</script>\n
;; <script src=\"https://cdn.jsdelivr.net/npm/scittle@0.8.33/dist/scittle.nrepl.js\" type=\"application/javascript\"></script>\n
;; <script type=\"application/x-scittle\" src=\"player.cljs\"></script>\n
;; </head>\n
;; <body>\n
;; <audio class=\"audio-for-speech\" src=\"\"></audio>\n
;; <div id=\"episode-container\" class=\"cljcastr\" style=\"width: 100%; display: flex; flex-direction: column; align-items: center;\">\n
;; <div id=\"episode-info\" class=\"cljcastr\" style=\"max-width: 420px; text-align: center;\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\" style=\"color: grey;\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; </body>\n
;; </html>\n
;; "
)
Looking at the browser console confirms that our HTML looks exactly as we wanted! And we could copy that sweet sweet HTML from the console and paste it into our index.html and move on with our lives... or we could shave just one more yak.
It's a bit annoying that we have to keep remembering the elements we want to keep when dumping our HTML, so let's create a nice little set of functions to take care of that:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn update-playback-position-indicators! ...)
(defn keep-child? [el]
(cond
(instance? js/Text el)
(-> (get-text el)
(str/replace #"[\n\s]+" "")
not-empty)
(instance? js/Element el)
(or (contains? #{"html" "head" "body"}
(get-name el))
(descendant? el "head")
(and (descendant? el "body")
(has-class? el "cljcastr")))))
(defn dom->html []
(el->html "html" {:filter-children keep-child?}))
;; (js/console.log ...)
;; ...
(comment
;; ...
(do
(log (dom->html))
nil)
;; => nil
)
Checking the console verifies that this is the HTML we're looking for.
But this is really not the yak we came here to shave. That big old annoying yak staring us in the face is the one that makes us use the mouse (the horror!) to copy a bunch of HTML from the browser console, and then pop back over to Emacs and switch to the index.html buffer and yank and then kill the old contents of the file and save. I mean, who has time for this kind of nonsense? Not us, that's for damn sure!
If only there was a way to save a file from the browser...
(comment
;; ...
(let [filename "boring.html"
html "<html><body><h1>Yawn...</body></html>"
blob-url (-> (js/Blob. [html] (clj->js {:type "text/html"}))
js/URL.createObjectURL)]
(-> (create-el {:tag "a"
:attrs {:download filename
:href blob-url}})
.click))
;; => nil
)
When we evaluate this form, we get a helpful save file dialog of the sort we've seen millions of times before, with boring.html helpfully prefilled.

If you were to save this file in your public directory alongside index.html and then browse to http://localhost:1341/boring.html, you would be quite bored.
But that's OK, for we have learned the magic incantation for saving a file:
text/html content type.blob: as its scheme) from said blob.download attribute set to a filename of our choosing and its href attribute set to the blob URL we created.This is quite straightforward to turn into a couple of functions:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn add-event-listener! ...)
(defn save-file! [filename content content-type]
(let [blob-url (-> (js/Blob. [content] (clj->js {:type content-type}))
js/URL.createObjectURL)]
(-> (create-el {:tag "a"
:attrs {:download filename
:href blob-url}})
.click)))
;; (defn create-button ...)
;; ...
;; (defn dom->html ...)
(defn save-html!
([]
(save-html! "index.html"))
([filename]
(save-html! filename (dom->html)))
([filename html]
(save-file! filename html "text/html")))
;; (js/console.log ...)
;; ...
(comment
;; ...
(save-html!)
;; => nil
)
When the save file dialog pops up, let's save it over top of our existing index.html and then reload the browser.
The page looks exactly the same as before, but our index.html is a work of art, formatted as purdy as a peach, as we say back where ah'm from (locating my point of provence shall remain an exercise for the reader, though that information is readily available in the latest episode of
defn—as of this writing, that is; I have no clue when you're reading this). 🍑
All is good in the world... except that clicking the buttons on our lovely player has no effect whatsoever. 😅
But fear not! 'Tis but the work of a moment to fix them. Moving back over to our player.cljs buffer, we mash C-c C-k (cider-load-buffer), then pop down to the bottom of our comment form and search backwards for "(def playback-controls". Once we find what we're looking for, we copy it and paste it into our namespace, right before our "Ready to play!" message:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(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"}})
(js/console.log "Ready to play!")
(comment
;; ...
)
We hit C-c C-k again, but are deeply saddened by an error message:
#error {:message "Unable to resolve symbol: seek-dur-sec",
:data {:type :sci/error, :line 10, :column 30,
:message "Unable to resolve symbol: seek-dur-sec",
;; ...
},
;; ...
}
Oops, looks like we need to grab seek-dur-sec as well:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(def seek-dur-sec 15)
;; (def playback-controls ...)
C-c C-k FTW?
Victory! 🏆
Or maybe more accurately, not defeat, since clicking the buttons still doesn't work. Which isn't that surprising, since we didn't actually attach any event listeners to anything. Let's see if we can figure out how to do that.
(comment
;; ...
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
;; => nil
)
Now if we click the play button, the episode starts playing! We skip backwards and forwards in the episode, try pausing it, and finally convince ourselves that all the buttons work.
The only problem is that the elapsed time, remaining time, progress bar, and slider aren't working. 🤔
Oh yeah, we had a handler for that as well. And we might as well attach the listener to the slider that updates the playback position too:
(comment
;; ...
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; => #object[HTMLAudioElement [object HTMLAudioElement]]
)
If we click play again, we see the timestamps updating and slider moving, and if we move the slider ourselves, we can successfully jump around in the episode. Life is good!
That is, until we hit C-c C-k again and realise that despite the console cheerily telling us that we're "Ready to play!", the buttons don't work. 😢
Of course, we can evaluate the two forms we just added to the Rich comment, but it's hardly a good user experience to expect our users to connect a ClojureScript REPL, paste in some code, and evaluate it before they can actually play an episode.
There is a pretty easy solution to this. If we just add the two magic forms right before our "Ready to play!" log statement, the listeners should be attached on page load. Let's try it:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (def playback-controls ...)
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; (js/console.log ...)
If we save the file and reload the page, the buttons now work from the start. This is good, but there is one oddity that we'll notice in the console if we pause playback then evaluate the buffer with C-c C-k again:
Added listener for click events to nrepl.cljs:10
‣ <button class="cljcastr" id="button-speed"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Playback speed">…</button>
Added listener for input events to nrepl.cljs:10
‣ <input class="cljcastr" id="progress-slider" type="range"
min="0" max="3138.727422" value="0"step="any"
style="top: 0px; background-color: transparent; width: 100%; cursor: pointer; z-index: 1; appearance: none; position: absolute; border: 0px; touch-action: manipulation; height: 28px; left: 0px; margin: 0px;"
aria-label="Seek" aria-valuetext="11:58 of 52:18"
aria-valuemax="3138.727422">
Added listener for timeupdate events to nrepl.cljs:10
‣ <audio class="cljcastr" controls style="display: none;"
src="https://media.zencastr.com/audio-files/5d9048546bacd9001de88f1c/2abc6f33-6a0a-4658-a8e0-c811984c13a0.mp3"></audio>
Ready to play!
And if we click play then pause again, we see that the click handlers are firing twice:
Handling click on nrepl.cljs:10
‣ <button class="cljcastr" id="button-pause"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Pause">…</button>
‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
Handling click on nrepl.cljs:10
‣ <button class="cljcastr" id="button-pause"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Pause">…</button>
‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
If we evaluate the buffer again, then click play and pause, we see the handlers firing thrice! 😱
Handling click on nrepl.cljs:10
‣ <button class="cljcastr" id="button-pause"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Pause">…</button>
‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
Handling click on nrepl.cljs:10
‣ <button class="cljcastr" id="button-pause"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Pause">…</button>
‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
Handling click on nrepl.cljs:10
‣ <button class="cljcastr" id="button-pause"
style="padding: 0px; margin: 0px; border: 0px; background: transparent; cursor: pointer;"
aria-label="Pause">…</button>
‣ PointerEvent {isTrusted: true, pointerId: 1, width: 1, height: 1, pressure: 0, …}
OK, so it appears that every time we evaluate the buffer, we get a new click handler. And if we think about it for a moment, it makes sense, because these forms:
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
;; (js/console.log ...)
are getting evaluated every time, since they are at the top level of our namespace. Luckily for us, if we whip out our microscope, we find a solution (and a precipitate, but that's beside the point): the mighty atom!
A common pattern in ClojureScript apps is to define a single atom that holds all of the state we care about. It is conventional to call this atom state, so let's follow said convention because we aren't iconoclastic hipsters, after all!
(ns cljcastr.player
(:require [clojure.string :as str]))
(defonce state (atom {}))
;; (defn log ...)
;; ...
We use defonce instead of plain ol' def, because otherwise every time we evaluated the buffer, our state var would be rebound to a fresh new atom, thus defeating the point of keeping state in the first place.
Now that we've done this, let's move our code that attaches event listeners into a function:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (def playback-controls)
(defn init-ui! []
(doseq [[el-id {:keys [click-handler]}] playback-controls]
(add-event-listener! (str "#button-" (name el-id)) "click"
click-handler))
(let [audio (get-el "audio.cljcastr")]
(add-event-listener! "#progress-slider" "input"
#(->> % .-target .-value (set! (.-currentTime audio))))
(add-event-listener! audio "timeupdate"
#(update-playback-position-indicators!
(.-currentTime audio) (.-duration audio)
(->> [:elapsed-time
:remaining-time
:progress-slider
:progress-bar]
(map (fn [el-id]
[el-id (str "#" (name el-id))]))
(into {}))))
audio)
(log "UI initialised"))
;; (js/console.log ...)
Now if we evaluate the buffer, we don't see "UI initialised!" get printed in the console. However, if we reload the page, we're back to our buttons not working, which makes sense, because we're never calling our new init-ui! function.
Let's fix that!
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn init-ui! ...)
(when-not (:initialised? @state)
(init-ui!)
(swap! state assoc :initialised? true))
;; (js/console.log ...)
If we reload the page now, we'll see our handlers getting installed and our log statement telling us "UI initialised", as well as the usual chipper "Ready to play!". And if we evaluate the buffer a couple times, we see "Ready to play!" being logged, but nothing about handlers and initialisation.

Better yet, everything seems to work!
Of course, we are not the sort of Clojurians to rest on our laurels, for there are still improvements to be made! It is considered a bit tacky these days to use inline styles, so let's see if we can take a page out of our el->html book and export our styles.
In fact, we already have a function to get a map of styles from an element (get-styles), and code in our set-styles! function that turns a map of styles into a CSS string, so it's but the work of a moment to squish them together:
(comment
;; ...
(->> (get-styles "#progress-bar")
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join "; "))
;; => "color: rgb(247, 147, 30); top: 10px; overflow: hidden;
;; background-color: lightgray; width: 100%; z-index: 0;
;; appearance: none; position: absolute; border: 0px;
;; border-radius: 4px; height: 8px; left: 0px; margin: 0px;"
)
What would be really cool, though, would be to produce a snippet like this:
#progress-bar {
color: rgb(247, 147, 30);
top: 10px;
overflow: hidden;
background-color: lightgray;
width: 100%;
z-index: 0;
appearance: none;
position: absolute;
border: 0px;
border-radius: 4px;
height: 8px;
left: 0px;
margin: 0px;
}
Honestly, that shouldn't be too tricky. Let's see what we can do:
(comment
;; ...
(let [el (get-el "#progress-bar")
styles (get-styles el)]
(str "#" (get-id el) " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "#progress-bar {\n
;; color: rgb(247, 147, 30);\n
;; top: 10px;\n
;; overflow: hidden;\n
;; background-color: lightgray;\n
;; width: 100%;\n
;; z-index: 0;\n
;; appearance: none;\n
;; position: absolute;\n
;; border: 0px;\n
;; border-radius: 4px;\n
;; height: 8px;\n
;; left: 0px;\n
;; margin: 0px;\n
;; }"
)
But what about elements without an ID?
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)]
(str "#" (get-id el) " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "# {\n display: none;\n}"
)
For that, let's fall back to the tag name, plus the class if it exists:
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)
id (get-id el)
classes (get-classes el)
sel (if (not-empty id)
(str "#" (get-id el))
(str (get-name el) (if (seq classes)
(str "." (first classes)))))]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "audio.cljcastr {\n display: none;\n}"
)
This is a bit clunky, though. It would be a lot cleaner if we used el->clj to turn the element into a Clojure datastructure:
(comment
;; ...
(let [el (get-el "audio.cljcastr")
styles (get-styles el)
id (get-id el)
classes (get-classes el)
sel (if (not-empty id)
(str "#" (get-id el))
(str (get-name el) (if (seq classes)
(str "." (first classes)))))]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
;; => "audio.cljcastr {\n display: none;\n}"
)
This code is so special it deserves a name! And lo! that name shall be el->css, and it shall be good.

(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->html ...)
(defn format-css
"Returns a CSS string with the specified selector and styles"
[sel styles]
(str sel " {\n "
(->> styles
(map (fn [[k v]]
(str (name k) ": " v)))
(str/join ";\n "))
"\n}"))
(defn el->css
"Given an element (or the Clojure representation of an element), returns a CSS
rule corresponding to the element's ID."
[el]
(let [el (log (if (map? el) el (-> (get-el el) el->clj)))
{:keys [styles id tag classes]} el
sel (if (not-empty id)
(str "#" id)
(str tag (if (seq classes)
(str "." (first classes)))))]
(format-css sel styles)))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (el->css "#progress-bar")
log)
;; => "#progress-bar {\n
;; color: rgb(247, 147, 30);\n
;; top: 10px;\n
;; overflow: hidden;\n
;; background-color: lightgray;\n
;; width: 100%;\n
;; z-index: 0;\n
;; appearance: none;\n
;; position: absolute;\n
;; border: 0px;\n
;; border-radius: 4px;\n
;; height: 8px;\n
;; left: 0px;\n
;; margin: 0px;\n
;; }"
)
This is pretty cool, so why don't we add a function to generate CSS for multiple elements at a time?
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->css ...)
(defn els->css
"Given an collection of elements (or Clojure representations of elements),
returns CSS rules corresponding to the elements' IDs."
[els]
(->> els
(map el->clj)
(remove (comp empty? :styles))
(map el->css)
(str/join "\n\n")))
;; (defn create-el ...)
;; ...
(comment
;; ...
(->> (els->css (get-els ".cljcastr"))
log)
;; => "#episode-container {\n
;; width: 100%;\n
;; display: flex;\n
;; flex-direction: column;\n
;; align-items: center;\n
;; }\n
;; \n
;; #episode-info {\n
;; max-width: 420px;\n
;; text-align: center;\n
;; }\n
;; \n
;; ...
)
Casting our gaze upon the console, joy fills our hearts:

However, (and you knew the however was coming, eh dear reader?) our heart rankles at the repetition inherent in the system.
What people do when they write CSS is use classes to combine related sets of elements. We're not going to be able to do that completely programmatically, but we can at least spit out something that's a better starting point for a person to pick up and shape to their liking.
How might we go about this?
(comment
;; ...
(def style-groups
(->> (get-els ".cljcastr")
(map el->clj)
(remove (comp empty? :styles))
(group-by :styles)))
;; => #'cljcastr.player/style-groups
(count style-groups)
;; => 13
(->> style-groups
(map (fn [[_styles els]]
(map #(select-keys % [:tag :id :classes]) els))))
;; => (({:tag "div", :id "progress-container", :classes ("cljcastr")})
;; ({:tag "div", :id "progress", :classes ("cljcastr")})
;; ({:tag "progress", :id "progress-bar", :classes ("cljcastr")})
;; ({:tag "img", :id "episode-art", :classes ("cljcastr")})
;; ({:tag "input", :id "progress-slider", :classes ("cljcastr")})
;; ({:tag "span", :id "episode-date", :classes ("cljcastr")})
;; ({:tag "div", :id "episode-info", :classes ("cljcastr")})
;; ({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
;; ({:tag "audio", :classes ("cljcastr")})
;; ({:tag "span", :id "elapsed-time", :classes ("cljcastr")})
;; ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")})
;; ({:tag "span", :id "remaining-time", :classes ("cljcastr")})
;; ({:tag "div", :id "episode-container", :classes ("cljcastr")}))
)
What we're doing here is getting all the elements having "cljcastr" as one of their classes, turning them into Clojure data structures, removing the ones with no inline styles, then grouping them by their styles. This results in a map where the keys are styles and the values are the list of the elements with those styles (see the documentation for the group-by function if this is confusing). We then map over that to look at the tag, ID, and classes of the elements with each unique set of styles.
Grouping elements like this seems useful, so let's create a function to do it:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn el->clj ...)
(defn group-styles
"Given a collection of DOM elements, returns a map keyed by styles. The value
at each key will be a vector of the corresponding elements, in the order they
appeared in the original list.
Example:
(let [els [(create-el {:tag \"div\", :id \"el1\", :styles {:color \"red\"}})
(create-el {:tag \"div\", :id \"el2\", :styles {:color \"blue\"}})
(create-el {:tag \"div\", :id \"el3\"})
(create-el {:tag \"div\", :id \"el4\", :styles {:color \"red\"}})]]
(->> (group-styles els)
(map (fn [[styles els]]
[styles (map #(select-keys % [:id]) els)]))))
;; => ([{:color \"red;\"} ({:id \"el1\"} {:id \"el4\"})]
;; [{:color \"blue;\"} ({:id \"el2\"})])"
[els]
(->> els
(map el->clj)
(remove (comp empty? :styles))
(group-by :styles)))
;; (defn attrs->str ...)
;; ...
Now that we have this, let's see how many of these styles apply to multiple elements:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(map #(select-keys % [:tag :id :classes]) els))))
;; => (({:tag "img", :id "button-backward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("cljcastr" "playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("cljcastr" "playback-icon")})
;; ({:tag "button", :id "button-backward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-play", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-pause", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-forward", :classes ("cljcastr" "playback-button")}
;; {:tag "button", :id "button-speed", :classes ("cljcastr" "playback-button")}))
)
These look like great candidates for consolidation! But what selector should we use to write the rule?
Well, we know that all of our elements share the "cljcastr" class in common, so if we exclude that, we see that both of these groups are left with a single class!
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(map (fn [el]
(-> (select-keys el [:tag :id :classes])
(update :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
els))))
;; => (({:tag "img", :id "button-backward-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-play-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-pause-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-forward-icon", :classes ("playback-icon")}
;; {:tag "img", :id "button-speed-icon", :classes ("playback-icon")})
;; ({:tag "button", :id "button-backward", :classes ("playback-button")}
;; {:tag "button", :id "button-play", :classes ("playback-button")}
;; {:tag "button", :id "button-pause", :classes ("playback-button")}
;; {:tag "button", :id "button-forward", :classes ("playback-button")}
;; {:tag "button", :id "button-speed", :classes ("playback-button")}))
)
Let's run those through els->css and see what we get:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles els]]
(->> els
(map (fn [el]
(update el :classes #(remove (fn [cls] (= "cljcastr" cls)) %))))
els->css)))
(str/join "\n\n")
log)
;; => "#button-backward-icon {\n width: 35px;\n ...}"
)
Looking at the console, we see that we're back where we started:
#button-backward-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-play-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-pause-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-forward-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-speed-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
#button-backward {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-play {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-pause {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-forward {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#button-speed {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
What we really want to do is generate a single rule for each group, with the remaining class as the selector. If we update el->css with a second arity that accepts a selector as an argument, we can accomplish this fairly easily:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn format-css ...)
(defn el->css
"Given an element (or the Clojure representation of an element), returns a CSS
rule corresponding to the element's ID."
([el]
(let [el (if (map? el) el (-> (get-el el) el->clj))
{:keys [styles id tag classes]} el
sel (if (not-empty id)
(str "#" id)
(str tag (if (seq classes)
(str "." (first classes)))))]
(el->css el sel)))
([el sel]
(format-css sel (:styles el))))
;; (defn els->css ...)
;; ...
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(filter (fn [[_ els]] (> (count els) 1)))
(map (fn [[_styles [el & _]]]
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))))
(str/join "\n\n")
log)
;; => ".playback-icon {\n width: 35px;\n ...}"
)
And now the console fills our hearts with delight once again!
.playback-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
.playback-button {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
Let's now take a step back and think about how we want to generate the rules:
el->css on itel->css on the element, using the custom selectorAnd if we say this in Clojure, it looks like this:
(comment
;; ...
(->> (get-els ".cljcastr")
group-styles
(map (fn [[_styles [el & rest-els]]]
(if (seq rest-els)
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))
(el->css el))))
(str/join "\n\n")
log)
;; => "#progress-container {\n position: relative;\n height: 28px;\n ...}"
)
If we look at the console, it appears that all of our prayers are answered! 🎉
#progress-container {
position: relative;
height: 28px;
clear: both;
}
#progress {
width: 390px;
margin: 1.5em auto 0px;
position: relative;
}
#progress-bar {
color: rgb(247, 147, 30);
top: 10px;
overflow: hidden;
background-color: lightgray;
width: 100%;
z-index: 0;
appearance: none;
position: absolute;
border: 0px;
border-radius: 4px;
height: 8px;
left: 0px;
margin: 0px;
}
#episode-art {
width: 400px;
border-radius: 5%;
padding: 10px;
}
#progress-slider {
top: 0px;
background-color: transparent;
width: 100%;
cursor: pointer;
z-index: 1;
appearance: none;
position: absolute;
border: 0px;
touch-action: manipulation;
height: 28px;
left: 0px;
margin: 0px;
}
#episode-date {
color: grey;
}
#episode-info {
max-width: 420px;
text-align: center;
}
.playback-icon {
width: 35px;
filter: invert(59%) sepia(92%) saturate(611%) hue-rotate(343deg) brightness(97%) contrast(99%);
}
audio.cljcastr {
display: none;
}
#elapsed-time {
margin: 0px 5px;
font-size: 0.7rem;
color: grey;
float: left;
}
.playback-button {
padding: 0px;
margin: 0px;
border: 0px;
background: transparent;
cursor: pointer;
}
#remaining-time {
margin: 0px 5px;
font-size: 0.7rem;
color: grey;
float: right;
}
#episode-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
Now that we have all of these lovely styles, we can write a couple of functions to export them, following the same pattern as for save-html!:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn save-html! ...)
(defn dom->css []
(->> (get-els ".cljcastr")
group-styles
(map (fn [[_styles [el & rest-els]]]
(if (seq rest-els)
(let [sel (str "." (->> (:classes el)
(remove (partial = "cljcastr"))
first))]
(el->css el sel))
(el->css el))))
(str/join "\n\n")
log))
(defn save-css!
([]
(save-css! "main.css"))
([filename]
(save-css! filename (dom->css)))
([filename css]
(save-file! filename css "text/css")))
;; (def seek-dur-sec ...)
;; ...
(comment
;; ...
(save-css!)
;; => nil
)
We evaluate the save-css! form and save the main.css file alongside our index.html. Now all we have to do to use the styles is two things:
index.htmlThe first part is simple, we just add one element to the DOM:
(comment
;; ...
(->> (create-el {:tag "link",
:attrs {:rel "stylesheet", :href "main.css"}})
(add-child! "head"))
;; => #object[HTMLHeadElement [object HTMLHeadElement]]
)
And now for the new HTML. Let's update el->html to take a new option called :exclude-styles?, and then emit the HTML without inline styles if it's truthy:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn indent ...)
(defn el->html
"Given an element (or the Clojure representation of an element), returns an
HTML string."
([el]
(el->html el {}))
([el {:keys [indent-level indent-str exclude-styles?]
:or {indent-level 0, indent-str " "}
:as opts}]
(if (instance? js/CharacterData el)
(str/trim (get-text el))
(let [{:keys [tag attrs]} (if (map? el) el (el->clj el))
attrs (if exclude-styles? (dissoc attrs :style) attrs)
attrs-str (attrs->str attrs)
;; Reset indentation for <head> and <body> tags
indent-level (if (#{"head" "body"} (str/lower-case tag)) 0 indent-level)
children-html (->> (get-children el opts)
(map #(el->html % (assoc opts :indent-level
(inc indent-level))))
(str/join ""))
tag-open (str (indent indent-level indent-str)
"<" tag
(if (empty? attrs-str) "" (str " " attrs-str))
">"
(if (str/ends-with? children-html "\n")
"\n"
""))
tag-close (str (if (str/ends-with? children-html "\n")
(indent indent-level indent-str)
"")
"</" tag ">\n")]
(str tag-open children-html tag-close)))))
;; (defn format-css ...)
;; ...
(comment
;; ...
(->> (get-els "body > .cljcastr")
(map #(el->html % {:exclude-styles? true}))
(str/join "\n")
log)
;; => "<div id=\"episode-container\" class=\"cljcastr\">\n
;; <div id=\"episode-info\" class=\"cljcastr\">\n
;; <h3 id=\"podcast-title\" class=\"cljcastr\">defn</h3>\n
;; <h2 id=\"episode-title\" class=\"cljcastr\">876b4c306172 Cora Sutton</h2>\n
;; <span id=\"episode-date\" class=\"cljcastr\">July 9, 2025</span>\n
;; </div>\n
;; ...
;; </div>\n
;; "
)
Let's update dom->html and save->html! to allow us to exclude styles if we wanna. But how to do it? dom->html is simple: we just add a 1-arity version of the function that takes an options map. save-html! on the other hand requires a tiny bit of design. We could add yet another arity, but then we have the problem of distinguising between what the arguments mean. Let's simplify a bit: we'll have the 0-arity function to fill in all the defaults, and a 1-arity version taking an options map:
(ns cljcastr.player
(:require [clojure.string :as str]))
;; ...
;; (defn keep-child? ...)
(defn dom->html
([]
(dom->html {}))
([opts]
(el->html "html"
(merge opts
{:filter-children keep-child?}))))
(defn save-html!
([]
(save-html! {}))
([{:keys [filename html]
:or {filename "index.html"}
:as opts}]
(let [html (or html (dom->html opts))]
(save-file! filename html "text/html"))))
;; (defn save-html! ...)
;; ...
Now we can save a new version of index.html, this time without the styles:
(comment
;; ...
(save-html! {:exclude-styles? true})
;; => nil
)
After saving, let's check index.html to make sure things look good:
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>cljcastr episode player</title>
<link rel="stylesheet" href="main.css"></link>
<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>
<div id="episode-container" class="cljcastr">
<div id="episode-info" class="cljcastr">
<h3 id="podcast-title" class="cljcastr">defn</h3>
<h2 id="episode-title" class="cljcastr">876b4c306172 Cora Sutton</h2>
<span id="episode-date" class="cljcastr">July 9, 2025</span>
</div>
...
</div>
</body>
</html>
Holding our breath, we reload the page, and... see the same thing we were just looking at. 😅
But actually, that's precisely what we were trying to achieve. If we open dev tools and inspect the play button, we see two exciting things:
.playback-button class being applied, defined in main.css
At this point, we've built a nice podcast player interactively, then created a persistence mechanism so we can safely reload the page. This feels really good, right? 😀
We bask in the feeling for an altogether brief moment, then start thinking of ways to improve things. Like adding that transcript we were talking about many thousands of word ago? Or maybe not limiting ourselves to a single defn episode (even if it is a 🔥 episode!)?
Our minds start to fill with the possibilities, but we come to our senses and decide some hammock time (in the form of a nice long walk perhaps—maybe even with one of our favourite human or canine companions?) is in order.
Next time on Scittlecastr, we return from our hammock time and get to scittling them features!
The preview image for this post was based on the Teenager looking at equation on chalk board photo by Rafa Fernandez from his Back To School Boy photoshoot. The photo is used under The Noun Project's Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.

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've 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:
cljcastr is hosted on Codeberg at jmglov/cljcastr.
In this blog post, I'm going to demonstrate the cljcastr ethos by implementing a simple podcast player in ClojureScript, using the mighty Scittle. This is gonna be a long one (even by my standards), so here's a table of contents to help us all stay oriented.
To get started, we're going to need a Babashka project set up to serve files from a local webserver. Let's 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 '-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'll 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's play!
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's do it!
This section assumes that you are a reasonable person and are thus using Emacs with CIDER, but if you've 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's 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're all set up for interactive development, so let's interact. First, execute cider-load-buffer (C-c C-k) in the player.cljs buffer. If you're watching the JS console in your browser, you'll see something like the following pop up:
Ready to play! nrepl.cljs:10
>
And meanwhile in your terminal, you'll 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've now completed the circle of LISP, meaning we can control everything on our player page with ClojureScript! 💥
Let's try some simple arithmetic just to get a sense of the power at our fingertips. In the player.cljs buffer, we'll 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're 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'll get a comment on the next line saying
;; => nil
This is somewhat underwhelming, but if you take a peek at your browser console, you'll be more satisfied:
3 nrepl.cljs:10
>
Can we have our cake and eat it too? (Note that from here on, I'll assume you're 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're cooking with biofuel!
Let's 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're seeing is a JavaScript representation of a ClojureScript vector. This is of course super cool and all, but not so readable (unless you're Rich Hickey and you don't even see the persistent data structures but just the woman in the red dress). Let's 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's up with this nonsense?
;; => #js [1 2 3 4 5]
Now we're seeing ClojureScript's JavaScript data literal. 😅
Let's try one more thing:
(comment
;; ...
(let [res (map inc (range 5))]
(js/console.log (clj->js res))
res)
;; => (1 2 3 4 5)
)
That's 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.
Let's add an h1 HTML element to the page to let users know what's 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've carefully built up in our REPL. 😭
Let's see if we can do this in the REPL instead. The basic idea is this:
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"))
;; => #'cljcastr.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"))
;; => #'cljcastr.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's 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
Let's find ourselves the URL of a random podcast episode and add an audio element to the page:
(comment
;; ...
(def audio (js/document.createElement "audio"))
;; => #'cljcastr.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!

And if we click play, we hear the defn theme music.
We could declare victory right here, but we're missing a few things that a podcast player usually has, such as:
Let's see what we can do about that.
The standard HTML audio control is functional, but a bit boring. In our hubris, we decide we can do way better than that!
Let's look at a typical podcast player for inspiration:

It looks like we'll want the following buttons:
Let's 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's 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's where we land:
Let's create a set of HTML images for each. We'll 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"}})
;; => #'cljcastr.player/playback-controls
(def seek-dur-sec 15)
;; => #'cljcastr.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))
;; => #'cljcastr.player/playback-buttons
)
Note that we're adding a playback-icon class to the element in addition to our standard cljcastr class, and also giving the element an ID so it's 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))
;; => #'cljcastr.player/playback-controls-container
(doseq [el playback-buttons]
(.appendChild playback-controls-container el))
;; => nil
)
Finally, let's display the controls!
(comment
;; ...
(.appendChild body playback-controls-container)
;; => #object[HTMLDivElement [object HTMLDivElement]]
)

Let's set the width of the images to a more reasonable size:
(comment
;; ...
(doseq [el playback-buttons]
(set! (.-style el) "width: 35px"))
;; => nil
)
That's much better! However, this black and white page is starting to bum us out.
Since our controls are SVGs, we can use the magic of CSS filters to colour them.
Let's 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't be specified in something as prosaic as hex colour codes, so we'll 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't 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!

One thing that's slightly annoying is that we had to remember to include to include the width style when we added the filters. Why don't we write some code that adds a style to an existing element whilst preserving existing styles?
First, let's 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+")]
[(-> (str/trim k) keyword) (str/trim 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, 'tis 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's 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
)
Now that we have visually stunning buttons, let's actually make them do something.
Or rather, let's 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't 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't get enough hammock time. Let's 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"))
;; => #'cljcastr.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.

Now we can do the same for the pause button:
(comment
;; ...
;; Grab the pause button
(def pause-button (js/document.querySelector "#button-pause"))
;; => #'cljcastr.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's no JS function to tell the audio object "skip forward x seconds", there's only a currentTime property that we can query and set:
(comment
;; ...
(.-currentTime audio)
;; => 29.396358
(set! (.-currentTime audio) 15.0)
;; => 15
)
Given this, it's 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's probably a good idea not to skip forward past the end of the episode or backward past the beginning, so let's 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't 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's 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"))
;; => #'cljcastr.player/forward-button
(def backward-button (js/document.querySelector "#button-backward"))
;; => #'cljcastr.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's a little more complicated, so let's save it for later. 😉)
The only thing that's obviously wrong with this amazing player is that it has two sets of controls, the standard HTML one and our amazing buttons. There's also something that's 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're buttons is by their appearance, because we're using img elements, which are semantically just images. They could be images of an elephant for all a screen reader knows.
Let's see what we can do about that!
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's 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's 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's make sure it works for them, using the power of ARIA!

No, not that one. 😉
This one: ARIA.
If we wrap each of our img elements in a button element, we're 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're in the process of writing). And if we set that button's aria-label attribute, we're 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"}})
;; => #'cljcastr.player/playback-controls
)
Turns out we had accessibility in mind all along!
Let's 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't 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're skipping around. Let's 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's 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's easy as 123, ABC, baby you and me"]
(str/replace s #"(\d)+.+([A-Z]+)"
(fn [match-vector] (str "<<<" (pr-str match-vector) ">>>"))))
;; => "It's easy as <<<[\"123, ABC\" \"3\" \"C\"]>>>, baby you and me"
)
Let's 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't exist in the context map, the template variable won't 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've pulled another nice thing out of Clojure's 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'd buttons onto our playback-controls div:
(comment
;; ...
;; Throw away the current crappy buttons
(.replaceChildren playback-controls-container)
;; => nil
;; Let's fix the order of our buttons whilst we're 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))
;; => #'cljcastr.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"))
;; => #'cljcastr.player/play-button
(def pause-button (js/document.querySelector "#button-pause"))
;; => #'cljcastr.player/play-button
(def forward-button (js/document.querySelector "#button-forward"))
;; => #'cljcastr.player/forward-button
(def backward-button (js/document.querySelector "#button-backward"))
;; => #'cljcastr.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
)

The only problem with this approach is that our buttons now look like, well, buttons. 😅
Let's 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))
;; => #'cljcastr.player/playback-buttons
(doseq [el playback-buttons]
(.appendChild playback-controls-container el))
;; => nil
(def play-button (js/document.querySelector "#button-play"))
;; => #'cljcastr.player/play-button
(def pause-button (js/document.querySelector "#button-pause"))
;; => #'cljcastr.player/play-button
(def forward-button (js/document.querySelector "#button-forward"))
;; => #'cljcastr.player/forward-button
(def backward-button (js/document.querySelector "#button-backward"))
;; => #'cljcastr.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's do some cleaning up.
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're logging. In other words, we'd 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's 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're 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't 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's 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've seen, it doesn't look good in the console when logged.
Let's 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's see if we can apply what we've 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's 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:

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"}})
;; => #'cljcastr.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))
;; => #'cljcastr.player/playback-buttons
(set-children! "#playback-controls" playback-buttons)
;; => #object[HTMLDivElement [object HTMLDivElement]]
)
OK, so we've 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's fix.
Making our audio element disappear isn't that hard. Let's 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+")]
[(-> (str/trim k) keyword) (str/trim 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 elementWe 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))
;; => #'cljcastr.player/playback-buttons
(set-children! "#playback-controls" playback-buttons)
;; => #object[HTMLDivElement [object HTMLDivElement]]
)

Testing our controls, we verify that they still work. However, we've lost three important things:
Actually, on the third point, we like Overcast's choice to display the time remaining instead of the duration, so let's do that as well.
Having made these bold design decisions, let's get to implementing! We'll 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"}))
;; => #'cljcastr.player/cur-time
(def remaining-time (create-el {:tag "span"
:text (str (- (.-duration audio)
(.-currentTime audio)))
:classes ["cljcastr"]
:id "remaining-time"}))
;; => #'cljcastr.player/remaining-time
(doseq [el [elapsed-time remaining-time]]
(add-child! "#playback-controls" el))
;; => nil
)

Well, this is something, at least. Something incredibly confusing and also ugly. 😭
Let's enumerate the problems:
h:mm:ss.Now that we know what's wrong, let's tackle things one at a time.
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's 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's 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's 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've 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
)

Whilst we're at it, let's 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's 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's address the last problem.
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've 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's 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"}}))
;; => #'cljcastr.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))
;; => #'cljcastr.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))
;; => #'cljcastr.player/episode-art
(def progress (create-el {:tag "div"
:id "progress"
:classes ["cljcastr"]
:styles {:width "390px"
:margin "1.5em auto 0 auto"}}))
;; => #'cljcastr.player/progress
(def playback-controls-container (-> (create-el {:tag "div"
:id "playback-controls"
:classes ["cljcastr"]})
(set-children! playback-buttons)
log))
;; => #'cljcastr.player/playback-controls-container
(def playback-speed (create-el {:tag "div"
:id "playback-speed"
:classes ["cljcastr"]}))
;; => #'cljcastr.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]]
)

This is looking really good (relatively speaking), but there's one major component missing.
We have no idea where in the episode we are! Let's 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"}}))
;; => #'cljcastr.player/progress-bar
)
Now that we have the progress bar, let's 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]]
)

Hurrah! I mean, it's a bit ugly, but at least it's there! 😅
Let's 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't 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's 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"}}))
;; => #'cljcastr.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's 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're 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:
Let's 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's extract it into a function. And whilst we're at it, maybe we can extract functions for the attribute and value setting so we don't 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's one final touch we should apply here before we move on. It's 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's another thing that's 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:

What we'd 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're going to have to learn a bit about CSS positioning.
Basically, we'll 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's 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:
With that advanced mathematics out of the way, let's make it happen!
(comment
;; ...
(def progress-container
(create-el {:tag "div"
:id "progress-container"
:classes ["cljcastr"]
:styles {:position "relative"
:height "28px"
:clear "both"}}))
;; => #'cljcastr.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]]
)

This is looking pretty good! There are a few more things we might want to do, but just as we're about to start doing some of those things, a bone-chilling thought occurs to us: what if we reload the page? We've 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're 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've 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.
The preview image for this post was based on the Arab woman listening to music with earphones and dancing outdoors photo by Javier Sanchez Mingorance from his Woman In Overall Dress photoshoot. The photo is used under The Noun Project's Royalty-Free Commercial License, and was remixed by hand in the GIMP, which does not allow genAI contributions.

Last Wednesday The Wednesday before last (fine blogging takes time to cook, you know), I attended Func Prog Conf here in Stockholm. It was my first time at the conference, having missed the inaugural instantiation last year somehow. In fact, I would have missed this year as well had not Arne texted to ask if I was going. I played it cool, "Maybe. Because I totally knew about this all along and I just wasn't sure if I could fit it into my awesome life with all the totally rad stuff that I'm always doing." Having maintained my street cred, I ran to my search engine of
choice and tried to figure out what the hell he was talking about. 😅
It turns out that Func Prog Conf is a small one day event in Stockholm, just five talks and 180 people. It's organised by the great people over at Func Prog Sweden, who run a meetup group with an interesting twist: every other meetup is online, so they're able to have speakers from all over Europe (and occasionally further afield). Their YouTube channel is definitely worth a look. They are quite ecumenical, with talks from the hardcore type enthusiasts to the Lisp hippies with their laid-back approach to the serious engineers who are down with OTP (yeah you know me). I'd definitely recommend checking out their next meetup, whether in person if you happen to be in Stockholm, or streaming live on YouTube if you happen to be in a compatible timezone or a shifted sleep schedule or high on life or whatever.
Anyway, back to the conference. It was held at a really cool venue in Slussen called Kägelbanan, high up on the cliff overlooking the Baltic Sea. It's part of the Södra Teatern complex, which has a really nice brunch on Sunday mornings. Definitely worth a visit in the summer, as much for the view as the food itself.
Sorry, I just realised that I went on a tangent again when I really really intended to talk about the conference itself. 🤦🏼
Oh wait, there's just one more thing. 😅
The story of the conference actually starts the day before, at least for me. You see, our local Clojure group, sthlm.clj, had organised a Func Prog Conf Prep on the previous evening, which I only found out about because Arne mentioned that he would also be there (with customary humility, he didn't mention that he was giving a talk at the meetup, of course). The meetup was scheduled to start at 16:00, and I was just a tiny bit late due to a frenetic work schedule (every manager knows that the pleasure of taking a day off is accompanied by the pain of having to shift all of the work you would have done on that day to other days). When I walked in a few minutes after 16:00, there was already a talk underway. I slunk into a seat in the back row, eyes downcast as I radiated shame.
Safely ensconced in my seat, I finally looked up at the speaker, and got a lovely shock as I realised it was none other than Christoph Neumann, Clojure developer advocate and all around fine fellow. It turned out that Christoph was doing a run-through off the talk he would be presenting at the conference the next day, a fact to which I was not privy, due to me not having actually read the speaker lineup for the conference.
After he finished, I went over to say hi, and right around the same time, Arne rolled in. Hugs were dispensed, conversation flowed, and the food arrived. As we were chowing down and chatting, Arne said something about his talk, and when I asked for clarification, it turned out that he was giving a talk at this very meetup, a fact to which I was not privy, due to me not having actually read the speaker lineup for the meetup.
If one were feeling particularly generous, one could describe my approach to life as being in the moment; whereas a more ungenerous interpretation would go more along the lines of wtf is wrong with you Josh do you actually read bro?
I throw myself upon the mercy of the readership to adopt a view more akin to the generous one. 🙏
Anyway, as I said, much milling about was occurring, and I made another astute observation:

Christoph was wearing a Calva t-shirt with a wicked awesome Clojure in-joke! Readers of the Clojure posts in this blog are likely already familiar with the Rich comment, but for those who aren't:
"These comments are rich because they provide rich detail about the development process and because they were written by a person named Rich."
- Stuart Halloway, Running With Scissors
There's a good explanation of Rich comments on the Between Two Parens blog, which I encourage y'all to read.
19:00 arrived, and with it, the first of the evening's two talks: Markus Holmström's: Cellsify – A take on Spreadsheets you didn't expect and now you need it in your life. This talk was as funny as it was mind expanding (spoiler alert: it's spreadsheets all the way down), and the second it was over, I DM'd Markus on Clojurians Slack to invite him on defn.
Next up was (a.k.a Arne Brasseur a.k.a. plexus), with Datastar – how to use it with Clojure and some of the architectural choices. I had read some about Datastar, but had gotten it confused with some other frontend thingy that used Datalog that was maybe developed by Roman Liutikov but maybe not since I can't find it in my memory or in my search engine... send paramedics please!
But like, yeah, Datastar. Seems pretty cool. But don't take my word for it; watch the talk! 😉
Even before the applause died down, I was on my feet, sprinting out of the Agical office and through Gamla Stan to catch my train.
I shall spare you the details of the rest of my evening (don't worry, I caught my train) and finally get around to talking about the conference itself.
Fast forward then to the next morning. The doors were set to open at 08:00, so I got myself to Slussen in good time, then walked up to the venue and arrived at about 07:50. Being a good Swede, I lurked just outside so I could be exactly punctual—not a minute early, not a minute late. Unfortunately, I rolled a 5 on my lurk check, and was detected by one of the Kägelbanan staff, who was out on the terrace in front of the venue having a crafty cig. Like, a real cigarette, with tobacco and all, not one of those new-fangled e-cigarettes that all the damn kids are puffing away on these days (get off my lawn, you damn kids!). That was surprising, but super cool, because you rarely see people going full James Dean in Stockholm anymore.
Even more surprisingly, she struck up a conversation with me. 🤯
You see, Stockholmers rarely so much as make eye contact with strangers, and never actually say so much as "hej" to them (unless out hiking, in which case they are downright friendly; or neighbours, in which case they might give a curt nod, a "hej" if they're feeling friendly, or even a "hej hej" if they're high on ecstasy or whatever). I guess this woman was primed for interacting with a bunch of foreigners (or maybe high on ecstasy, I dunno), but converse she did. I stumbled through the conversation in my barely functional Swedish, thinking I was getting away with it until she asked "Pratar du engelska på jobbet eller?" I hung my head and admitted that yes, I did in fact speak English at work, and yes, I was embarrassed that I hadn't learned Swedish in my 16 years here. Luckily, she wasn't the SD type, so she let me off the hook with the standard "Men svenskar alskar att prata engelska, så bli det tufft att lära sig svenska." I nodded, still appropriately ashamed of my laziness.
Anyway, we chatted away for a few minutes, then she told me it was actually OK to go in a couple minutes early (and by this, I mean a literal couple, since it was 07:58). I thanked her effusively, then headed inside to find my badge. I said hi to Magnus, one of the organisers of both the Func Prog Sweden group and the conference itself, and he told me that I wasn't in fact the first attendee to arrive—apparently someone else had been there since just after 07:00!
Grabbing my badge, I headed into the mingle area and armed myself with a coffee and a nice breakfast roll. I spotted Felix Alm setting up the Multiply booth, and walked over to chat. A few more people started arriving, and I had a really nice chat with a guy named Mauricio about Scala and Clojure and Lisp and programming philosophy and so on. He helped me distribute some defn and Pitch stickers whilst we talked, then finally it was nearing 09:00, when the conference officially kicked off.

I made a beeline for the auditorium so I could get the perfect seat (third row, right side, aisle), and luxuriated in the sounds of silence as I checked out the setup. After a minute or two, Magnus walked out on stage and gave his opening remarks one last look as people started to file into the auditorium and find their seats. I snapped a quick picture, then whipped out my trusty Babashka-branded notebook and settled back in my seat, ready to be entertained and educated by the stellar lineup of speakers that had been assembled for the day.

Christoffer Ekeroth from Functional Software (a Swedish recruitment agency specialising in functional programming roles) appeared in the wings, surveying the crowd. When he felt the moment was right, he strode onto stage and with a gesture, invoked the spotlights. He and Magnus assumed b-boy stances (OK, maybe that was just in my mind) and a hush fell on the crowd. They kicked off the event by introducing themselves, giving a brief history of the conference, and introducing the sponsors.

After brief remarks from each sponsor, it was time for the opening keynote: "Transforming Software Evolution: Lessons from 10 Years of Clojure in Production", by Adam Tornhill.

I learned two things in the first 30 seconds of Adam's talk:
The reasons for the first one were twofold: I hadn't actually looked at the talk lineup before the conference (as mentioned above), and another super embarrassing reason that shall be revealed presently.
The reason for the second one was far simpler: I had never seen Adam speak or heard him interviewed on a podcast (the former is now solved, whilst the latter is pending).
So anyway, in that first 30 seconds, Adam introduced himself, then started walking us through his path to Clojure. Apparently he got started pretty early!

It turned out that this precocious hacker wasn't Adam, but his son. Shortly after this picture was taken, his son started tearing pages out of the book, which Adam noticed were from the section on Common Lisp's type system. "So he was way ahead of me," Adam noted, drawing a chuckle from the crowd and a few mock scowls from some of the hardcore Haskellites in the crowd. Ah, 'twas all in good fun, 'twasn't it? A friendly trolling amongst friends, nothing more. 😉

There were many parallels to my own introduction to programming, which started out early in life on a primitive but beloved machine on which we used primitive but beloved programming languages (assembly, C, and Pascal for Adam; Motorola 6502 assembly, C, and BASIC for me), then moving on to slightly less primitive but slightly less beloved languages like C++ and Java, before reading Paul Graham's "Beating the Averages" essay (which I won't link to here because Paul Graham is an odious person) and seeing the Light of LISP. This is where our paths diverged a little bit, as Adam went for Common Lisp, even writing a book entitled Lisp for the Web (which you can access on the web for any price between 0 - 20—your choice!), whereas I went for the elegant weapon for a more civilised age that is Scheme.
I loved me some Scheme, but knew that I wasn't likely to be able to use it at work (even though one of my colleagues was writing a Lisp implementation in C++ as a hobby project at the time), so Lisp remained merely a source of personal pleasure for me. Until 2009, that is, when I encountered Stuart Halloway's "Programming Clojure". Lisp on the JVM sounded like a thing I might be able to sneak in, especially given the fact that my company was in the midst of a massive migration from Perl (yay!) to Java (less yay, but still OK). But then I moved from Japan to Ireland, trading a Perl-but-soon-to-be-Java team for a team doing operations for a big C++ search engine with many lovely Bash and Perl scripts, so poor Clojure had to wait.
It was somewhere around 2012 that I first used Clojure at work, building an end-to-end testing system for our product. Judging from his timeline, it seems that Adam was getting into Erlang and C# around the same time. In late 2013, I took my first real Clojure job, building the backend of a rock'n'roll trivia game in Clojure on AWS. Sometime in the next year, I too picked up C#, writing the network stack for our game's new frontend, which was built on Unity. I actually used Clojure there too (unbeknownst to my colleagues), thanks to Tims Gardner and Ramsey Nasser's amazing Arcadia, which was a port of ClojureCLR to Unity. 🤯
Thanks to Acadia, I was able to sit in a REPL and use the powers of interactive programming to prototype a network stack, one evaluated S-expression at a time. Oh happy times! That is, until I had to take my pristine Clojure code and translate it into C#. 😢
Whilst I was messing around with Unity, Adam was writing a book: Your Code as a Crime Scene. As a brief aside (surprise surprise) I just love the description of the book:
Jack the Ripper and legacy codebases have more in common than you’d think. Inspired by forensic psychology methods, you can apply strategies to identify problems in your existing code, assess refactoring direction, and understand how your team influences the software architecture. With its unique blend of criminal psychology and code analysis, Your Code as a Crime Scene arms you with the techniques you need to take on any codebase, no matter what programming language you use.
I have a copy of the book, but have only read the introduction. Which is why I didn't realise that Adam was a Clojurist. 😬
But back in 2015, I didn't have a copy of the book, because I only heard about it somewhere in the Long 2020. What I did have a copy of in 2015 was Joe Armstrong's Programming Erlang. Why I had this is somewhat unclear to me. My friend Ryan had dabbled in Erlang back in the winter of 2000-2001, and gushed about lightweight processes and live reloading and supervisors and stuff, but when I looked at Erlang back then, I had never seen an immutable, functional language before, so it looked like complete gibberish to me (this was before the infamous Programming Languages course I took in university that made me a first-class Lisp Hater).
But taking a page out of Adam's book (in a metaphorical fashion, don't worry), I can examine the dusty crime scene that was 2015 and uncover some clues:
From this evidence, I can surmise that I bought the book because I wanted to learn Erlang. And I did get an offer from that fintech, but I decided instead to join a streaming TV company to do Scala webservices (I actually did know Scala, having worked with it professionally for about a year at the same company where I built that Clojure end-to-end testing thingy). Of course, when I started at that company, I was dropped into a team that did low-level video transcoding in C and Python. But then we got this potential deal with a big TV company to do video on demand, and to win the deal, we had to demonstrate that we could transcode and stream arbitrary titles from their catalogue, and we had a week to do it. So I reached for the most obvious tool: Pixie. 😅
You see, this was many years before Babashka would make it possible to write Clojure shell scripts that started up immediately, and many many years before babashka.ffi, and I needed to call into libffmpeg from my scripts (I actually ended up abandoning that and just shelling out to the ffmpeg command-line tool).
The team I was part of consisted of me, two hardcore Finnish consultants who worked remotely from Helsinki, and this Swedish guy who worked together with me in the Stockholm office. At that point, I had been working for the company for maybe three months, and I had learned a little bit about video streaming (I had worked with my Swedish colleague on a DASH server in Golang) and maybe a little bit more about video transcoding, but I wouldn't really say I knew what I was doing.
Somehow, we made enough progress that we were very close by Wednesday morning. But not quite there. And the company we were trying to win the deal with was based in Qatar. And the Qatari working week runs from Sunday to Thursday. Which meant that we had until Thursday morning to have a working demo of our system. 😱
So my colleague and I loaded up on coffee and sat down at my desk and started what would turn out to be a nearly 15 hour pairing session. I would hack some Pixie, then he would take the keyboard to tweak our DASH server, then we'd switch again for more Pixie, then we'd get stuck and ping the Finns on our IRC channel (I think it was still IRC back then, but maybe we had Slack?), and they'd give us some ffmpeg incantation that was waaaaay over my head and just grazing the top of my Swedish colleague's head, and then back to the DASH server, and so on and so forth.
Around midnight, we successfully ingested, transcoded, and served our first video asset (I seem to remember it being a Champions League match). By 1 AM, we were scratching our heads wondering why the second asset we tried didn't work. But by 2 AM, we had fixed that, and by 3 AM, we had ingested five assets in a row, chosen completely at random (by shuffle-ing a list of asset IDs, natch). By 4 AM, I was in bed, trying to convince my caffeine-addled brain to stop projecting ffmpeg commands on the inside of my eyelids. And by 10 AM, I was somehow back at my desk, giving our demo system one last poke before we sent it over to the client.
And it worked! And we won the deal, which wouldn't have been possible without Lisp. So naturally, when it was time to build the production version of the system, we chose to do it in... Elixir. 😂
It turns out that my Swedish colleague had an irrational fear of Lisp, and an undying love of Ruby, and the things I loved most about Clojure were functional programming, immutable data structures, and interactive development, so we compromised on a language that had all of those things for me and looked a lot like Ruby for him.
It was the best decision we ever made. Elixir was absolutely perfect for the task. We had a set of processes that scanned the catalogue for new assets, then fired events to another set of processes that downloaded the assets, then another set that transcoded downloaded assets, and of course a DASH server. We built the thing interactively in Emacs with the amazing Alchemist.el, and after the first week of development to get a skeletal cluster up, we didn't restart the system more than once or twice in the three months it took to finish the thing.
Try that with C++, I dare you. 😉
But anyway, back to 2026, and this functional programming conference that I'm allegedly writing about (though there is a reason I took the Elixir detour, I promise). 😅
Like I said, there are a lot of parallels in Adam's and my roads to Clojure, but there's a decisive split circa 2025, when Adam Go(es) agentic and Rusty, and I very much don't. As I mentioned, I had done some Go at the video streaming company, with very mixed results. On the one hand, writing a service that listened to UDP multicast from a satellite downlink station and served it up on a TCP socket was like 50 lines of code. 🏆 On the other hand, writing a DASH server that read some JSON and then generated an MPD manifest was like a hundred million lines of code. 😭
And Rust? Well, if I was still doing much systems programming, it seems like a reasonable choice, and there's some really interesting stuff in the language, but my few forays into the community surrounding the language have left me with an incredibly bad taste in my mouth.
And agents? Well, any Clojurians reading this probably know how I feel about LLMs (a scourge upon humanity, etc). But more on this anon.
What Adam is doing in this talk is explaining why Clojure was a great choice for building CodeScene, the system he built for analysing the health of big codebases. And one of the key characteristics of Clojure is that it's a functional programming language. And a functional programming language is...

Well, what exactly? As Fogus and chouser quip on this slide, it's not an easy question to answer:
"If you ask 100 programmers for their definition, you'll likely receive 100 different answers."
But Adam actually answers the question quite succinctly on his previous slide:

Less code, more features, more fun... in Erlang, that is. In Clojure, it's more fn, proving once again its superior expressiveness. 😉
Interestingly enough, Adam is trained as a psychologist. So of course he had to give us an IQ test, showing us a set of patterns with one missing, and asking the audience to shout out the number corresponding to the missing pattern.
It started out with a reasonably straightforward one:

Got it? Many people in the audience did too, yelling out "8" with gusto.
But then, the friendly IQ test turned into this:


If you struggled with this one, you are far from alone. Only one brave voice in the audience called out the answer (which is 5).
Having humbled us with the IQ test, Adam made a recommendation: "Do not turn your code into an IQ test." Sage advice indeed!
He then explained the goal of Code Health, the central concept behind the CodeScene platform he had developed in Clojure: to bring visibility to code and code quality:

At a high level, what CodeScene does is:
This allows CodeScene to build really cool visualisations to identify areas of the code where technical debt is lurking. Of course, when you have a massive codebase like the Linux kernel, with 21.4 million lines of code, contributed by over 15,000 authors...

...you can't exactly just
(map calculate-code-health-of files)
Of course, with Clojure, you can just add a character to turn the serial into the parallel:
(pmap calculate-code-health-of files)
According to Adam, "That single character gave me a 10x speedup. It was the most efficient character I've ever written!"
But other than the magic of the letter p, what does Adam look for in a programming language?

When he first chose Clojure for CodeScene back in 2015, it was:
And in 2026, with his agents:
Clojure still wins.
Anytime I write the word "AI", I of course need to give the 🤮 disclaimer.
Here's a fun code health problem for us:

Looks like Ryu needs to learn recursion. 😂
One way to think about programming is that you have two models:

The situation model is used to express knowledge, which is encoded in the system model, then data is input and the outcome is observed by the programmer, allowing them to update their situation model (this is where learning occurs). The bigger the gap between these two, the more cognitive load is placed on the programmer, thus increasing the chance of defects in the system.
The programmer's goal then becomes to close the gap between the two models, and one way Adam proposes to do this is to build domain-specific languages, such as:
(defn costs-by-file
"Given a sequence of task durations (e.g from Jira),
what are the development costs per file?
Development costs are expressed in work days, wd."
[duration-by-task]
(->> duration-by-task
(map (partial assign-to-files file-name->ticket))
(filter passes-minimum-thresholds?)
(reduce by-file-name)
And you can take this a step further with an embedded DSL, using the great and terrible power of macros!

You could define a macro along these lines:
(defmacro enable-code-health-suppressions-for
[& suppressions]
(->> suppressions
(map (fn [s]
`(defmethod apply-directive-to ~s [parsed-code#]
(apply-shared-logic parsed-code#)
(remove-warning ~s parsed-code#))))))
And then write code like this:
(enable-code-health-suppressions-for
:complex-method
:bumpy-road
:deep-nested-complexity)
which would expand at compile time to this:
(defmethod apply-directive-to :complex-method [parsed-code]
(apply-shared-logic parsed-code)
(remove-warning :complex-method parsed-code))
(defmethod apply-directive-to :bumpy-road [parsed-code]
(apply-shared-logic parsed-code)
(remove-warning :bumpy-road parsed-code))
(defmethod apply-directive-to :deep-nested-complexity [parsed-code]
(apply-shared-logic parsed-code)
(remove-warning :deep-nested-complexity parsed-code))
So it looks like macros can be used for good after all... for a time. But continuing down this road leads to Common Lisp, and Common Lisp leads to:

Adam's claim is that judicious use of macros can move the programmer from instructing a machine (which is slow) to expressing a domain (which is powerful).

And all jokes aside, Clojurists do use macros, but as a tool of last resort. The Clojure philosophy is something like this:
Whereas the Common Lisp philosophy is more like this:
Having made a series of compelling points about how to keep complexity under control with the power of functional programming in Clojure and enticed us to try out CodeScene (I'm starting the free trial as soon as I have a few free days in my calendar to point it at my company's fairly large Java, TypeScript / JavaScript + React, and .NET codebases to see where be the dragons), Adam summarises why we should all be using Clojure in 2026 (🤮 disclaimer on the first point):

This was a fantastic talk and a great way to kick off the conference! I would recommend that everyone watches the talk as soon as it goes up (and I'll try to remember to update this blog post with the link to the recording when that happens), but in the meantime, you can also check out Adam's slides.
After the thunderous applause died down and Adam answered a question from the audience (which I didn't note down, but I remember as being an actual question, not the infamous Tech Bro "Question") and folks started filing out, I took up my lurker's position near the stage, ready to intercept Adam before he could escape the auditorium.
I thanked him for his talk, admitted that I had only read the intro to his book, and invited him to come on defn and talk about all this awesome stuff. We're trying to find a date as I write this, so hopefully it works out and you'll have something to feast your ears on in a few weeks (and I'll try to remember to update this blog post with the link to the episode when that happens). 🎉
After a coffee break, the next talk began, and it was of a very different character to Adam's: Eric Torreborre's "Beyond the Expression Problem".
I have to admit that this one was tough going for me, so whilst the Haskellers and Scalars in the crowd nodded knowingly throughout, I took copious notes, which helps my brain decomplect.
Eric's talk was basically about the holy grail of compositional programming:

If you have no idea what that slide means, fear not! Eric explained it. And his explanation made sense to me at the time, but looking back at my notes as I write this, it seems that my absorption of the topic was limited to the absorption of ink into my notebook, and did not indeed penetrate my thick skull. Ah well, I can at least share a couple of slides that I enjoyed!

Legos! And the Millennium Falcon! And the Millennium Falcon made out of Legos! And yes, that does in fact cost basically 10,000 Swedish Crowns, which as of this writing is about 900 Euros or 1000 US Dollars. 🤯
So modularity is good, but

But not to worry

Because the type system can save us!

"That's a very simple lambda calculus in a way," quoth Eric, as my head exploded.
But then there's a twist: one has to actually implement this, which Eric illustrates as follows:

Eric's face falls. "It sounds like the whole type system is useless." 😭
But there was a happy ending, and Eric concluded that it may be possible to implement this in a new programming language that he is working on together with some researchers.
Despite the type magic going a bit over my head, the talk was extremely engaging, and Eric got a few good questions at the end.
Mulling it over afterwards, I thought back to his explanation of the expression problem, which is that it's difficult to extend types and functions at the same time. Unless I'm missing something, it feels like Clojure makes this pretty easy: we have multimethods, which cover the most common cases, and protocols, which "avoid the 'expression problem' by allowing independent extension of the set of types, protocols, and implementations of protocols on types".
I was reminded of a Haskell team that I managed a few years back. They had a huge amount of fun, constructing elegant cathedrals of types and typeclasses and higher-kinded types to solve problems that they had created for themselves. Meanwhile, our stakeholders were becoming increasingly desperate for us to like actually ship the software so we could actually like solve an important problem for our customers.
I don't claim this is representative of static type enthusiasts working in industry, only that a significant portion of my interaction with the statically typed programming language communities (both at work and conferences) tends toward the esoteric and academic. And no shade, that's very interesting. I often enjoy Haskell and Scala talks at conferences (even when my comprehension level is usually 25% at best), and definitely like the vibe of a group of people excitedly expanding their minds in communion with each other.
But here's my problem: I've ended up in one too many conversations over the years where I magnanimously offer that statically typed and dynamically typed languages are equally valid, offer different tradeoffs, and ultimately tend toward the shape of the programmer's mind, rather than an inherent superiority of one over the other, only to be told confidently "you don't get it" (almost certainly true, but I feel that validates rather than invalidates my assertion), or "you probably just haven't worked on large enough systems to have seen the limitations of dynamic typing" (I always have to bite my tongue not to be That Guy™ and mention a certain e-commerce website that I worked on for a few years, in a dynamically typed language). I don't go around trying to convince people that Clojure is better than their favourite statically typed programming language, I just make the statement that Clojure is better for me than their favourite statically typed programming language.
Anyway, after this feast for the mind came a feast for the body, in the form of lunch. I had some kind of vegan salad thingy with falafel and other stuff, which was as delicious as a deftly placed defmethod, especially with a side of conversation with Arne and Christoph, which continued whilst relaxing on some comfortable sofas on a mezzanine overlooking the stage, which I must shamefully admit caused us to pay only cursory attention to Adam Warski's "The Scala 3 Features for Agentic Development, Today and Tomorrow" (🤮 disclaimer on the middle part of the title). But such is the cost of fellowship, and it's one I'll gladly pay!
The next talk, however, drew me downstairs and into the auditorium like a moth to a flame: Giuseppe Castagna and Guillaume Duboc presenting "All Set: Bringing Static Types to Elixir". See, I told you there was a point to my previous rhapsody on Elixir!

I absolutely loved this talk! Of course, I am extremely partial to Elixir. In fact, if every Lisp disappeared from the world tomorrow, I would shed a single tear, and then, like a Phoenix rising from the ashes, be reborn as an Elixir programmer.
The talk started with Giuseppe showing some code with type annotations in a comment above each function, but they were different from the type of type annotations (sorry, I promise that wasn't intentional) I'm used to seeing. In fact, these type annotations read more like pseudocode, which, like, I can get behind.
Giuseppe admitted that all wasn't rainbows and unicorns, however:

I mean... I can't help but deploy this meme:

But wait, Giuseppe is about to turn my frown upside down!
"The hard part is to derive algorithms to decide if a type s is a subtype of a type t. But don't worry, we know how to do that, so you don't have to do it yourself."
Hurrah!
I don't remember the exact context for this quote, but it amused me enough for me to jot it down in my notebook:
"That seems silly, but is actually very smart."
The coolest part of the talk was when Giuseppe revealed Elixir creator José Valim's reaction to their work. "Very cool," quoth he, "but here are my requirements for adding it to the compiler":
The rest of the talk was about how they implemented their type system within José's constraints, and it was a very clever solution indeed, as it ended up not requiring any annotations (I think), but rather using type inference to make the type checking as strict as it could be whilst not pushing the programmer into that annoying situation where you know your code is correct, but end up arguing with the compiler for hours before giving up and just throwing an Any or void * or whatever at it just to shut it up.
They also measured the type checking time as a proportion of the overall compilation time, with the goal of keeping it under 10% (I think—I didn't write down the actual target, for some reason).
So yeah, great talk!
And finally, it was time for Christoph Neumann to deploy some game theory to help us navigate the challenges of industrial programming.

I absolutely loved this talk! Of course, I am extremely partial to Elixir. In fact, if every Lisp disappeared from the world tomorrow, I would shed a single tear, and then, like a Phoenix rising from the ashes, be reborn as an Elixir programmer.
The talk started with Giuseppe showing some code with type annotations in a comment above each function, but they were different from the type of type annotations (sorry, I promise that wasn't intentional) I'm used to seeing. In fact, these type annotations read more like pseudocode, which, like, I can get behind.
Giuseppe admitted that all wasn't rainbows and unicorns, however:

I mean... I can't help but deploy this meme:

But wait, Giuseppe is about to turn my frown upside down!
"The hard part is to derive algorithms to decide if a type s is a subtype of a type t. But don't worry, we know how to do that, so you don't have to do it yourself."
Hurrah!
I don't remember the exact context for this quote, but it amused me enough for me to jot it down in my notebook:
"That seems silly, but is actually very smart."
The coolest part of the talk was when Giuseppe revealed Elixir creator José Valim's reaction to their work. "Very cool," quoth he, "but here are my requirements for adding it to the compiler":
The rest of the talk was about how they implemented their type system within José's constraints, and it was a very clever solution indeed, as it ended up not requiring any annotations (I think), but rather using type inference to make the type checking as strict as it could be whilst not pushing the programmer into that annoying situation where you know your code is correct, but end up arguing with the compiler for hours before giving up and just throwing an Any or void * or whatever at it just to shut it up.
They also measured the type checking time as a proportion of the overall compilation time, with the goal of keeping it under 10% (I think—I didn't write down the actual target, for some reason).
So yeah, great talk!
And finally, it was time for Christoph Neumann to deploy some game theory to help us navigate the challenges of industrial programming.

Before revealing the meaning behind the titillating title of his talk, however, Christoph poses what I can only assume is a rhetorical question:

But then, he posed a tougher question: if you could not use your favourite programming language, what language would you use instead?
This question was actually posed in the 2025 State of Clojure survey, and the top five answers were mostly unsurprising:
But wait, what's going on at #3 there?
Apparently, nearly 1 in 10 Clojure programmers would actually quit programming altogether if they could not use Clojure! 🤯
I'm one of the 9 out of 100 that would use Elixir (unless I could sneak Scittle or Babashka or Jank or Clojerl or Cherry or Squint past the Ministry of Love), as noted above, but I totally get the sentiment.
Most of the Clojurists in the audience had already seen this, either when the State of Clojure results were published back in February, or the day before during Christoph's dry run at the sthlm.clj meetup, but minds were clearly blown in the audience, and many a furrowed brow was spotted as Haskellers and Scalars contemplated life without their powerful types and slow compilers (sorry! I jest out of love for my fellow Functionalistas) and Elixirians despaired at the thought of letting it crash only to find it's unsupervised.
Having gotten the audience's attention, Christoph made a statement that I think everyone in the room would have agreed with two years ago: making software is hard. He then issued an update which I hope is uncontroversial amongst the people in the audience and readership of this blog, even in 2026: making excellent software is hard. If this is the case, a deeper question emerges:

Christoph ended his talk early by just giving us the answer: functional programming! (and monads), drawing a hearty laugh from the crowd. But of course he wasn't actually done, and like the good Clojurian he is, he pointed out that there's no way to answer a question such as "what makes software excellent?" without defining the word "excellent":

And now it's officially a Clojure talk!
OK, but now that we know what "excellent" means, we must ask ourselves what our software should be excellent at. Christoph offers some common options:
But then there's a twist! Excellence at any of these matters not a whit if you're not solving real-world problems!

And to solve problems, we must make choices. But there's a paradox here: choosing an option reduces our options! 😱
There is a way to manage this paradox, though. If we separate our choices, make them truly independent of each other, a choice in one space only affects that part of the solution, leaving options open elsewhere. And this, my dear friends, is why we decomplect. If you're not a Clojurist, "decomplect" means something like "to take apart a complex thing by unbraiding it into its constituent pieces, each of which can now be used independently". I actually couldn't find a good reference for the definition, so if anyone reading this can point me at one, that would be wonderful. Rich Hickey does however clearly define the word "complect" in his epic Simple Made Easy talk: "to interleave, entwine, or braid". (By the way, there's an amazing repo on GitHub called talk-transcripts, which has full transcripts for loads of talks by such luminaries as Rich Hickey, Alan Kay, David Nolan, Guy Steele, Evan Czaplicki, etc.)
OK, back to Christoph. Having elucidated the paradox of choice and offering us a way out, Christoph asserts that changeability (and of course he includes a definition slide: the ability to make the changes that we want or need) is the central factor of longevity of an excellent codebase. Remembering the paradox of choice, one of the best design questions we can ask ourselves is "what choices can we defer?" I can't remember where I read this, but someone said that what we should be doing is not making choices at the last possible moment, but rather at the last responsible moment, and I think that's what Christoph is getting at here.
And now another definition. Optionality: preservation of choices. We want to preserve optionality for as long as possible responsible, allowing us to decide later when we have more information. Having now presented the concepts of changeability and optionality, Christoph tells us what they have to offer us:
And optionality is more important to us as programmers than changeability.
Finally, Christoph reveals why his talk is titled "Infinite Coding". In a book from 1986 called Finite and Infinite Games, the philosopher James P. Carse proposed that
all human behavior can be categorized into two types of play. Finite games, such as chess or politics, have defined boundaries, fixed rules, and clear winners, played with the sole purpose of bringing the game to an end. In contrast, infinite games, such as culture, love, and life itself, have evolving rules and open boundaries; they are played for the purpose of continuing play and welcoming new participants.
Christoph thinks this is a very useful theoretical construct for talking about programming, and he simplifies the concept for us:
Finite games have boundaries, such as time, rules, and players, whereas in order to keep playing an infinite game (which is the goal), the rules must change, and play is in fact threatened by boundaries of the sort imposed by finite games.
So what does this have to do with software?

For systems that solve real-world problems, which tend to evolve over time, we want to be able to evolve our software for as long as the problem exists; in other words, we want to keep playing indefinitely. When our software becomes so rigid that we cannot change it anymore, the game ends, and we lose.
So what kinds of things end the infinite game of software?
Like all infinite games, as we play the game of software, we encounter many finite games along the way, such as:
As any programmer will attest, playing finite games is necessary. However, finite games may harm the infinite game, and it's possible to win our way into failure. What we want to do is to win the finite games we play, but choose to play only the ones that don't threaten the infinite game.
To keep the infinite game going, we want to:
To avoid the need to change, we need to identify the things that don't change, which is of course easier said than done. There is hope, however, as Christoph points us at a book by Morgan Housel called Same As Ever, and then offers some examples from the programming game:
map, filter, and reduce are eternal, regardless of which functions we're mapping over, filtering by, and reducing with.
Christoph now poses the question of what keeps the infinite game going? And then of course offers some answers:
But here comes the real world again:

To play any game, we need a good strategy, and Christoph proposes one here:
By applying this strategy skilfully, we can maximise both changeability and optionality.

And now for Christoph's mic drop moment:
We are human. Playing must be fun and worthwhile!
🫳🏻🎤💥
As you may have gathered from the number of words I expended writing about this talk, I thoroughly enjoyed it. My notes runneth to five pages, and verily did I consult Christoph's slides to make sense of them.
With that, the conference was over... except it wasn't!

The organisers gathered most of the speakers onstage for a panel discussion, which was lively and fun, with some disagreement expressed cordially. I wish I had taken notes, because I don't remember many of the questions (one was something like "what is the future of functional programming?" and I'm sure there was an "AI all the things?" 🤮 one in there somewhere) or answers, but I do remember one of the panellists saying something about mutability and me getting so excited that I literally pointed at Christoph, willing him to mention transients in transducers, and getting even more excited when he asked for the mic, then less excited when he said something awesome but not as awesome as transients in transducers. 😂
Finally, Christoffer thanked the speakers, shooed them off the stage, and gave some closing words. Amongst those closing words were words hyping up some upcoming conferences, and amongst those were the EuroClojure 2027 reboot (May 19-21 in Prague), and Arne's own HeartConf, the expansion of the legendary Heart of Clojure conference (August 18-19 in Mechelen, Belgium).

I'll definitely be at HeartConf (I've already got several ideas brewing, just waiting for that sweet sweet CFP to open)—whether that's an enticement or deterrent I can't say—so hope to see you there!
With that, Christoffer thanked the sponsors for making the conference possible, the speakers for making the conference contentful, the organisers for making the conference, and the audience for making the conference awesome. He all but promised that there will be a Func Prog Conf 2027, and after basking in a huge round of applause, released us to go mingle. Which we did. My social batteries were running low, but I did have just enough juice to grab some juice of the barley (big thanks to the organisers for making sure non-alcoholic beer was available!) and making one last circumnavigation of the mingle zone, saying my thank yous and goodbyes and exchanging LinkedIn profiles and email addresses and Clojurians Slack usernames.
Arne was also feeling the need to recharge, so the two of us shook hands with Christoph and Magnus on our way out the door, then strolled through Södermalm to the place where Arne was staying, taking in the scenery (the clouds had parted and the sky was blue, though rapidly turning into dusk because it was after 17:00 in Stockholm in October) and enjoying some relaxed conversation which was not about Trump or AI (the rules of engagement at the Belgian Clojure meetup in Leuven, if you ever happen to be in the neighbourhood), then finally parted ways, he turning rightwards and I trying to orient myself in order to locate the nearest tunnelbana station.
I had done a pretty good job of keeping my phone in my pocket (except for photos and networking) and my attention in the moment all day, so I resisted the urge to whip out Organic Maps, and instead ducked into a restaurant to actually ask a human being for directions. The human was most obliging, and pointed me "rakt fram" down Folkungagatan, and I enjoyed a leisurely 20 minute stroll through the heart of Söder, the lights coming on as the sky darkened, people going about their business, bicycles cruising past; in short, the city breathing.
What a lovely end to a lovely day.