glitter-uikit
  • Home
  • Docs
  • GitHub

glitter-uikit β€” Guide

Why this exists

glitter-uikit is an AppKit (native macOS) renderer for glitter. Its upstream source, glimmer-uikit, applies Reagent's model (ratoms, automatic dependency tracking, component-local state) to AppKit; this project deliberately applies a different model β€” Replicant's single application-state atom, pure state -> hiccup view function, top-down re-render, and data-driven action-dispatch handlers β€” the same model glitter itself applies to GTK4. This guide covers how that model was adapted to a native, retained-mode, Objective-C toolkit that has no DOM underneath it.

glitter-uikit is a whole alternative renderer, not a widget added to an existing registry: it implements glitter.protocols/IRender and IMemory for real AppKit views, exactly as glitter.gtk implements the same two protocols for GTK4. The two are siblings, chosen at mount!'s call site by which library an app requires β€” glitter.core's reconciler itself knows nothing about either toolkit.

What glitter-uikit is

A .clj (Jolt/Chez Scheme host, not JVM) library:

(require '[glitter-uikit.app :as app]
         '[glitter-uikit.appkit :as appkit]
         '[glitter.core :as core])

(defonce state (atom {:count 0}))

(defn view [{:keys [count]}]
  [:vbox {:spacing 12}
   [:label {:label (str "Count: " count)}]
   [:hbox {:spacing 8}
    [:button {:label "+ 1" :on {:click [[:action/inc]]}}]]])

(defn execute-actions [_event actions]
  (doseq [[kind] actions]
    (case kind
      :action/inc (swap! state update :count inc)
      nil)))

(core/set-dispatch! execute-actions)

(defn -main [& _]
  (app/run (fn [window] (appkit/mount! window view state))))

Every subsequent swap! on state fires mount!'s watcher, which routes the re-render through glitter-uikit.app/on-gui (marshalling onto the AppKit main thread when the swap! came from elsewhere) and calls view again; glitter.core's reconciler diffs the new hiccup against the previous vdom and issues the minimal set of IRender/ IMemory calls needed to bring the live AppKit view tree in sync.

Pages

Orientation

  • examples.md β€” the catalogue of all sixteen runnable namespaces: the eight interactive demos (counter, widgets, temperature, flights, timer, crud, circles, todo) with screenshots and what each shows about the model, plus an index of the eight live-AppKit smokes and the one property each one pins. Also why the screenshots are stills rather than animations for now.
  • architecture.md β€” why glitter-uikit is a whole alternative renderer rather than a registered widget, the single reify implementing both IRender and IMemory (and the :extend-via-metadata-is-broken-under-Jolt finding behind that choice), the el atom that tracks a live view rather than handing the reconciler a raw pointer, mount!'s wiring, the data-driven event model this port adapted from glimmer-uikit's closure-based one, and the :ctor-always-gets-{} finding that shapes every widget spec.
  • porting-and-attribution.md β€” the two sourcing buckets (ported from glimmer-uikit / ported from glitter) and every documented deviation, model adaptation, and defect fix in the port β€” NOTICE.md is the authoritative ledger this page summarizes.

AppKit integration

  • appkit-widget-layer.md β€” the widget mapping layer, why it's shaped as it is, where AppKit is genuinely simpler than GTK (single-branch insert-before, no suppression set needed) and where it needs more care (NSNotFound raising uncaught, process-aborting exceptions; pointer-keyed registry cleanup) β€” kept deliberately separate from which changes are model adaptations versus which are fixes for real defects in glimmer-uikit v0.1.0.
  • app-loop-and-threading.md β€” the NSApplication bootstrap, on-gui's three-way thread branch, the single long-lived CFRunLoopSource + thunk-queue marshaller (and the atomic-drain fix that closed a dropped-callback race), and the flag-ordering defect main_thread_smoke.clj caught live.

Verify

  • testing-and-tasks.md β€” the headless unit suite and what each namespace in it covers, the live-AppKit smokes and what each one actually pins (reading the real AppKit tree, never this renderer's own bookkeeping), and the full jolt/bb task surface that runs them.
  • limitations.md β€” every known v1 gap and the reasoning behind leaving each one unfixed for now, including the two gaps that are about how confidently something is known rather than what the code does.

See also

  • glimmer-uikit β€” the Reagent-style sibling this project forked its AppKit FFI/widget layer from.
  • glitter β€” the source of glitter.core's reconciler and the GTK4 renderer this project mirrors the structure of.
  • README.md (repo root) β€” feature overview, quick start, requirements, and the full jolt/bb command reference.
  • CONTRIBUTING.md (repo root) β€” conventions, gotchas, file map and scope.
  • NOTICE.md (repo root) β€” the authoritative file-by-file attribution ledger and the Known gaps list limitations.md expands on.

App loop and cross-thread marshalling

glitter-uikit.app is adapted from the non-reconciler half of glimmer-uikit.core (its scheduler, run*, run!, quit!), reshaped to glitter.app's signature: run takes an on-activate callback of one arg β€” the window pointer β€” so this namespace has no dependency on any particular reconciler. glitter.app (the GTK sibling) is the same reshaping applied to a different upstream source β€” glimmer.core, the GTK original, rather than glimmer-uikit.core β€” for the same reason. The two diverge sharply once the underlying platform APIs do, which is most of what this page covers.

Bootstrapping: run and run*

(defn run
  [on-activate & {:as opts}]
  (let [start (fn [] (run* on-activate opts))]
    (if-let [hop (resolve 'jolt.host/call-on-main-thread-async)]
      (hop start)
      (start))))

AppKit requires its event loop on the process main thread. run hops onto Jolt's main-thread pump asynchronously via jolt.host/call-on-main-thread-async when that var resolves β€” true for an nREPL session, whose primordial thread parks there, so the eval that started the app returns and the session stays live β€” and runs inline (blocking until the app quits) otherwise, which is what a plain jolt run invocation gets.

run* does the actual bootstrap: gets NSApplication's shared instance, sets its activation policy to regular, sets w/invoker (the shared GlitterTarget instance) as the app delegate, builds a window, then β€” after setting main-thread/gui-loop-running?, covered below β€” calls on-activate with the window pointer so the caller can mount its own root content, centers and shows the window, activates the app, wires the optional :auto-quit-ms timer (used by every automated live smoke to quit the loop deterministically), and finally calls [NSApp run], which blocks running the AppKit main loop for the life of the app.

on-gui's three-way branch

(defn on-gui
  [work]
  (cond
    (not @gui-loop-running?) (work)
    (= (Thread/currentThread) @main-thread) (work)
    :else (post-to-gui work)))

Every render in this project goes through on-gui, not called directly β€” mount!'s state-atom watcher calls it, and IRender/next-frame is implemented as (app/on-gui f). Each of the three arms exists for a distinct reason:

  1. No loop running β†’ inline. Unit tests never start NSApplication at all, so on-gui degrades to a plain synchronous call with no run-loop machinery involved.
  2. Already on the main thread β†’ inline. Without this branch, every call to on-gui would marshal β€” even one already safely on the main thread, like appkit/mount!'s initial render! call happening from inside on-activate, or a click handler's dispatch triggering a swap! whose watcher fires synchronously. That would break any caller expecting a synchronous read-back immediately after a state change. examples/glitter_uikit/keyed_smoke.clj is exactly that caller: it calls (reset! state {:order ["c" "a" "b"]}) from inside on-activate (main thread) and reads the live NSStackView's arranged subviews back on the very next line β€” that only observes the post-render state because this branch ran the render inline rather than deferring it to the next loop iteration.
  3. Any other thread β†’ marshal via post-to-gui. This is the actual safety net: AppKit rejects view mutation from a thread that isn't the main thread.

glitter.app carries this identical branch β€” the code's own comment says so directly ("glitter.app carries the identical branch, added there after a live finding during its final whole-branch review"), and always-marshalling regardless of caller thread was the specific bug that finding caught there: it breaks exactly the synchronous-read-back case above.

The CFRunLoopSource + thunk-queue marshaller

Marshalling a worker-thread call onto the main loop needs a way to wake the loop and run something on it. GTK's answer is g_idle_add, which allocates and retains a fresh one-shot source per post. AppKit's CFRunLoop offers a lower-level primitive that this project uses to avoid that per-post cost: a single, long-lived CFRunLoopSource installed once, whose perform callback drains a shared queue.

(defonce ^:private scheduler
  (let [queue   (atom [])
        perform (ffi/foreign-callable
                 (fn [_info]
                   (let [[jobs _] (swap-vals! queue empty)]
                     (run! (fn [f]
                             (try (f)
                                  (catch :default e
                                    (println "glitter-uikit: scheduled work failed:" e))))
                           jobs)))
                 [:pointer] :void :collect-safe)
        ctx (ffi/alloc 80)]
    ;; ... CFRunLoopSourceContext fields written into ctx, perform at offset 72 ...
    (let [src (u/cf-run-loop-source-create ffi/null 0 ctx)
          rl  (u/cf-run-loop-get-main)]
      (u/cf-run-loop-add-source rl src (u/default-mode))
      {:queue queue :source src :run-loop rl})))

(defn- post-to-gui [work]
  (let [{:keys [queue source run-loop]} scheduler]
    (swap! queue conj work)
    (u/cf-run-loop-source-signal source)
    (u/cf-run-loop-wake-up run-loop))
  nil)

Posting is just conj onto the queue, signal, wake up β€” no allocation, no retained callable, per post.

The drain step is the interesting part, and it carries a real, previously shipped defect and its fix. The queue must be captured and cleared as one atomic operation:

(let [[jobs _] (swap-vals! queue empty)]
  (run! ... jobs))

The code's own correction comment explains exactly why a simpler (let [jobs @queue] (reset! queue []) ...) β€” a deref followed by an unconditional reset, which is what the upstream original did β€” is wrong: a worker thread's (swap! queue conj work) landing between the deref and the reset is captured by neither the already-read jobs nor the just-clobbered queue. The thunk is lost permanently, with no error and no log line. swap-vals! is CAS-based, so a concurrent post either lands before this swap (and gets drained in this pass) or after it (and survives intact for the next signal) β€” there is no window in between where a post can vanish.

The run-loop mode: a copy of the default mode, never the common modes

;; ffi.clj
(def ^:private kCFRunLoopDefaultMode
  (cf-string-create-with-cstring ffi/null "kCFRunLoopDefaultMode" 134217984))

(defn default-mode [] kCFRunLoopDefaultMode)

CFRunLoopAddSource hashes the mode string it's given and doesn't accept NULL β€” a value-equal CFString copy of an ordinary mode name works fine as far as CFRunLoopAddSource is concerned. kCFRunLoopCommonModes is the one exception: CoreFoundation recognizes that specific constant by pointer identity, not by string value, so a value-equal copy of it β€” constructed the same way as above, from the same characters β€” silently fails to register as a common mode. The code sidesteps that trap entirely by targeting kCFRunLoopDefaultMode instead, which is where [NSApp run] actually pumps events, and which behaves like an ordinary mode string under CFRunLoopAddSource.

The flag-ordering defect

run* originally reset gui-loop-running? and main-thread after calling on-activate. This repository's history is a single squashed commit, so the original ordering isn't independently diffable β€” but the fix's own correction comment, still in app.clj today, records exactly what was wrong and why:

These two resets MUST happen BEFORE on-activate, not after it. glitter.app sets them immediately before g_application_run and that is correct THERE, because its on-activate is a foreign-callable wired to GTK's "activate" signal β€” it fires from INSIDE the running loop, so the flags are already set by the time it runs. AppKit has no such signal indirection: run* calls on-activate eagerly and directly, before [NSApp run]. Copying glitter's textual ordering without accounting for that difference left both flags unset for the whole of on-activate, so EVERY on-gui call during it took branch 1 (inline) regardless of thread.

The fix moved both resets to before on-activate is called β€” the current, corrected code is:

(reset! main-thread (Thread/currentThread))
(reset! gui-loop-running? true)
(try
  (on-activate win)
  (u/window-center! win)
  (u/window-show! win)
  (u/activate! app)
  (when auto-quit-ms (w/auto-quit! app auto-quit-ms))
  (u/run-app! app)
  (finally (reset! gui-loop-running? false)))

With the flags reset after on-activate (the original ordering), every on-gui call made during on-activate β€” which is exactly when appkit/mount! runs its first render β€” saw gui-loop-running? still false and took branch 1 (inline), unconditionally, regardless of which thread actually made the call. For the mount's own synchronous render that's harmless by accident. It stops being harmless the moment anything else calls on-gui from a genuinely different thread while on-activate is still running β€” an nREPL eval racing the mount, a background fetch completing early β€” because inline means mutating AppKit views directly from that worker thread, which is precisely the violation the three-way branch in on-gui exists to prevent.

The reason this is subtle, not just a copy-paste slip, is that glitter.app's run* has the identical textual ordering β€” reset the flags, then immediately call the blocking run function β€” and it is correct there:

;; glitter/app.clj β€” correct with this exact ordering
(try
  (reset! gui-loop-running? true)
  (reset! main-thread (Thread/currentThread))
  (g/g-application-run app 0 ffi/null)
  (finally (reset! gui-loop-running? false)))

glitter.app's on-activate is not called directly by this code at all β€” it's a foreign-callable wired to GTK's "activate" signal via g_signal_connect_data, registered before this block runs. The callback only actually fires once g_application_run starts pumping the GTK main loop and GTK dispatches that signal β€” i.e., from inside the already-running loop, after gui-loop-running?/main-thread were set immediately before the call that starts it. The flags are simply already true and correct by the time GTK's activate fires.

AppKit has no equivalent indirection. run* calls on-activate eagerly and directly, as a plain function call, before [NSApp run] (u/run-app! app) is ever invoked β€” there's no signal dispatch standing between "the flags get set" and "the caller's mount code runs." Copying glitter.app's textual ordering without accounting for that difference left both flags unset for the whole of on-activate on this platform. The fix is the flag resets moved earlier, with the try/finally widened to cover on-activate too, so gui-loop-running? still gets cleared if on-activate throws.

Caught by main_thread_smoke.clj

examples/glitter_uikit/main_thread_smoke.clj is what found this. It mounts, then mutates state from inside a future β€” a genuinely different thread β€” and schedules a read-back via app/schedule!, which (unlike on-gui) always marshals regardless of caller thread, so the read-back is guaranteed to run after the watcher's own posted render (CFRunLoopSource thunks drain FIFO).

The load-bearing assertion is which thread view actually ran on, not merely that the label's text updated:

(defn view [{:keys [txt]}]
  (reset! render-thread (Thread/currentThread))
  [:vbox {:spacing 4} [:label {:label txt}]])
...
(record! (not= main-t @worker-t) "worker-really-was-another-thread")
(record! (= main-t @render-thread) "view-rendered-on-the-main-thread")
(record! (= ["from-worker"]
            (mapv u/control-string (w/stack-children (root-stack window))))
         "label-updated")

An unmarshalled watcher still updates the label β€” nothing stops a worker thread from writing NSTextField's stringValue under Jolt; it's an AppKit violation, not something that throws or gets caught. So a text-only check (did the label say "from-worker"?) would pass with the bug fully present and prove nothing about which thread did the mutating. view recording its own thread into render-thread, and the smoke requiring that to equal the main thread captured by run*, is what actually pins the regression. The smoke also separately asserts the worker thread really was a different thread from the main one, so the whole check can't pass vacuously if future were ever to run inline, and it tracks whether the scheduled read-back callback ran at all (checked after app/run returns) β€” so a scheduler regression that drops the callback entirely can't report a silent, vacuous :PASS either.

The AppKit widget layer

glitter-uikit.widget maps hiccup tags to AppKit view constructors and prop appliers; glitter-uikit.appkit drives it from the IRender/IMemory protocols. This page covers the mechanics and the specific AppKit API traps this port hit β€” each is a real, live-verified behavior this codebase measured rather than assumed.

The widget registry

(def specs
  (atom {:window      (window-spec)
         :box         (box-spec)
         :button      (button-spec)
         :label       (label-spec)
         :entry       (entry-spec)
         :checkbutton (checkbutton-spec)
         :separator   (separator-spec)
         :frame       (frame-spec)
         :scrolled    (scrolled-spec)}))

Nine tags, and :hbox/:vbox are not among them. They are sugar, resolved by a separate two-step mechanism rather than by their own specs: aliases maps both onto :box, normalize-tag applies that map, and spec-for looks the normalized tag up β€” while with-orientation injects the implied :orientation prop so a bare [:hbox …] lays out horizontally without the caller saying so.

(def ^:private aliases {:hbox :box :vbox :box})
(defn- normalize-tag [tag] (get aliases tag tag))
(defn- spec-for [tag] (@specs (normalize-tag tag)))

Each spec is {:ctor (fn [props] view) :apply (fn [view props]) :container kw}. :container determines how children attach: :box (ordered append/remove/reorder via NSStackView), :window/:frame/:scrolled (single child), or :none (leaf). Concrete view mapping:

TagAppKit viewContainer
:windowNSWindowSingle child (pinned to content view)
:box, :hbox, :vboxNSStackViewOrdered (horizontal or vertical)
:buttonNSButton (push style)Leaf
:labelNSTextField (label style)Leaf
:entryNSTextField (editable, bordered)Leaf
:checkbuttonNSButton (switch style)Leaf
:separatorNSBox separator (horizontal only)Leaf
:frameNSBox (titled)Single child
:scrolledNSScrollViewSingle child (document view)

create! builds a view end-to-end: construct via the widget spec's :ctor, then apply props via its :apply closure β€” :apply is the prop applier, not a separate step that runs after props are applied. create! does no signal wiring at all: unlike glimmer-uikit's original, event lifecycle belongs entirely to glitter-uikit.appkit, which calls IRender/set-event-handler whenever handler data changes between renders β€” not on every render, and not for glitter.gtk either, which calls its equivalent under the same "data changed" condition β€” see "Where AppKit is simpler" below.

Where AppKit is simpler than GTK

insert-before is single-branch

glitter.gtk's insert-before branches on whether a child is already tracked in the parent's children list, because gtk_box_insert_child_after asserts its child is unparented (gtk_widget_get_parent(child) == NULL) and throws a GTK-CRITICAL + silently no-ops when called on an already-parented child. The real GTK API for repositioning is gtk_box_reorder_child_after.

AppKit's insertArrangedSubview:atIndex: handles both uniformly β€” it automatically MOVES an already-arranged subview if you pass one of the stack's own children, so no GTK-style parented/unparented branch is needed. Measured live:

;; Starting state: [A B C]
;; Insert C at index 0
;; Result: [C A B]
;; Count: unchanged

So glitter-uikit.widget/insert-child-after! is a single code path serving both a fresh insert and a keyed move β€” but it is not identical to DOM's insertBefore. insertBefore takes a reference node; AppKit's call takes an index, and that index is remove-then-insert internally, interpreted against the post-removal array:

;; Starting state: [A B C D]
;; Insert A at index 3 (A is already arranged, at index 0)
;; Result: [B C D A]   -- NOT "insert before whatever is now at index 3"

A fresh insert or a backward move (the child already sits at or after the target sibling) is unaffected, because nothing before the sibling shifted. A forward move (the child currently sits before the sibling) needs the un-incremented sibling index rather than (inc i), or the child lands one slot too far right β€” a real bug this port shipped and fixed during final review; see insert-child-after!'s docstring in widget.clj for the full measured detail.

No suppression set needed

glitter.widget carries a suppressing set because GTK's programmatic setters (like gtk_editable_set_text, gtk_check_button_set_active) synchronously re-emit their own signal, which would feed a re-render back into app dispatch.

AppKit does NOT fire action or delegate callbacks for programmatic setState:/setStringValue:/etc., so there is nothing to suppress. This absence is intentional β€” do not add a suppression set. The property is asserted live by examples/glitter_uikit/keyed_smoke.clj's programmatic-active-does-not-dispatch test, which sets a checkbutton's state programmatically and verifies no action callback fires.

Where AppKit needs more care than GTK

NSNotFound is NSIntegerMax, not -1, and raises uncaught exceptions

NSStackView's children are indexed via arrangedSubviews array access. indexOfObject: returns NSNotFound (which is NSIntegerMax β€” 9223372036854775807 β€” not -1 or NSUIntegerMax) when the view is not found. Feeding NSNotFound + 1 to insertArrangedSubview:atIndex: raises an uncatchable NSException that aborts the process. A Clojure catch :default does not intercept it, because Objective-C exceptions do not unwind into Scheme.

Solution: Every index read goes through arranged-index, which checks for NSNotFound/negative results and returns nil for "absent" β€” it throws nothing. Every caller (insert-child-after!, replace-child!) is written to handle that nil, falling through to a safe branch (append) instead of ever reaching the arithmetic that would produce a process-aborting index. Verified live β€” an index access that misses silently would cascade into worse bugs downstream, so this is load-bearing.

removeArrangedSubview: leaves a plain subview

NSStackView's removeArrangedSubview: removes a view from the stack's arranged list but does NOT remove it from the view hierarchy β€” it becomes a plain, unmounted subview still parented to the stack, consuming memory and potentially interfering with sibling layout. A second call to removeFromSuperview is needed to fully detach it.

Solution: remove-child! calls removeFromSuperview after removeArrangedSubview: to complete the detach. Verified live against the actual view hierarchy.

Pointer-keyed registries and cleanup

glitter-uikit.widget maintains several registries (:actions, :changes, :alignments) keyed by view pointer. Views passed to remove-child! or replaced must be explicitly cleaned up via forget-view!, or the registries will grow unbounded and accumulate stale handlers that, because AppKit reuses freed addresses, can be inherited by new views landing on dead views' addresses.

Solution: remove-child! calls forget-view! before removing the view. If cleanup is skipped, a re-created view can silently inherit handlers from a previous view that occupied the same memory.

Model adaptations vs. real defect fixes

The port made three changes that are deliberate model adaptations, not fixes for bugs in glimmer-uikit β€” each is required because glitter's architecture differs from glimmer's own, not because the original code was wrong for glimmer:

  1. Event lifecycle ownership split. Upstream (glimmer-uikit) connects target/action once at mount and lets handlers close over a reactive cell β€” correct for glimmer's own Reagent-style model. glitter calls IRender/set-event-handler whenever handler data changes between renders, not just on mount/unmount, and two writers of setTarget:/setAction: would fight β€” so connect-signals! was removed rather than adapted, and event wiring moved entirely to glitter-uikit.appkit's IRender/set-event-handler.
  2. GlitterTarget class registration. Renamed from upstream's "GlimmerTarget" (registered process-wide via objc_allocateClassPair) so the two classes are distinguishable if glitter and glitter-uikit ever ran in the same process β€” not a bug fix, just a name collision avoided.
  3. Prop filtering on some?. Upstream's apply-props! filters on truthiness; here it filters on some? so an explicit false (:active false, :sensitive false) still reaches the view. Needed because glitter's own apply-props! makes the same some? choice for the same reason (deviation #3 from Replicant) β€” matching the caller's contract, not repairing upstream.

Three further changes ARE real defect fixes β€” bugs that would misbehave regardless of which reconciler drives them:

  1. replace-child! captures position before removing. Upstream did removeArrangedSubview: then addArrangedSubview:, which always inserts at the END of the stack β€” replacing a non-final child silently relocated it there. This is the identical defect glitter fixed on the GTK side (see glitter's NOTICE.md). Fix: capture the old child's index before removing it, then insert the new child at that same position.
  2. insert-child-after! added, then its forward-move index fixed. Absent upstream entirely β€” glimmer's reconciler never needs it, but glitter.core's insert-before does. The first version carried a real bug of its own: insertArrangedSubview:atIndex: is remove-then-insert internally with a POST-removal index, and the added code incremented the sibling's PRE-removal index unconditionally, so a forward keyed move (the moved child currently sits before its target sibling) landed one slot too far right. Fixed during this arc's final review β€” see "insert-before is single-branch" above and insert-child-after!'s docstring in widget.clj.
  3. arranged-index added, and every index read routed through it. Upstream called stack-index-of! directly and did (inc i) on the result β€” a process abort waiting to happen whenever the sibling was absent (NSNotFound is NSIntegerMax, and feeding NSNotFound + 1 to insertArrangedSubview:atIndex: aborts the process uncatchably). Fix: arranged-index returns nil for "absent" instead, and every caller handles it.
  4. forget-view! added, and remove-child! calls it. Upstream's actions/changes/alignments registries were never cleaned β€” an unbounded leak, and a stale-handler hazard: because AppKit reuses freed addresses, a newly allocated view could land on a dead view's address and inherit its handler.

For full provenance (which file ported from where, every documented deviation), see NOTICE.md.

Architecture

What glitter-uikit is (and isn't)

glitter.core (ported from replicant.core) owns the entire reconciler β€” the diff algorithm that decides what changed between two hiccup trees and what to do about it β€” plus the IRender/IMemory protocols in glitter.protocols that it drives. Neither namespace knows anything about AppKit, or even that AppKit exists.

glitter-uikit supplies the other half: real AppKit views (glitter-uikit.widget), the IRender/IMemory implementation that wires them into the reconciler (glitter-uikit.appkit), and the app loop that gets an NSApplication running in the first place (glitter-uikit.app). This is a whole alternative renderer β€” the AppKit counterpart of glitter.gtk β€” chosen at mount!'s call site by which library an app requires, not a widget registered into some shared component registry inside glitter.core itself.

flowchart TD
  state["state atom"] -->|swap!| watch["add-watch fires"]
  watch --> ongui["glitter-uikit.app/on-gui<br/>(marshal to the AppKit main thread if needed)"]
  ongui --> view["(view @state)"]
  view -->|new hiccup| reconcile["glitter.core/reconcile(renderer, root-el, new-hiccup, prev-vdom)"]
  reconcile -->|"diffs new hiccup against prev-vdom, issues the<br/>minimal set of protocol calls to bring the<br/>live tree in sync"| protocols["glitter.protocols/IRender + IMemory<br/>(glitter-uikit.appkit implements this for real AppKit views)"]

One reify, not two composed pieces

glitter-uikit.appkit/renderer implements IRender and IMemory in a single reify form, rather than composing two separately-built pieces. This is deliberate, not incidental: the code's own comment on the point reads

;; IMemory, folded into the SAME reify form rather than composed via
;; metadata: :extend-via-metadata is verified broken under Jolt (see
;; glitter's porting-and-attribution.md). Keyed off the el atom, which is
;; already a stable Clojure identity, rather than the raw view pointer.

The verification itself lives in glitter's own porting notes, not re-derived here: glitter.protocols declares both protocols with :extend-via-metadata true (mirroring Replicant's own replicant.protocols), and Replicant's test helper (replicant.mutation-log) actually relies on that β€” composing IRender plus a logging concern via with-meta. Requiring that helper under Jolt and calling its renderer throws No method create-element in replicant.protocols/IRender. :extend-via-metadata simply doesn't dispatch under Jolt; reify does. Both glitter.gtk and glitter-uikit.appkit implement IRender+IMemory together in one reify because of that finding, not by parallel taste.

The el atom: a tracking atom, not a raw pointer

create-element and create-text-node don't hand the reconciler a raw AppKit view pointer. They return a Clojure atom:

{:tag <hiccup tag keyword>
 :view <AppKit view pointer>
 :children [<child el atom> ...]
 :handlers {<event keyword> <handler fn>}}

(mount! builds the same shape by hand for the root element, tagging it :window over the caller-supplied NSWindow pointer.)

There are two separate reasons this is an atom holding a small map, not the pointer itself.

The first is shared with glitter.gtk: glitter.core's reconciler wants an opaque, stable identity per live-tree node to hold across renders β€” IMemory's remember/recall key off it, and keyed-list diffing needs something to compare across two renders that isn't just "the same pointer happened to come back." An atom is already a stable Clojure identity, so remember/recall can use it as a map key without any question of whether a Jolt FFI pointer hashes or compares correctly.

The second is AppKit-specific, and it's about registry cleanup, not identity. An NSControl has exactly one target/action slot, and the shared ObjC method implementations that receive its callback β€” fire-cb/change-cb in glitter-uikit.widget β€” are typed [:pointer :pointer :pointer] :void: (fn [_self _cmd sender] ...). The IMP gets a raw sender pointer and nothing else β€” no Clojure closure can travel across that boundary. So dispatch has to go through global, process-wide Clojure atoms keyed by raw view pointer (glitter-uikit.widget/actions, /changes, /alignments), owned end-to-end by glitter-uikit.appkit.

That's a real hazard GTK doesn't share. GTK's per-widget signal connections live on the GObject itself (g_signal_connect_data returns a connection id, g_signal_handler_disconnect uses it) β€” there's no external Clojure-side table for a freed widget to leave stale. AppKit's port has exactly that table, and AppKit reuses freed pointer addresses. An un-scrubbed entry in actions/changes/alignments isn't just a leak: a brand-new, entirely unrelated view can allocate at the same address a removed one used to occupy and silently inherit its stale handler. glitter-uikit.appkit/forget-subtree! exists to close that window β€” every IRender method that detaches a subtree (remove-child, replace-child's displaced node, remove-all-children) calls it first, and it walks (:children @el) recursively, calling glitter-uikit.widget/forget-view! on every descendant's pointer before that memory can be reused. That walk is only possible because the el atom carries :children β€” a raw pointer alone couldn't be walked at all.

:handlers on the el atom itself is a bookkeeping mirror, not a second source of truth: set-event-handler/remove-event-handler write it (swap! el assoc-in [:handlers event] f, swap! el update :handlers dissoc event), but nothing in this codebase reads it back except a test asserting it starts as {}. The live dispatch tables are glitter-uikit.widget/actions and /changes.

mount!'s wiring

(defn mount!
  [window view state-atom]
  (let [r (renderer)
        root-el (atom {:tag :window
                       :view window
                       :children []
                       :handlers {}})
        vdom (atom nil)
        render! (fn [state]
                  (reset! vdom (:vdom (core/reconcile r root-el (view state) @vdom
                                                      {:aliases (alias/get-registered-aliases)}))))]
    (render! @state-atom)
    (add-watch state-atom ::render (fn [_ _ _ state] (app/on-gui (fn [] (render! state)))))
    nil))

The root element is the NSWindow pointer itself, tagged :window β€” an NSWindow is a single-child container in this port (glitter-uikit.widget's :window spec has :container :window, and append-child!'s :window branch pins the mounted view to window-content), so the view function's return value becomes the window's one content child, not a replacement for the window.

Every re-render goes through glitter-uikit.app/on-gui, never called directly β€” a swap! on state-atom can originate from any thread (an nREPL eval's worker thread, a future), and on-gui is what makes routing that safely onto the AppKit main thread possible. See app-loop-and-threading.md.

Registered aliases (glitter.alias/get-registered-aliases) are merged into every reconcile call automatically via {:aliases ...}, so an app never has to thread its alias registry through by hand.

The event model: data, not a closure wired once

This is the port's central adaptation, and it's worth naming precisely what changed. glitter-uikit.widget was forked from glimmer-uikit, whose Reagent-style model wires target/action once, at widget creation, via connect-signals! β€” reading from the widget's own source confirms this: glimmer_uikit/widget.clj has

(when-let [h (:on-click props)]    (swap! actions assoc widget h))

called from create!, once, and the stored handler h is a plain closure the caller passed as :on-click. If the click behavior needs to change, the caller re-mounts or re-derefs a reactive cell the closure already closes over β€” the target/action wiring itself never changes.

glitter's hiccup carries handlers as data instead: [:button {:on {:click [[:action/inc]]}}]. glitter.core's diff calls IRender/set-event-handler again whenever that data changes between renders β€” not just once at creation β€” because the action tuples for the same event on the same element can differ across two renders without the event key itself changing. glitter-uikit.appkit's set-event-handler has to actually do something on every one of those calls:

(set-event-handler [_ el event handler _opt]
  (let [view (ptr el)
        tag  (:tag @el)
        f    (dispatcher el tag event handler)]
    (cond
      (contains? action-events event)
      (do (u/control-target! view w/invoker)
          (u/control-action! view (u/sel "fire:"))
          (swap! w/actions assoc-in [view event] f))

      (= :change event)
      (do (u/control-delegate! view w/invoker)
          (swap! w/changes assoc view f))

      :else nil)
    (swap! el assoc-in [:handlers event] f))
  nil)

action-events is #{:click :toggled :activate} β€” these route through NSControl's target/action slot, redirected to the single shared GlitterTarget instance (w/invoker) and its fire: selector. :change routes through the NSTextField delegate instead (controlTextDidChange:), also on w/invoker. Either way, the actual handler function f lands in a pointer-keyed registry (glitter-uikit.widget/actions or /changes) β€” the same structural requirement described above: fire-cb/change-cb receive only the sender pointer, so dispatch has to be a global lookup by that pointer. The el atom's :handlers map gets the same write purely for bookkeeping, as noted above.

dispatcher wraps the caller's handler (a glitter.core-supplied fn of one event map) as the one-arg fn the ObjC callback actually invokes:

(fn [sender]
  (let [value-fn (@signal-value [tag event])]
    (handler (cond-> {:glitter/node el
                      :glitter/appkit-view sender}
               value-fn (assoc :glitter/value (value-fn sender))))))

:glitter/node is the key glitter.core's own build-event-map reads on Jolt to recover the acting element, since there's no DOM event.target to fall back on. signal-value is a small table of [tag event] -> (fn [view] value) extractors (e.g. [:entry :change] reads control-string) β€” every entry re-reads the view's own current property rather than trusting a value the callback happened to carry, which is safe here because AppKit updates a control's property before invoking its action/delegate, mirroring the identical choice in glitter.gtk.

remove-event-handler and clear-target-if-unused! are the other half: removing the last action handler for a view drops its entry from w/actions and clears the control's target back to null via control-target!. Note this clears only target, not the action selector itself (still fire:) β€” harmless, since AppKit has nothing to send the action message to once target is null.

The :ctor finding: props always arrive empty

glitter.core's create-node calls IRender/create-element with only an optional XML-namespace hint, never the real hiccup props:

;; glitter/core.clj β€” create-node's actual call
(r/create-element renderer tag-name (when ns {:ns ns}))

ns is non-nil only for SVG/foreignObject hiccup, which no AppKit widget in this project ever produces β€” so for glitter-uikit, options is always nil at this call site. glitter-uikit.appkit's create-element reflects that directly:

(create-element [_ tag-name options]
  (let [tag (keyword tag-name)
        view (w/create! tag (or options {}))]
    ...))

(or options {}) means w/create! β€” and therefore a spec's :ctor, and the :apply call create! makes right after constructing the view β€” is always invoked with {} through the real reconciler path. The real prop values arrive afterward, one key at a time, through a different path entirely: glitter.core's set-attributes calls set-attr per key (run! over the new attrs map), which reaches IRender/set-attribute once per attribute. glitter-uikit.appkit's implementation forwards each call as a single-key partial map:

(set-attribute [_ el a v _opt]
  (w/apply-props! (:tag @el) (ptr el) {(keyword a) v})
  nil)

The consequence for anyone writing a widget spec: a :ctor that branches on a prop value is dead code along the real reconciler path β€” the widget's actual observable state depends entirely on whether :apply independently handles that same key once set-attribute delivers it. button-spec's :ctor reads (:label p), but p is always {} there; the button's real label comes from :apply's (when (contains? p :label) (u/control-title! w (:label p))), invoked later through apply-props! once glitter.core sends the real :label attribute through. Every spec in glitter-uikit.widget follows that shape β€” :ctor builds a bare, presentable view; :apply is what any real prop value actually reaches.

The examples

examples/glitter_uikit/ holds sixteen runnable namespaces, and every one has a deps.edn alias and a bb task. They come in two kinds:

  • Eight interactive demos you open and click β€” the galleries below.
  • Eight live-AppKit smokes, each a small, complete glitter program that mounts a real window, asserts against real AppKit state, and exits non-zero on failure.

Run any of them with bb <name>, or jolt -M:<name> without babashka. bb info prints the whole list grouped, and bb smokes runs all eight smokes in sequence, stopping at the first failure.

All fourteen need a GUI session β€” and GTK4 installed, even though this renderer never calls a GTK function. See Limitations for why.

Interactive demos

previewbb nameTaskWhat it demonstrates
counterCounterThe canonical demo. One state atom, a pure state -> hiccup view, handlers as data. The whole model in a window you can click through in ten seconds.
temperatureTemperature ConverterTwo linked numeric fields, each edit updating the other. Its domain half is glitter's, carried across unchanged β€” the pure part of a glitter app is renderer-agnostic, which is the point of the split.
flightsFlight BookerConstraints between widgets and within one: a :drop-down choosing one-way/return, two strictly-validated date fields, and a Book button gated on both.
timerTimerThe only demo whose state advances on its own β€” a repeating NSTimer drives a :progress-bar, and moving the :scale changes the duration immediately rather than at the next tick.
crudCRUDA prefix filter, a selectable list, and Create/Update/Delete gated on selection. The spec's "separation of domain and presentation logic" is get-people β€” one pure filter-and-sort fn. The list is built from :scrolled + :button rows rather than a table widget; see below.
circlesCircle DrawerClick to place a circle, click one to select it, adjust its diameter live, undo and redo. Circles are CALayers, not views β€” a layer takes no part in hit-testing, so a click reaches the canvas even under a circle and hit-testing stays a pure function over the model.
todoβ€”A task board on glitter.nexus: derived counts computed inline on every re-render (glitter has no reactive-derivation primitive), an entry with :change/:activate, checkbutton toggles, list rendering in a frame.

7GUIs tasks 1 through 5 ship. Task 5 arrived without NSTableView: a list box is functionally a scrollable column of selectable rows, so crud builds one from :scrolled + a :button per person, with the selected row marked by a caret in its label. Every rule the spec states is satisfied; what is missing is presentation β€” real selection highlighting, keyboard navigation, alternating row colours β€” which a table widget would give for free. When :list-box lands, that view swaps its list section and nothing else.

Task 6 needed two things this renderer did not have, and both are now in it. Mouse coordinates: +[NSEvent mouseLocation] returns a CGPoint, a struct, which no scalar message-send can carry — Jolt's FFI does support aggregate returns, so ffi.clj gained struct-by-value sends and a screen→window→view conversion. And free positioning: NSStackView places children in order, so the :canvas tag is an NSButton (it must receive clicks) whose layer holds the circles.

Cells (task 7) remains out of reach: a 100Γ—26 spreadsheet with a formula language, change propagation and cycle detection. 2,600 live cells is where NSTableView stops being avoidable.

glitter itself ships tasks 1-5 and no further, so circles had no reference to port β€” its model and its rendering are both original here.

The widget gallery

previewgallery
bb widgets β€” every tag the renderer registers, in one window. One state key, :level, is read by three widgets at once: a :scale drives it while a :progress-bar and a :level-bar display it, so dragging the slider shows a single key re-rendering everything that reads it. It is also the only example that exercises :separator and :scrolled.

Why the demos are worth reading, not just running

counter.clj says it directly in its own docstring: in glimmer-uikit (the Reagent-style sibling this project was ported from), local state lives in a component-scoped ratom and a click closure calls swap! itself. Here all state is in one top-level atom, the view is a pure function of it, and click handlers are data dispatched through one global fn, never closures.

todo.clj makes the same contrast at larger scale, and adds the derived-counts point: there is no memoized selector, no reaction, no cache β€” the three numbers above the task list are just arithmetic over :tasks re-run on every render, because re-running the whole view is the model.

The three 7GUIs ports make a different point. Their domain halves β€” set-temperature, parse-date / get-form-state, get-view-state β€” are carried over from glitter unchanged, because they are pure Clojure with no toolkit in them. Only the view and the -main differ. That is the renderer split doing exactly what it exists to do.

Findings worth knowing, from porting the 7GUIs demos

:width-chars is not a width

Nothing in this renderer could give a control a width until flights.clj needed one. :width-chars looks like the answer and is not β€” it routes to setPreferredMaxLayoutWidth:, a text-wrapping hint that leaves a control free to be compressed to nothing. The measured consequences were not subtle: an :entry beside a label was squeezed to zero width and vanished, and where it survived, a ten-character date rendered as 26.08.20.

Four plausible routes were each tried against a live window and each did nothing: :vexpand false on the container, :hexpand true on the field, :hexpand true on the row, and :halign :fill. What works is :width-request, which installs a real NSLayoutConstraint. The full table is in Limitations.

Lenient date parsing

flights.clj does not call t/parse-date directly. glitter verified that it is lenient under this Jolt port: "27.03.2014x" parses to 2014-03-27 ignoring the trailing garbage, "not-a-date" parses to -0001-11-30, and "31.02.2014" β€” not a real date β€” rolls over to 2014-03-03. The round-trip wrapper (parse, reformat with the same formatter, reject unless it matches the trimmed input exactly) is what actually makes the spec's "coloured red when ill-formatted" rule work. All four traps are re-verified here.

Live-AppKit smokes

These are examples in exactly the sense the demos are β€” each mounts a window and drives a real glitter view. What makes them smokes is that they then assert, against the live AppKit tree rather than against the renderer's own bookkeeping, which would agree with itself and pass even if no AppKit call ever landed.

bb smokes runs all eight in sequence and stops at the first failure. The full argument for each β€” the exact assertions, and why each is shaped to fail loudly instead of passing vacuously β€” is in Testing and tasks. The tables below are the index; that page is the argument.

Smokes: reconciler behaviour

bb namePins
smokeA view function renders into a real NSWindow, and a state-atom write re-renders it. Start here.
keyed-smokeA keyed reorder lands in the right live order and reuses the same view pointers rather than recreating them. Also pins the no-suppression property this renderer is built on.
replace-child-smokeA replaced child stays at its exact index, not appended at the end β€” the glimmer-uikit original's bug.
insert-before-smokeA new child lands mid-list; an existing child's keyed reorder moves rather than duplicates; a forward move lands at the sibling's un-incremented index β€” this port's own final-review fix.

Smokes: events and value delivery

bb namePins
reactivity-smokeA programmatic state write re-renders in place, and a real -[NSControl performClick:] dispatches through the target/action path the renderer actually wired β€” not a direct handler call, which would prove nothing about the wiring.
handler-cleanup-smokeUnmounting a subtree drops every handler registration it held, grandchildren included. AppKit reuses freed addresses, so a leaked registration can be inherited by a newly allocated view at the same address.

Smokes: threading

bb namePins
main-thread-smokeA state change made from a non-main thread still renders on the AppKit main thread β€” the property the CFRunLoopSource scheduler exists for.
repl-live-smokeThe same marshalling, shaped like a live nREPL session: a worker thread mutates state while the app runs. An unmarshalled render touching AppKit off-main aborts the process outright, so surviving is itself part of the assertion.

How the recordings are made

The capture tooling is maintainer-only. Everything below describes how the images in this repo were made, and both tools it names β€” screen-grab and cgevent β€” are not public yet. You do not need either of them: every screenshot and GIF is committed, so the gallery works from a plain clone. This section is here so the provenance of each image is on the record, and so the recipe is written down for whoever regenerates them.

Every preview above is a real recording of the demo being driven. They are produced by scripts/record_gifs.sh, which drives each demo through cgevent's accessibility API: :tap-by-role sends AXPress to a real control, and once a field is focused that way a synthetic :type lands in it. That is a different mechanism from glitter's, which steers GTK with a raw Tab/Space/type timeline β€” screen-grab's own README notes that a synthetic click "cannot actuate in-window controls in any app", which is why the accessibility route is the one that works here.

The flows live in scripts/flows/*.edn, one per demo, and are worth reading before writing another. :role/:text/:id are the only selector shorthand keys cgevent honours, so {:role "AXButton" :title "Add"} matches any button and passes vacuously. An ambiguous match is an error, which is why flights.edn taps only uniquely-named controls β€” both its date fields hold today's date at startup.

Recording one needs a click first

scripts/record_gifs.sh is not unattended. From a cold launch the app exposes only a recursive AXApplication with no AXWindow child, so a flow finds nothing to press; the subtree populates once a person clicks the window. Activating through System Events, clicking the title bar synthetically, and polling for four minutes all failed to wake it. The script waits β€” printing the window's on-screen position β€” until you click, then records by itself.

timer is the exception that needs no click, because its flow contains no taps: the demo advances on its own.

Whether an AppKit app launched bare by Jolt β€” no .app bundle, no bundle identifier β€” should expose its window to accessibility before it is focused is an open question, and the likeliest place a fix would come from.

The stills under docs/demos/*.png are kept alongside the GIFs and regenerate unattended via screen-grab shot --manifest scripts/demo_manifest.edn, needing no clicking at all. That is the CI-safe path if these ever have to be rebuilt without a person present.

One further caveat if you touch counter: its committed screenshot shows Count: 5 because a person clicked the button five times. That frame cannot be regenerated by the tool. The ledger hashes each item's :src, so an unchanged counter.clj reports up to date and the frame is safe β€” but editing counter.clj, or passing --force, recaptures it as an initial-state Count: 0. Re-steer it by hand rather than committing that.

Adding an example

Two touchpoints, and skipping either leaves the example invisible to something:

  1. The namespace under examples/glitter_uikit/, plus a deps.edn alias so jolt -M:<name> works without babashka.
  2. A bb.edn task, so bb <name> works and it shows up in bb info.

Then add its row here β€” to the demo gallery, or to the smoke index above. A new smoke also belongs in bb smokes and in Testing and tasks, which is where its assertions get explained.

If the new example is a screenshot-worthy interactive demo, add it to scripts/demo_manifest.edn's :examples and regenerate with screen-grab shot --manifest scripts/demo_manifest.edn. If it animates on its own, without needing input, it can go in scripts/demo_gifs.edn instead and be recorded with screen-grab record.

Both of those need the maintainer-only tooling above. A contribution that adds an example is entirely welcome without an image β€” say so in the PR and it can be captured on this side.

Known v1 limitations

NOTICE.md's "Known gaps" section is the authoritative list this page expands on. Most of these are AppKit-native constraints (there is no AppKit equivalent of a thing GTK has); a couple are cross-renderer defaults that happen to differ from glitter.gtk and were only found during this port's final whole-branch review. None are unnoticed rough edges β€” each has a reason the fix was deferred rather than a reason it's impossible.

GTK4 must be installed, even though this renderer never touches it

deps.edn pulls in glitter via :local/root "../glitter", and glitter's own deps.edn declares GTK4/GLib/GObject/GIO under :jolt/native. Jolt inherits a dependency's natives transitively and hard-fails in load-natives! before any namespace loads if one is missing β€” so a glitter-uikit app needs GTK4 installed even though it renders exclusively through AppKit and glitter-uikit.ffi never calls a GTK function. deps.edn's own comment records this, and it isn't a guess: an :aliases-scoped :jolt/native was verified live to be silently ignored, so it cannot be scoped away from this side.

The real fix is extracting a natives-free glitter-core β€” the toolkit-agnostic half of glitter (core, protocols, hiccup, vdom, alias, assert, asserts, errors, console-logger, env, nexus/*) β€” exactly the split upstream glimmer made at its own v0.1.0, with glitter (GTK4) and glitter-uikit (AppKit) both depending on it. Recorded in README.md's Status section as deferred out of this arc because it touches glitter and glitter-gl, not because it's hard to see how to do.

The no-op IRender methods: no CSS, no inline styling

AppKit has no CSS-class system and no inline-style property β€” there is no counterpart to gtk_widget_add_css_class or DOM's element.style.color = .... glitter-uikit.appkit's renderer still implements all four IRender methods glitter.core calls for :style/ :class diffing, but each is a genuine no-op:

(set-style [_ _el _k _v] nil)
(remove-style [_ _el _k] nil)
(add-class [_ _el _cn] nil)
(remove-class [_ _el _cn] nil)

Hiccup :style/:class props are still accepted and diffed by glitter.core (which is what calls these methods at all) β€” they're just inert once they arrive here. This is a deliberate v1 boundary, not an unfinished method: building a real equivalent would mean designing a per-widget-type style/attribute system AppKit has no native primitive for, not wiring an existing one the way glitter.gtk's :class support wires GTK's own CSS provider.

on-transition-end is the same story for animation: (f) runs immediately and synchronously rather than after a real transition, since there is no animated mount/unmount support in v1 either.

remove-attribute is a no-op for a different reason

Unlike the four methods above, remove-attribute is wired to real AppKit setters β€” it just never reaches them, because of how apply-props! filters its input:

(remove-attribute [_ el a]
  (w/apply-props! (:tag @el) (ptr el) {(keyword a) nil})
  nil)

w/apply-props! filters on some?, not truthiness, and drops any key whose value is nil before it ever reaches a spec's :apply closure β€” that's what makes an explicit false (:sensitive false, :active false) reach the view correctly while nil never does. So {(keyword a) nil} reduces to {} and the underlying AppKit property is left completely untouched. Setting an attribute to a new value always works; removing it so it reverts to some type default does not β€” AppKit has no generic "unset this property" call the way DOM's removeAttribute does, so there's no default to revert to even if the plumbing reached the widget.

A bare [:box …] renders HORIZONTAL here, VERTICAL under glitter.gtk

glitter-uikit.widget/box-spec constructs its view via a bare (u/stack-new) and only calls stack-orientation! when the caller's props actually contain :orientation:

(defn- box-spec []
  {:ctor  (fn [_] (u/stack-new))
   :apply (fn [w p]
            ...
            (when (contains? p :orientation)
              (u/stack-orientation! w (if (= :vertical (:orientation p))
                                        u/ORIENTATION-VERTICAL
                                        u/ORIENTATION-HORIZONTAL)))
            ...)
   :container :box})

NSStackView's own un-set orientation is horizontal β€” measured directly against a headlessly-constructed view:

(u/stack-orientation (w/create! :box {}))  ;=> 0 (ORIENTATION-HORIZONTAL)

glitter.gtk/box-spec, by contrast, constructs explicitly vertical:

(defn- box-spec []
  {:ctor (fn [p]
           ;; construct vertical by default; the real orientation is set in
           ;; :apply, by which point the box exists and GtkOrientation is
           ;; registered (the box installs the orientation property).
           (g/gtk-box-new 1 (or (:spacing p) 0)))
   ...})

β€” 1 is GTK_ORIENTATION_VERTICAL, and the comment states the choice is deliberate.

The consequence: a glitter view written for GTK using a bare [:box ...] (rather than :hbox/:vbox, which both inject an explicit :orientation via with-orientation regardless of renderer) renders rotated 90Β° under this renderer β€” silently, no error, no warning. :hbox/:vbox are unaffected and portable either way; this project's own counter.clj demo notes in its docstring that it deliberately uses :vbox/:hbox rather than mirroring glitter's own examples/glitter/counter.clj bare :box usage, for exactly this reason.

Why left as-is: matching glitter.gtk's default here would itself be a deviation from the glimmer-uikit source this file was ported from, and would need its own review β€” not a free fix. Recorded as a final-review finding in NOTICE.md's Known gaps, not something this port introduced and missed.

:halign/:valign are stack-wide, not per-child

NSStackView.alignment is a property of the stack, not of an individual arranged subview β€” there is no per-child alignment API to bind to. glitter-uikit.widget records each child's :halign/:valign in a view -> [halign valign] atom at prop-apply time, then derives the parent stack's alignment from whichever child was appended or inserted most recently:

(defn- maybe-align!
  [parent child]
  (when-let [[halign valign] (get @alignments child)]
    (u/stack-alignment! parent (->stack-alignment halign valign (u/stack-orientation parent)))))

append-child!/insert-child-after!/replace-child! all call this after placing a child. So the last child with a :halign/:valign prop in a given stack wins for the whole stack β€” an earlier sibling asking for a different alignment is silently overridden. Every bundled example in this repo only ever sets alignment on the sibling that actually needs it distinguished (e.g. todo.clj's :valign :center on the checkbutton and label of each task row, where all the row's children want the same alignment anyway), so this has not caused a visible bug here β€” but it's a real, unfixed constraint for any layout that wants two differently- aligned children in the same stack.

A vertical :separator renders as nothing

(defn separator-new
  "An NSBox separator β€” a horizontal line. (AppKit has no vertical separator
  primitive; a :vertical :separator renders as nothing in v1.)"
  ...)

NSBox's boxType separator style only draws a horizontal rule; AppKit ships no vertical equivalent widget to fall back to. separator-spec's :apply is itself a no-op ((fn [_ _] nil)), so there is currently no prop that would even let a caller ask for a vertical orientation β€” the gap is in the constructor, not a missing branch in :apply.

:window's :width/:height are read once and never re-applied

(defn- window-spec []
  {:ctor    (fn [p] (u/window-new (:title p) (or (:width p) 400) (or (:height p) 300)))
   :apply   (fn [w p]
              (when (:title p) (u/window-title! w (:title p)))
              (when (false? (:visible p)) (u/window-hide! w)))
   :container :window})

:width/:height are only read in :ctor; :apply never touches them, so a re-render that changes either prop has no effect on an already-created window. Inert in practice, though: glitter-uikit.app/run builds the root NSWindow itself and glitter-uikit.appkit/mount! wraps that pointer directly into the root element atom β€”

(let [root-el (atom {:tag :window :view window :children [] :handlers {}})
      ...])

β€” so create! (and therefore window-spec's :ctor) is never invoked for :window in a running app at all. The gap exists in the spec table for completeness and for any future caller that constructs a :window node directly, not on any path this project's own examples or smokes exercise.

signal-name/signal-value-fn/retain-callable!/release-callable! are deliberately absent

These four symbols exist in glitter.widget for two GTK-specific reasons that have no AppKit counterpart, and their absence here is a design decision recorded in NOTICE.md, not a dropped port:

  • GTK connects a new foreign-callable per widget per signal, so it needs retain/release bookkeeping to keep each one from being collected while still connected β€” hence retain-callable!/release-callable!.
  • GTK's connect/disconnect API is name-keyed (g_signal_connect_data/g_signal_handler_disconnect), so it needs the raw signal-name string β€” hence signal-name/signal-value-fn.

AppKit uses a target/action model instead (see appkit-widget-layer.md for the pointer-keyed registry mechanics this replaces it with): a handful of permanently-retained defonce callbacks β€”

(defonce ^:private fire-cb ...)
(defonce ^:private change-cb ...)
(defonce ^:private quit-cb ...)
(defonce ^:private terminate-cb ...)

β€” shared across every control, plus a pointer-keyed registry (glitter-uikit.widget/actions/changes) that glitter-uikit.appkit owns end to end. There is no per-widget-per-signal callable to retain, no connect API to release from, and no name string to look up: glitter-uikit.appkit selects handlers with a static action-events set (#{:click :toggled :activate}) instead. A later reader comparing the design spec's Architecture section (which lists these four as part of the widget layer's surface) against this code should read this as an intentional absence, not something to "restore."

A Pango :markup attribute value containing a quote crashes

markup->attributed's parse-attrs extracts k='v' pairs with a regex that stops at the next literal quote character:

(defn- parse-attrs [tag]
  (into {}
        (for [[_ k v] (re-seq #"([a-zA-Z_]+)=['\"]([^'\"]*)['\"]" tag)]
          [(keyword k) v])))

But hiccup escapes an embedded quote inside an attribute value to &quot; rather than emitting a literal " β€” confirmed directly by this project's own test suite:

(is (= "<span foreground=\"a&quot;b\">x</span>"
       (w/markup [:span {:foreground "a\"b"} "x"])))

So [:span {:foreground "a\"b"} "x"] reaches color-hex as the literal string "a&quot;b", not "a\"b". color-hex assumes a #rrggbb/#rgb hex string and throws as soon as it hits a non-hex character:

(defn- hex-digit [c]
  (let [n (int c)]
    (cond (<= 48 n 57) (- n 48)
          (<= 97 n 102) (- n 87)
          (<= 65 n 70) (- n 55)
          :else (throw (ex-info (str "glitter-uikit: bad hex digit " c) {})))))

β€” & is not a hex digit, so this throws bad hex digit &. Present upstream and carried forward deliberately rather than fixed opportunistically: the real fix is decoding entities before parse-attrs runs, which is its own scoped task, not a one-line patch to color-hex.

Sizing: :width-chars is not a width, :width-request is

:width-chars / :max-width-chars route to setPreferredMaxLayoutWidth:, which is a text-wrapping hint. It does not stop a control from being compressed. The measured consequences were not subtle: an :entry beside a label in a stack was squeezed to zero width and the field vanished entirely, and where it survived, a ten-character date rendered as 26.08.20.

Four plausible-looking routes were each tried against a live window and each did nothing:

attemptresult
:vexpand false on the containerno effect
:hexpand true on the fieldno effect
:hexpand true on the rowno effect
:halign :fill β†’ NSLayoutAttributeWidthno effect

What works is :width-request, which installs a real NSLayoutConstraint (width == constant) via ffi.clj's set-width!. Use it whenever a control must be a given size. It is applied once per view and guarded, because constraints are cumulative β€” re-adding one on every re-render would stack conflicting constraints on the same view.

:halign :fill is still mapped, since NSLayoutAttributeWidth is the correct attribute for a vertical stack, but it did not fix the narrow-row case and whatever governs that is unresolved. Treat it as available-but-unproven.

Props accepted and ignored on the post-v1 controls

These exist so a glitter view ports across renderers unchanged, but AppKit has no counterpart for them. They are listed rather than silently dropped:

tagignored propswhy
:scale:step, :digits, :draw-valueNSSlider is continuous, draws no value label, and quantises only through tick marks. Use :ticks / :ticks-only instead.
:progress-bar:show-text, :textNSProgressIndicator draws no text. Pair it with a :label.
:spin-button:digitsAn NSStepper is only the arrows β€” unlike GtkSpinButton it has no built-in text field, so pair it with a :label or :entry.
:password-entry:show-peek-iconNo AppKit counterpart.
:search-entry:search-delayNSSearchField sends its action as you type.
:image:pixel-sizeSize it with :width-request or the surrounding layout.

:switch needs macOS 10.15, unlike every other tag

NSSwitch is API_AVAILABLE(macos(10.15)) β€” verified in the SDK header, not assumed. Every other tag works on the project's 10.13 floor. The spec's :ctor throws a named error when the class is absent rather than letting a null class crash inside objc_msgSend with nothing pointing at the cause, so an older system gets a clear message about that one tag instead of an opaque abort.

Two gaps that are about verification, not behavior

The rest of this page is about what the code actually does. These two are about how confidently that's known.

The CI workflow has never been executed

.github/workflows/tests.yml is on: [workflow_dispatch] only β€” no push/pull_request trigger β€” because the project has no GitHub Actions credit budget and nothing should run automatically. That means no run of this workflow has ever completed, and its own comments flag a specific, plausible failure point rather than claiming a clean bill of health: the job checks out this repo, then tries to check out glitter (needed for deps.edn's :local/root "../glitter") using the job's default GITHUB_TOKEN, which GitHub scopes to the triggering repository only. If burinc/glitter is private, that second checkout has no credentials to succeed with, and the workflow file says so directly:

No GitHub Actions run has been performed for this project ... so this has NOT been verified on a real runner. The first manual run may fail at this exact step; that is a disclosed gap, not a surprise.

Nothing downstream of that checkout β€” installing jolt, installing GTK4, running jolt -M:test β€” has ever executed in that environment either, since the workflow would never get that far if the checkout itself fails.

The thunk-queue drain fix has no adversarial-concurrency test

glitter-uikit.app's scheduler (see app-loop-and-threading.md for the full mechanics) fixed a real dropped-callback bug: the original capture-and-clear was a non-atomic (let [jobs @queue] (reset! queue []) ...), so a worker thread's swap! landing between the deref and the reset was silently lost. The fix replaced it with a single CAS-based operation:

(let [[jobs _] (swap-vals! queue empty)]
  (run! (fn [f] (try (f) (catch :default e ...))) jobs))

Two of this project's live smokes exercise this path under real cross-thread concurrency β€” main_thread_smoke.clj posts from inside a future, and repl_live_smoke.clj posts from a second, un-joined future while the main AppKit pump is running β€” and both pass. Neither, though, drives genuine contention on the CAS itself: each has exactly one worker thread posting once, not several threads racing to post at the same instant the main loop's perform callback is mid-drain, which is the specific race swap-vals! was chosen to close. Testing that properly needs multiple threads posting concurrently against a live CFRunLoop actually pumping β€” not something the headless unit suite can set up at all (there's no run loop in jolt -M:test), and not something any current smoke was written to do either.

Porting and attribution

glitter-uikit's source falls into three buckets. NOTICE.md (repo root) is the authoritative, maintained ledger β€” this page explains what the buckets mean and summarizes the deviations; if the two ever disagree, NOTICE.md wins.

Bucket 1: ported from glimmer-uikit

Mechanical namespace-rename port from glimmer-uikit commit 8f1c6a4 (tag v0.1.0), Copyright 2026 Dmitri Sotnikov, published under the jolt-lang organization.

Upstream ships no LICENSE file. Absent a license, default copyright reserves all rights β€” no grant has been made. This section records accurate provenance, not a claimed permission. The same author licenses the sibling glimmer-gl under Apache-2.0, so this appears to be an upstream oversight rather than a deliberate reservation; that is an observation, not a substitute for a license.

Ported files: - src/glitter_uikit/ffi.clj β€” src/glimmer_uikit/ffi.clj - src/glitter_uikit/widget.clj β€” src/glimmer_uikit/widget.clj - src/glitter_uikit/app.clj β€” adapted from src/glimmer_uikit/core.clj (the non-reconciler half: CFRunLoopSource scheduler, run/quit lifecycle) - test/glitter_uikit/widget_test.clj β€” test/glimmer_uikit/widget_test.clj - examples/glitter_uikit/*.clj β€” layouts/scenarios follow examples/glimmer_uikit/*

The port carries three deliberate model adaptations (required because glitter's architecture differs from glimmer's, not because upstream was wrong for glimmer), plus real defect fixes in widget.clj and app.clj:

Model adaptations: 1. Event lifecycle ownership split β€” glitter calls set-event-handler whenever handler data changes, so glitter-uikit.appkit owns the lifecycle end to end. connect-signals! was removed rather than adapted. 2. GlitterTarget class registration β€” renamed from GlimmerTarget to avoid collision if both run in the same process. 3. Prop filtering on some? β€” allowing explicit false to reach views (e.g. :active false, :sensitive false), not treating it as "absent".

Real defect fixes: 4. replace-child! position preservation β€” captures index before removing and re-inserts at the same position (the identical defect glitter fixed on the GTK side). 5. insert-child-after! added, then its own forward-move bug fixed β€” absent upstream entirely; glitter.core's insert-before requires it. The first version incremented a moved child's target index unconditionally, which overshoots by one slot on a forward keyed move (the child currently sits before its target sibling), because AppKit's insert is remove-then-insert with a post-removal index. Fixed in this arc's final review. 6. arranged-index added, guarding every index read β€” upstream did (inc i) on a raw stack-index-of! result, which aborts the process (uncatchably) when the sibling is absent, since NSNotFound is NSIntegerMax. arranged-index returns nil for "absent" instead. 7. forget-view! added, called from remove-child! β€” upstream's actions/changes/alignments registries were never cleaned, an unbounded leak and a stale-handler hazard (AppKit reuses freed addresses). 8. app.clj fixes β€” (a) thunk queue drain made atomic (CAS-based swap-vals!), (b) run* flag ordering (set flags before calling on-activate so they are visible inside it), and (c) on-gui's three-way branch (inline when headless, inline when already on-thread, marshal otherwise β€” not always-marshal like upstream).

Full detail for all deviations: NOTICE.md.

Bucket 2: ported from glitter

src/glitter_uikit/appkit.clj is new code, but its structure follows glitter.gtk closely (the IRender+IMemory single-reify form, the tracking-atom el shape, mount!'s state-atom wiring). Same author as glitter-uikit; listed for provenance.

bb.edn, .clj-kondo/, .lsp/, and scripts/check_positional_args.clj are rename-only adaptations of glitter-gl's copies, which in turn credit glitter and b12n-rljlt β€” see glitter-gl's own NOTICE.md.

Bucket 3: new code (glitter-uikit-specific)

  • NOTICE.md, CONTRIBUTING.md β€” this repository's documentation
  • docs/guide/ β€” architecture and design decision documentation
  • examples/glitter_uikit/counter.clj, todo.clj β€” state models rewritten (one top-level state atom, plain derived values, action data instead of closures)
  • Test suite enhancements β€” eight live-AppKit smokes (keyed reorder, child replacement/insertion, handler lifecycle, main-thread rendering, state-atom reactivity, nREPL live editing) plus two additional unit tests (markup :color alias validation, programmatic-active-does-not-dispatch)

Licensing

glitter-uikit itself is MIT-licensed β€” see LICENSE, Copyright 2026 Burin Choomnuan. That grant covers this project's own code: the AppKit FFI bindings, the widget layer's reshaping, the renderer, the app loop, the examples and the docs.

That is a separate question from the status of the code ported IN, and the two must not be run together. Upstream glimmer-uikit ships no LICENSE file, so absent one, default copyright reserves all rights and no grant has been made for those files. NOTICE.md records that accurately and file by file. This project's MIT grant does not extend to the upstream material it vendors, and nothing here should be read as claiming otherwise.

Testing and tasks

glitter-uikit has two layers of verification: a headless unit suite that runs in a plain jolt -M:test, and eight live-AppKit smokes that need a real GUI session. Both matter for different reasons β€” the unit suite is what CI can actually run, and the live smokes are what catches the class of bug this port shipped several of during its own review: code that looks correct against glitter-uikit.appkit's own :children bookkeeping and is only wrong once it's checked against the real, live AppKit tree.

Unit suite: jolt -M:test / bb test

test/glitter_uikit/test_runner.clj is the entry point (deps.edn's :test alias points -m at it). -main requires five namespaces and runs clojure.test against all of them:

[glitter-uikit.scaffold-test
 glitter-uikit.ffi-test
 glitter-uikit.container-test
 glitter-uikit.widget-test
 glitter-uikit.appkit-test]

Run live: jolt -M:test (or bb test) currently reports 23 tests, 77 assertions, 0 failures, 0 errors.

What each namespace covers:

  • scaffold-test (1 test) β€” proves the project resolves at all: the :local/root "../glitter" dependency is on the classpath and its IRender/IMemory protocol maps have the expected shape (19 IRender methods, 2 IMemory methods). Its own docstring puts it plainly: "If this fails, nothing else in the repo can work."
  • ffi-test (2 tests) β€” pure constant checks against the raw Objective-C/AppKit values (NSWindowStyleMask, NSUserInterfaceLayoutOrientation, NSControlStateValue, NSLayoutPriority ordering, the NSAttributedString attribute-name strings). Deliberately avoids any objc_msgSend call.
  • container-test (8 tests) β€” the child-management fixes carried in from the glimmer-uikit port (replace-child position, insert-after fresh/move/forward-move, NSNotFound-safety, handler cleanup on removal) plus the entry-text-setter's only-when-different guard β€” see appkit-widget-layer.md for the mechanics behind each fix. Its own ns docstring is the reason this namespace is safe in a headless jolt -M:test at all: it "construct[s] real NSStackViews and NSButtons but never run[s] an event loop" β€” real AppKit objects exist and can be manipulated directly through their Objective-C API without [NSApp run] ever starting, so there's no main-loop requirement to fake.
  • widget-test (8 tests) β€” the pure, no-AppKit-needed half of the widget layer: escape-markup, hiccup-to-Pango markup/markup-string rendering (including the :color/:foreground alias glitter-uikit deliberately keeps as a superset of glitter.widget's own vocabulary), with-orientation's :hbox/:vbox injection, and that an explicit false/nil-valued prop survives tag normalization unmolested (the filtering itself happens downstream, in apply-props!).
  • appkit-test (4 tests) β€” the renderer's pure parts: the signal-value table (that :entry's :change reads back the field's live text, that :checkbutton's :toggled reads back a real boolean via control-state, and that register-signal-value! lets an extension add a value-bearing event without editing this namespace), and the shape create-element/create-text-node produce. Its own docstring is explicit about the boundary: "The end-to-end render is covered by the live smokes, which need a GUI session and so cannot guard CI."

Live-AppKit smokes

Eight examples under examples/glitter_uikit/ each open a real AppKit window, exercise one specific behavior, read back the actual live AppKit state β€” never glitter-uikit.appkit's own :children tracking β€” and call (System/exit 1) directly on any mismatch. This "read the real tree, not our bookkeeping" discipline is stated explicitly in more than one smoke's own docstring, e.g. smoke.clj: "bookkeeping would agree with itself and pass even if no AppKit call landed."

taskpinshow it verifies
jolt smokea view renders into a real NSWindow, and a state-atom write re-renders itreads each label's stringValue back through w/stack-children/u/control-string before and after (reset! state {:count 42})
jolt reactivity-smokea programmatic state write re-renders in place; a REAL click through target/action reaches glitter.core's dispatchreads label text before/after a reset!; a real -[NSControl performClick:] call (not a direct handler invocation) drives the actual wiring glitter-uikit.appkit set up, asserting the count incremented, dispatch fired exactly once, and the event map carries :glitter/node
jolt replace-child-smokeIRender/replace-child lands the new view at the OLD child's exact index, not appended at the end (the glimmer-uikit original's bug)a stable "anchor" label placed AFTER the swapped text child is what makes a wrong position observable; reads texts back before/after :txt changes from "first" to "second"
jolt insert-before-smokea fresh keyed child inserts mid-list correctly; an existing child's keyed reorder MOVES rather than duplicates; a FORWARD keyed move lands at the sibling's un-incremented index (this port's own final-review fix, not carried from upstream)reads the live stack's children back after each reset!; the forward-move case moves "1" to sit after "3" in ["1" "2" "3" "4"], expecting ["2" "3" "1" "4"] β€” the buggy pre-fix code produced ["2" "3" "4" "1"]
jolt keyed-smokea keyed reorder lands in the right live order AND reuses the same view pointers (not recreated); the "no suppression needed" claim this renderer is built oncaptures each view's pointer before the reorder (keyed by its text) and confirms the same pointers show up in the new order; separately, a DIFFERENTIAL check β€” three programmatic :active writes on a checkbutton must fire zero dispatches, then a REAL performClick: on that same control must fire exactly one, so a broken/dead fire path can't make the "zero dispatches" result pass vacuously
jolt handler-cleanup-smokeunmounting a subtree drops every handler registration it held, including grandchildrenthe button lives ONE LEVEL DOWN inside a conditionally-rendered :hbox, not at the removed node itself, so a non-recursive remove-child! would leave its registration behind; counts @w/actions before and after :show? flips to false
jolt main-thread-smokea state write from a NON-main thread still renders ON the AppKit main thread β€” the property the CFRunLoopSource scheduler exists for (see app-loop-and-threading.md)records WHICH thread view last ran on (not merely whether the label text changed β€” an unmarshalled render would still update the label, just from the wrong thread, so a text-only check would pass with the bug present); also asserts the worker thread really was a different thread, and that the scheduled read-back callback itself ran, so the smoke can't pass vacuously if future ran inline or if the scheduler silently never drained
jolt repl-live-smokethe same cross-thread marshalling property as main-thread-smoke, shaped like a live nREPL dev sessionone thread runs the AppKit app + pump (standing in for the nREPL session's primordial thread); a second, un-joined thread mutates state after a delay (standing in for an nREPL eval on its own worker thread); confirmed two ways β€” no crash (an unmarshalled render touching AppKit off-main-thread aborts the process outright) and the worker's mutation is actually reflected in a render once the loop quits

Interactive demos

jolt counter, jolt widgets, jolt temperature, jolt flights, jolt timer, jolt crud and jolt todo are the seven demos meant to be run and clicked, not asserted on β€” counter.clj is the canonical Replicant-style counter (data-driven :on {:click [[:action/dec]]} dispatch, all state in one top-level atom), and todo.clj is a larger task-board demo built on glitter.nexus (action-expansions, an :entry's :change/:activate pair, checkbutton toggles inside a :frame), while widgets.clj is the visual index of the widget layer β€” the only example that exercises :separator and :scrolled, and temperature.clj is the 7GUIs Temperature Converter ported from glitter. All four block on their window until closed, which is exactly why bb smokes (below) excludes them β€” an aggregate task that includes an interactive demo would hang forever waiting for a window close that never comes in an automated context.

Exit discipline: direct System/exit, never a resolve-guard

Both test_runner.clj and every live smoke call (System/exit 1) directly on failure, never through a resolve-guarded indirection. test_runner.clj's own comment states why:

;; Call System/exit DIRECTLY. `System/exit` is a static-method interop FORM,
;; not a var, so `(resolve 'System/exit)` is ALWAYS nil β€” under Jolt and on
;; the JVM alike. A cond guarded on that resolve never fires and silently
;; falls through to nil, so the suite prints its failures and still exits 0.

repl_live_smoke.clj's docstring makes the provenance explicit: upstream's own smoke guards its exit with (let [exit (resolve 'jolt.host/exit)] (when exit (exit 1))), which means upstream's smoke prints a failure message and still exits 0 β€” unable to ever fail CI. This file (and every smoke in this project) was written to call System/exit directly instead, specifically to avoid that trap.

Running things: jolt -M:<alias> and bb task surfaces

Every runnable has its own deps.edn alias with :main-opts, and bb.edn wraps each one as a babashka task shelling straight to jolt -M:<alias> β€” never the jolt <task> shorthand, which (per the sibling glitter project's own verified finding against jolt v0.6.3, noted directly in bb.edn's header comment) does not propagate its child process's exit status. bb info is the discoverability entry point β€” a grouped cheat-sheet, easier to scan than the flat bb tasks listing:

bb info                    grouped task cheat-sheet (start here)
bb test                    jolt -M:test β€” the unit suite
bb verify                  lint (report) + test (must pass) β€” pre-commit gate
bb counter                 interactive demo (opens a real window)
bb todo                    interactive demo (opens a real window)
bb smoke                   live-AppKit smoke: mount, reconcile, quit
bb reactivity-smoke        live-AppKit smoke: state-atom watch + real click
bb replace-child-smoke     live-AppKit smoke: replace-child position
bb insert-before-smoke     live-AppKit smoke: insert/reorder ordering
bb keyed-smoke             live-AppKit smoke: keyed reorder + no-suppression
bb handler-cleanup-smoke   live-AppKit smoke: handler registry cleanup
bb main-thread-smoke       live-AppKit smoke: on-gui's 3-way thread branch
bb repl-live-smoke         live-AppKit smoke: nREPL-driven live mount/update
bb smokes                  all eight smokes above in sequence; stops at first failure
bb lint                    clj-kondo, report only
bb lint:strict              clj-kondo, propagates the real exit code
bb lsp:format               clojure-lsp reformat (mutating)
bb lsp:format-check         clojure-lsp reformat, dry run
bb lsp:clean-ns             clojure-lsp ns cleanup (mutating)
bb lsp:clean-ns-check       clojure-lsp ns cleanup, dry run
bb check:positional-args    fns with 3+ positional args, report only
bb check:positional-args:strict   same check, exits non-zero if any found
bb hooks:install            install the FAST git pre-commit hook
bb hooks:install:full       install the FULL git pre-commit hook (+ tests)
bb hooks:uninstall          remove the git pre-commit hook

Every task name above was cross-checked against a live bb tasks run β€” none are aspirational. bb smokes chains all eight non-interactive live smokes with a plain sequence of shell calls; babashka's task runner aborts a do block at the first non-zero exit, so it naturally stops at the first failure with no extra control flow needed. counter and todo are deliberately absent from bb smokes for the reason given above β€” they block on a window and would hang an automated run.

Quality gates: lint, format, positional-args

bb lint            clj-kondo over src/test/examples (report only, always exits 0)
bb lint:strict      same lint, but propagates clj-kondo's real exit code
bb lsp:format / lsp:format-check   clojure-lsp reformat, or a dry-run check
bb lsp:clean-ns / lsp:clean-ns-check   clojure-lsp ns cleanup, or a dry-run check
bb verify           pre-commit gate: lint (report only) + jolt -M:test (must pass)
bb check:positional-args / :strict   fns with 3+ positional args (report | gate)

Live-run results as of this page: bb lint reports errors: 0, warnings: 0, and bb lsp:format-check reports Nothing to format! β€” both clean, unlike glitter's own project (which carries two accepted warnings for style/dead-code reasons documented in its own guide).

bb verify gates only on jolt -M:test. Its clj-kondo step runs under {:continue true}, so a lint finding never fails the gate β€” only a test failure does. This mirrors bb lint vs bb lint:strict: the plain form is always a report, the :strict form is what actually propagates a non-zero exit.

check:positional-args is report-only and wired into no gate at all. Running it finds 20 functions across src/glitter&#95;uikit/{app, appkit,ffi,widget}.clj with 3+ positional args β€” app.clj contributes 1 (run), appkit.clj 3 (register-signal-value!, dispatcher, mount!), ffi.clj 8 (mostly raw AppKit-call wrappers like window-new/constraint/stack-edge-insets!), and widget.clj 8 (container-management fns like append-child!/insert-child-after!/ replace-child!/reorder-child!, whose argument order mirrors glitter.widget's own equivalents). Confirmed live: bb check:positional-args exits 0 regardless of how many findings it reports (the :strict variant is what would actually fail on them, and neither bb verify nor either git hook below calls even that variant). The script's own exceptions set is deliberately left empty rather than pre-populated with all 20 β€” its comment states this directly: curating which of the 20 are "legitimately-positional-forever" (native-API mirrors, protocol-shaped signatures) versus genuinely refactorable "is a src/-level design judgment out of this tooling/CI task's scope β€” left for a dedicated follow-up rather than decided here."

Git hooks: bb hooks:install / :install:full / :uninstall

bb hooks:install writes an executable .git/hooks/pre-commit (via spit, not itself tracked in the repo β€” each clone opts in with its own run). The FAST hook runs three steps: clj-kondo --lint src test examples gated on its exit code being exactly 3 (errors present, not just warnings), then clojure-lsp format --dry, then clojure-lsp clean-ns --dry. bb hooks:install:full adds a fourth step β€” the full jolt -M:test suite, safe to run in a hook since the unit suite is entirely headless. bb hooks:uninstall deletes the hook file (idempotent β€” reports "no pre-commit hook found" on a second run rather than erroring). git commit --no-verify skips the hook for one commit. Neither hook calls check:positional-args in any form.

An AppKit (native macOS) renderer for glitter: the same Replicant-style state atom and data-driven events, driving real NSView widgets instead of GTK4.