Workflow Ergonomics with AI

How AI streamlined my digital life

Build your own web server

My road to a more productive digital life started when I asked Claude Code (CC) to build a web server for me. It's called mdserve (short for markdown server), a full-stack web application in a single file, that has grown into the control panel for my whole desktop. Its generic core is on GitHub, for you to build your own on.

It started with wanting a more pleasant reading experience than what CC produces. Instead of reading a long answer inside CC itself, I tell it to write the answer to a markdown file and open it in the browser for me. CC will do that without any help. The problem mdserve solved was where the page turns up: I wanted it to always be in a known browser's window, on the workspace I'm working in.

It has grown since then. A "personal agent", one that goes off and does things for you while you're not there, isn't something I want. But I do like the convenience such agents promise, and this is how I get it. Anything I find myself doing a lot gets streamlined. Sometimes that's a small ~/bin command, sometimes a button or page in mdserve, and sometimes just a rule CC follows for me, such as always backing up a file before changing it.

When I start mdserve from the command line, it opens several browser windows, each with many tabs. I have three areas of focus at the moment, so I work across three workspaces. The one step I haven't been able to automate is moving each window to its workspace, but that only takes a few seconds.

About the videos: they're screen recordings, and the text in them is small. Open a panel, then go full screen before you press play. YouTube picks the picture quality from the size of the player, so a small player gets a blurry picture.

Watch: mdserve starting up

Why a browser and localhost

Why a web server and not a desktop application? Because everything talks HTTP. CC shows me a page with a single curl to mdserve, with no plugins or plumbing needed. mdserve in turn drives the browser with web requests too, through the Chrome DevTools Protocol. The server holds the state, so closing a tab loses nothing. And it isn't tied to one machine: CC sends documents to my tablet over the LAN with the same kind of request.

Everything lives on a tab

As you saw in the video, I start mdserve.sh from workspace 2. That's the workspace I use for control, and for anything that isn't a programming project. The idea is that mdserve bootstraps the machine. After that, all the work I need to do is on an mdserve browser tab somewhere.

When I want to work on mdserve itself, I start a new CC session and ask it to read MDSERVE.md, where its documentation lives. If you haven't worked with CC, or a similar coding agent such as Codex CLI or Gemini CLI: maintenance documents like this are written by the AI itself, from the conversations you have with it at its prompt.

Watch: Improving mdserve with Claude Code

Browser tabs are just URLs. On startup mdserve reads them from an EDN file, and that file gets changed during my conversations with CC.

It's interesting to see how tabs come to be there. There are three ways:

  1. Always wanted. These are often complete programs, such as the dashboards for my projects, or ordinary sites such as Gmail and Slack, and they come back every time mdserve starts.
  2. Asked for. I've asked CC to "put it up". This is usually markdown documentation.
  3. Opened by another tab. A button on another tab was pressed, usually to take a closer look at something, such as a log.

Each tab also gets a coloured dot as its icon, so I can tell at a glance what kind of tab it is. Ordinary websites keep their own icons, but they're just as much part of mdserve: remembered and reopened with the rest, and CC can put one up in the correct window as easily as a document. The idea is to keep it down to one browser window per workspace.

More features, and your own Windows Explorer

The last video showed "Refresh" for how much memory is in use, and "Remember Tabs", which saves the open tabs.

One rule Claude follows for me is to always make a backup before changing a markdown file. That makes a mess of backup files, which of course can be tidied up.

The next video shows the tidying up.

Watch: mdserve's Explorer tidying up backups

Imagine having your own programmable version of Windows Explorer per workspace. You just saw that in the last video! There's always at least one of these tabs in each workspace. The video doesn't show it, but clicking a file opens a new tab that displays that type of file. Markdown files matter most, so they're listed with the most recently changed first.

Many of mdserve's buttons don't do the work themselves. They run a small command from my ~/bin directory, the same command I could type at a shell prompt, or that CC can run for me. The "Flotsam" button in the video runs archive-flotsam, a script that moves backup files and screenshots into a timestamped archive folder. Most of the commands behind buttons are just a few lines of bash. A few buttons, such as the memory "Refresh" and "Remember Tabs", are built into mdserve itself.

Fewer terminals

Working as a developer or a support person, you end up with terminals everywhere, some temporary and some running permanently, and they clutter your desktop. There are plenty of ways to tame them: tmux, tiling window managers such as i3, and on macOS, iTerm2.

The terminals that cluttered my desktop were mostly long-running processes: servers, build watchers and the like. Those now run in the background, each terminal having been replaced by a row of buttons ("Launch", "Stop" and "Log") on an mdserve dashboard, one at a time or a whole group at once. "Log" opens the output in a tab, which I close when I'm done. All I keep is one tabbed terminal window per workspace, for my CC sessions and the occasional one-off terminal session.

Use runbooks

That occasional one-off terminal session I mentioned is often a runbook, often on another machine.

Some jobs need me, rather than CC, to type the commands, because they need my password or a console. For those, CC writes a runbook: a plain file of shell commands, with comments saying why each step is there. I press Alt-n and the next step appears at my prompt, already typed, and nothing runs until I press Enter. No more copying commands out of the chat. The tool is called cmdq, and it's a gist you can install in a minute.

Each step's output is saved for CC to read back, and my place is saved too, so a job can span reboots. When the job is on another machine, CC copies the runbook there, and I log in and keep pressing Alt-n. Each runbook is a dated file kept with its output, so there's a history of what ran on each machine.

At this stage I've written the main points I wanted to make. Please read on if you are still curious...

Complex deployment

Buttons aren't only for small jobs. Our point-of-sale system (POSES) is four webapps (chat, till, prods and pos) that run on one production server, and deploying them is a job with several steps. It now lives in mdserve in a Deploy panel on the POSES dashboard tab, as five buttons in the order they're used:

  1. Plan compares what's live with the latest code (whether committed or not), app by app, and shows what a deploy would ship.
  2. Ship builds and uploads an uberjar for each changed app, stopping at the first failure.
  3. Release doesn't actually do anything! Instead, it prints the step for the human deployer to run on the server.
  4. Record checks that every app that was shipped is really live, and fails loudly if one isn't, which catches a step missed on the server. It then notes what went live, so the next Plan can work from that note instead of having to query the live server. It's also the only way Plan knows which version of the shared library is in production, because the server doesn't say.
  5. Confirm runs the plan again, read-only, and should find nothing left to deploy. Its log ends: "Confirm done. If every app above shows 'up-to-date', the deploy is complete." It then restarts the development build watchers that Ship had to stop.

Each step has a coloured dot showing whether it has run and how it went, and a "Log" button for its output.

Watch: A complex deployment from mdserve

Life hacks

The same habit works away from the desk. Four examples, and how each one works.

Reading on the couch. When I say "put it up on the tab", CC adds a document to a reading list, which a second, deliberately tiny web server shows on my tablet. That server can only show the list and the documents on it. mdserve itself only listens on the machine's own internal address, so nothing on the LAN can reach it. The terminal, editor and money pages simply don't exist on the tablet's server, so nothing I add to mdserve later can leak onto the network by accident. A web page can go on the list too; it's just a link to the real site.

The big screen. "Put it on the big screen" opens a page on the TV. Behind that phrase is bigscreen, a small command on my desktop: give it a web address and the page appears on the TV. CC runs it when I ask, and I can type it myself too. The TV is really just an HDMI screen for a Linux laptop. That beats a smart TV: the laptop is a normal computer, so it can run a web server and a fully controllable Chrome. A page on the TV is no use if you can't scroll it from the couch, so the TV runs its own little web server, tvremote, and my phone is the remote control. In reading mode it scrolls the page up and down, makes the text bigger or smaller, reloads it, and plays the page's YouTube videos; the same remote also does Netflix. tvremote shows pages in a separate Chrome with its own profile, because Chrome won't let a program take control of your everyday one. It's on GitHub if you have a spare laptop and a TV.

What makes tvremote work is the trick mdserve uses, stretched across two devices. In mdserve, the pages I click on and the browser being driven are one and the same: a button on a page asks mdserve, and mdserve drives that same browser through the Chrome DevTools Protocol. With tvremote there are two browsers. My phone's browser shows the remote, but it has no power over the TV at all, because Chrome only accepts that kind of remote control from programs on its own machine. So each press goes to the web server on the laptop, which sits right next to the TV's Chrome and drives it. A web page can't control another browser, but it can ask a web server that can. Any device on the LAN can be the remote, and bigscreen on my desktop is just one more of them.

Email and messages. When I need an email written, CC drafts it straight into Gmail and leaves it open on my screen: a new message, or a reply at the end of the correct thread. Slack messages work the same way. There's no email integration behind this. Gmail and Slack are just tabs in mdserve's browser, already logged in, and CC works inside them through the Chrome DevTools Protocol, the same remote control mdserve uses to open its windows.

Errands. My second favourite (behind the TV remote): I needed an official photo from one of several local photo shops. CC made me a one-page guide with the questions to ask and the spec, and the shops in order, each with a button that opened Google Maps to it. As I rode from shop to shop, the next one was always a tap away. This one couldn't be served from home: my web servers there reach only my own machine and my LAN, and out on the bike my phone was on mobile data. So CC published it on the web server I rent on the internet, the one that hosts this blog, as a temporary page under my own domain. That's a single command for CC, and the page needs no login to read.

Financial flows

Money gets the same treatment. My mdserve pages only ever talk to the mdserve backend, which in turn makes the calls to Binance, holds the secret API keys, and signs each request, so a key never appears in a web page, and the browser never talks to the money.

The main flow:

  1. Selling crypto for local currency. Binance's peer-to-peer (P2P) market shows who is offering what for my USDT, paid into my GCash account. My page picks the best offer I can actually fill, not just the cheapest one listed, skips people I've blocked, and keeps track of GCash's monthly limit. The counterparty in a P2P trade often asks for a payment QR code, so mine is on the page, ready to send.
  2. Knowing where the money is. One page shows every wallet, with a button to move funds between my Binance wallets.
  3. Withdrawals. Funds go out to Kinesis, an asset-backed digital currency platform. mdserve guards the send: the destination address is fixed on the server, so the page can only choose an amount; an address is checksum-verified before it's ever used; the button needs two clicks; mdserve never retries; and after a send there's a 30-minute cooling-off period, so nothing can go out twice by accident.

Every trade is saved locally, as an EDN file, so the history is mine and doesn't depend on Binance.

Try this tomorrow

You don't need mdserve to start. Each of these is something to say to your own coding agent, and each one stands alone.

  • "Don't give me long answers here. Write them to a markdown file and open it in my browser." This is where mdserve started, and it needs no server at all.
  • "Always back up a file before you change it." It's one line in your agent's rules file, and you never lose a version again.
  • "Before you build this, run falsifier on it." falsifier is a skill (a file of instructions your agent loads) that checks a spec, a request or code for contradictions, hidden rules and wrong assumptions, and reports a short ranked list of what to fix.
  • "When I have to run commands myself, write them as a runbook file, with the reasons in comments, not as a list in the chat." To step through it, cmdq is the Alt-n tool from the runbooks section: one script and three key bindings.
  • Want more than markdown in the browser? mdserve-starter is the generic core of mdserve, ready to build your own on.
  • Got a spare laptop and a TV? tvremote is the phone remote from The big screen, above. Install it on the laptop, open its page on your phone, and you have Netflix, YouTube and reading on the TV. Reading is for pages your agent sends: its bigscreen command puts a page on the TV, and you scroll it from the couch with your phone.

Conclusion

AI, or rather the software it can build quickly, has finally let me get rid of most of the friction involved in controlling computers. I've never looked at mdserve's code, nor at the EDN file its tabs come from. I know what it does from using it every day.

Now imagine arriving for work at a new job, as a developer or a production support person, and being told to run one thing from the command line. Up comes everything that role needs: the dashboards, the logs, the runbooks, the documents, each in its place. Each role could have its own mdserve. A turnkey introduction to a job, long desired, is finally practical.

The next stage is, of course, doing some of these things in a loop, without a person driving each step. That's already happening in software development: agents make several attempts, and the best are kept. Two products now arriving work like this. Both take an evolutionary approach.

samizdat is an agentic harness for Clojure that splits larger tasks among several agents, and each sub-agent gets a contract plus tests as its spec. Several attempts can run side by side, and the winner is chosen by ranking each attempt on checked evidence. The loop itself is a state machine written as data, and the agent can rewrite it while it runs, behind a validate-and-rollback gate.

simmis is a self-hosted app where people and agents share one memory, including the code repository, and its central idea is cheap forking. Every change an agent makes to shared knowledge, documents or the code lands on its own copy-on-write branch, so agents can make several attempts at the same thing side by side. Whoever holds merge authority, a person or another agent, reviews the differences and merges the ones worth keeping. That's what makes it safer to let agents work on software in a loop. Two things stay off the branches: chat, where people and agents talk and the work gets asked for, and actions with outside effects, such as sending an email, which a branch can't undo. simmis is an early public release; the overview and roadmap are at simm.is.

Waterfall plans everything up front, then builds; agile builds in small steps and changes course as it learns. Waterfall's weakness is going back up: once building starts, changing the spec is expensive. The evolutionary approach allows both, and removes that weakness. Each attempt can start from a spec, as in a waterfall, and what the attempts reveal goes back into the spec before the next round, as in agile. samizdat gives each sub-agent a contract and tests as its spec and ranks the attempts on evidence; in simmis, attempts sit on forks until a person or agent with merge authority keeps the good ones. And when the code takes hours rather than months, going back up is cheap.

Glossary

  • Agentic harness: software that runs AI agents in a loop, handing them tasks, checking their work and feeding back results, so they can keep working without a person driving each step.
  • Agile: a way of running software projects in short steps, each one delivering something that works, so the plan can change as you learn. It largely replaced the waterfall.
  • API key: a long secret string that lets a program log in to a service on your behalf, like a password for software rather than people.
  • Babashka: a fast-starting way to run Clojure as everyday scripts, the kind of small programs you'd otherwise write in a shell language. babashka.org
  • Checksum: a few extra characters built into a code or address and calculated from the rest, so a single typo makes it fail to check out.
  • Chrome DevTools Protocol: the remote control built into Chrome. Developer tools, and programs like mdserve, use it to open tabs, read pages and click on them.
  • Clojure / ClojureScript: a programming language from the Lisp family, popular for working with data. ClojureScript is the same language compiled to run in the browser.
  • Copy-on-write branch: a cheap copy of some data that only stores what changes. It can be kept, merged back or thrown away without touching the original.
  • Counterparty: the person on the other side of a trade.
  • curl: a small command-line program that sends a single web request, the way a browser would but without the window.
  • E-wallet (GCash): money kept on a phone app rather than in a bank account, spent and transferred by phone. GCash is the big one in the Philippines.
  • EDN: Clojure's format for data files, a cousin of JSON. edn-format
  • HTTP: the language browsers and web servers use to talk to each other.
  • LAN: the network inside your home or office, usually just your Wi-Fi, which links your own devices to each other. Devices out on the internet can't reach in.
  • Localhost: a machine talking to itself. A web server listening only on localhost can't be reached by anything else, even on the same home network.
  • Markdown: plain text with a few simple marks for formatting, like # for a heading and * for emphasis. Easy to write, and easy to turn into a web page.
  • P2P (peer-to-peer): buying and selling directly between people rather than through a shop or a bank, with the exchange only holding the money in escrow until both sides are done.
  • REPL: a live session inside a running program, where you can type code and see the result at once, reading and changing the program while it runs. Epupp puts a Clojure REPL into a live browser tab, so an agent can see what the page actually drew, not just the HTML it was sent, and even change the page until a reload puts it back. clojure.org
  • Spec: the written specification, a precise description of what software must do, written before the code.
  • Stablecoin (USDT): a cryptocurrency designed to stay worth one US dollar. USDT (Tether) is the most widely used.
  • Terminal: the text window where you type commands to the computer, instead of clicking.
  • tmux: a program that keeps terminal sessions running in the background and lets you split one terminal window into several.
  • Uberjar: a single file that packages a Java or Clojure program with everything it needs, ready to run on a server.
  • Waterfall: the old way of running software projects, in strict stages: write the whole specification first, then design, then build, then test, with little going back. It fell out of favour because mistakes in the spec were found late and were expensive to fix.
  • Webapp: an application you use in a web browser. The part you see and click runs in the browser itself, and it talks to a server that stores the data and does the heavy work.
  • Workspace: a whole separate desktop with its own windows, switched with a keystroke. Windows calls them virtual desktops, and macOS calls them Spaces.

Comments or questions? Email me at (turn on JavaScript to see my address).

Published: 2026-10-08

Tagged: Clojure AI

Some Polylith tricks, without Polylith

Polylith is no doubt an attractive framework with huge benefits. However their documentation assumes you want to go all in. This article serves to guide the setup of your code so as to use two of Polylith's 'tricks' to achieve multiple implementations of an interface without runtime dispatch. Best used for an architectural level 'interface API' where choosing the implementation at build or REPL-start time is more benefit than hassle.

We will use the example of having two different database backends, PostgreSQL and Datomic, and one simple function q-by-ident. We will start with the directory structure then look at that from the point of view of deps.edn, and lastly look at the code.

Directory Structure

project/
├── interfaces/                            # Switchable facade layer
│   ├── storage/
│   │   └── src/your_domain/storage.clj       # Generic storage API
│   └── users-storage/
│       └── src/your_domain/users_storage.clj # Users API
│
├── packages/                              # Implementation layer
│   ├── storage-datomic/
│   │   └── src/your_domain/storage_datomic.clj
│   ├── storage-postgresql/
│   │   └── src/your_domain/storage_postgresql.clj
│   │
│   ├── users-storage-datomic/
│   │   └── src/your_domain/users_storage_datomic.clj
│   └── users-storage-postgresql/
│       ├── src/your_domain/users_storage_postgresql.clj
│       ├── src/your_domain/users_query.clj
│       └── src/your_domain/users_txn.clj
│
└── src/your-domain/                     # Consumer code
    ├── starter_app/
    │   └── auth.clj                     # Uses your-domain.users-storage
    └── restaurant/
        └── main.clj                     # Uses your-domain.storage

Here we are showing two APIs: storage and users-storage, implemented in PostgreSQL and Datomic. I'm using the term package, that in Polylith would be the implementation of a component. An equivalent Polylith directory structure would be more involved and require multiple deps.edn files.

deps.edn

This is where your project's classpath is setup. If I wanted to use the PostgreSQL API then I might have this as the :paths map-entry:

:paths 
["src" "src-contrib" "resources"
 "interfaces/storage/src" "packages/storage-postgresql/src"
 "interfaces/users-storage/src" "packages/users-storage-postgresql/src"
]

For Datomic it would be this:

:paths 
["src" "src-contrib" "resources"
 "interfaces/storage/src" "packages/storage-datomic/src"
 "interfaces/users-storage/src" "packages/users-storage-datomic/src"
]

I would then comment out the Datomic artifact coordinates from :deps and make sure the PostgreSQL ones are not commented out. As a more sophisticated deps.edn user you might employ separate aliases for datomic and postgresql each having :extra-paths and :extra-deps, and selectively use them for development and build.

code

The interface namespace acts as a switchboard, requiring and exposing functions from the chosen implementation:

File: interfaces/storage/src/your_domain/storage.clj

(ns your-domain.storage
  (:require [your-domain.storage-postgresql :as impl]))

(def q-by-ident impl/q-by-ident)

Or for Datomic:

(ns your-domain.storage
  (:require [your-domain.storage-datomic :as impl]))

(def q-by-ident impl/q-by-ident)

The implementations are of course completely different:

File: packages/storage-postgresql/src/your_domain/storage_postgresql.clj

(ns your-domain.storage-postgresql
  (:require [next.jdbc :as jdbc]))

(defn q-by-ident [db-conn ident]
  (let [[attr val] ident]
    (jdbc/execute-one! db-conn
      [(str "SELECT * FROM entities WHERE " (name attr) " = ?") val])))

File: packages/storage-datomic/src/your_domain/storage_datomic.clj

(ns your-domain.storage-datomic
  (:require [datomic.api :as d]))

(defn q-by-ident [db ident]
  (d/pull db '[*] ident))

Consumer code requires the interface, never the implementation:

File: src/starter_app/auth.clj

(ns starter-app.auth
  (:require [your-domain.storage :as storage]))

(defn lookup-user [sys username]
  (storage/q-by-ident sys [:user/username username]))

To switch backends: change the require in the interface namespace, update your deps.edn paths, and restart your REPL. Consumer code remains unchanged.

Conclusion

This approach gives you Polylith's core architectural benefits - complete backend isolation and zero runtime overhead - without the need to put your entire codebase into Polylith's structure and without the complexity of the Polylith CLI toolchain or multiple deps.edn files. The two key tricks are:

  1. Namespace indirection: Interface namespaces act as load-time switchboards that require and expose implementation functions
  2. Build-time path selection: Only the implementation you're using appears on the classpath, keeping deployable artifacts clean

Polylith offers sophisticated change detection, incremental testing and multiple deployable artifacts from the same codebase. When you need those features, you're already most of the way there - the working code exists, you just need to reorganize into Polylith's structure.

Published: 2025-12-09

Tagged: Polylith Clojure

Simple Reactive Client Router for Re-frame

With Fulcro the props always flow down from the base of your UI's tree structure. Thus all components/views will be refreshed, even those that are not currently visible. Introducing Fulcro's own router into your code-base is how you stop non-visible components being unnecessarily refreshed.

Re-frame doesn't have this story, so my thought was to not use any router until I hit a problem, despite the examples usually using a router. Initially I had 'navigation' app-state kept in many top level keys. Then I put them all under a key. Not much better! The third iteration, and the point of this post, has one key that describes the current path through the tree as a series of steps, from base to leaf. I used the key :at . Example two step app-state map-entry:

:at [[:tab :hamburger] [:hamburger :status]]

With this in place there's one reactive subscription for your app and one data-structure for user navigation events to alter.

You can have events that go to the base, across, or down to a new deeper level. Your views can pick apart the data-structure returned from the one subscription. Both the events and the queries upon this data-structure can be done with generic names, making the navigation flow of your app easy to read and easy to navigate from your IDE. The query functions I've needed so far are leaf-keyword, base-step, leaf-step, equals and predicate.

I'm proposing making a small Re-frame only client router library out of the code I'm using now. I say Re-frame only but this router will be easily adaptable to Reagent as well, directly using a ratom rather than a subscription. The rest of this article will be showing the code in use. It needs a name so Atrun, short for 'All the router U need'. Just a prospective name, I'll change it as soon as I find out its a lie!

Refactoring from 'something else' to this scheme can at first be done additively. The dispatch-able event in some view before:

[::r-events/inject-true [:nav :my-shop?]]

After:

[::at/down 
 [:hamburger :my-shop] 
 [[r-events/inject* [[:nav :my-shop?] true] true]]]

Explanation time. Fulcro has a convention that a function that takes and returns app-state end with a *. All reg-event-db events can call such functions and be done:

(defn inject* [st path value]
  (s/setval path value st)

(reg-event-db
  ::inject-true
  (fn [st [_ path]]
    (inject* st path true)))

All the at events can take extra st->st functions. Mutating state in other ways whilst navigating is the bread and butter of any UI program. Here we are just making sure we are doing no harm in the first round of refactoring. The :nav was the legacy way of storing navigation state, so events and subscriptions around it will eventually be deleted. Here's a more expansive example to complete this explanation:

(defn check-out-button [bill-entity till-id disabled?]
  [:button
   {:on-click #(dispatch [::at/across :bill 1 
            [[wc-till-events/check-out* [:phone bill-entity till-id] true]]]
         #_[::wc-till-events/check-out   :phone bill-entity till-id])
    :disabled disabled?
    :class (->class :phone/bottom-action-button)}
   "Check Out"])

Just to tidy up, for the curious, s is the alias for Specter and ->class is what I use as the entry-point for ShadowCSS. And the true that you see here:

[wc-till-events/check-out* [:phone bill-entity till-id] true]

, is saying that check-out* takes app-state as its first arg - false would mean last arg. Lastly the second arg to the event ::at/across is a number. Here the number is 1 - which means that internally there's an assert to check that :at is already at the base of the tree.

For me every change of route needing to have a URL browser representation is too much to think about whilst building an application. Atrun is simple, designed for when your application lives inside a single URL. The :at path directly represents a way through your UI's tree structure without the translation layer of mapping URLs to routes and back again. You get simpler code, fewer concepts to learn, and no impedance mismatch between your navigation state and how you actually think about your interface hierarchy.

Atrun's design makes navigation events composable with arbitrary state mutations through the st->st function mechanism. This is great for real-world UIs where navigation rarely happens in isolation - you're usually also clearing selections, loading data, or updating multiple parts of state. With Atrun, these concerns flow together naturally in a single event dispatch. The result is navigation code that reads like what it does: "go down to the checkout screen, and also trigger the checkout process with these parameters."

Is Atrun more of a navigator than a router? Sure, but it is still all the router you most likely need. And when browser history and shareable URLs become requirements, Atrun's path-based design should make it easy enough to add bi-directional synchronization between the :at vector and the URL bar - but only where it is needed.

Published: 2025-12-07

Tagged: Re-frame ClojureScript

Archive