WebKit Applications - macOS Only

In this chapter we build native macOS desktop applications using Common Lisp and WebKit. The webkit-cl library lets you create windows with embedded WKWebView panels, load HTML/CSS/JavaScript UIs, and communicate between Lisp and JavaScript through a bidirectional bridge. This approach gives you the full power of Common Lisp for application logic while using modern web technologies for the user interface.

Note 1: This library works only on macOS. It requires SBCL, CFFI, and cl-json.

Note 2: This example was vibe coded with AntiGravity and Claude Opus 4.6.

Architecture Overview

The webkit-cl framework is organized in four layers:

  1. Objective-C shim (webkit_cl.m): Bridges macOS Cocoa and WebKit APIs to a flat C interface
  2. CFFI bindings (webkit-cl-ffi.lisp): Exposes the C functions to Common Lisp
  3. Bridge (bridge.lisp): Manages JS <—> Lisp command dispatch and JSON serialization
  4. High-level API (webkit-cl.lisp): Idiomatic Lisp functions: with-app, load-html, register-handler, etc.

When JavaScript calls window.webkit_cl.invoke("command", payload), the message travels through WKWebView’s script message handler into the C shim, through CFFI into Lisp, where a registered handler processes it and returns a JSON response. The response flows back to JavaScript via a Promise.

Prerequisites and Building

You need macOS (Apple Silicon or Intel), SBCL, and Quicklisp with cffi and cl-json installed. Build the native library with:

1 cd src/webkit-cl
2 make

This compiles the Objective-C shim into libwebkit_cl.dylib:

1 clang -fobjc-arc -fPIC -O2 -Wall -framework Cocoa -framework WebKit \
2   -dynamiclib -install_name @rpath/libwebkit_cl.dylib \
3   -o libwebkit_cl.dylib webkit_cl.m

Project Structure

The ASDF system definition ties the components together:

 1 ;;; webkit-cl.asd — ASDF system definition for webkit-cl
 2 
 3 (asdf:defsystem #:webkit-cl
 4   :description "Lightweight WebKit GUI apps for Common Lisp (macOS)"
 5   :author "Mark Watson"
 6   :license "Apache-2.0"
 7   :version "0.1.0"
 8   :depends-on (#:cffi #:cl-json)
 9   :serial t
10   :components ((:file "package")
11                (:file "webkit-cl-ffi")
12                (:file "bridge")
13                (:file "webkit-cl")))

The package exports the public API:

 1 (defpackage #:webkit-cl
 2   (:use #:cl)
 3   (:export
 4    ;; App lifecycle
 5    #:with-app
 6    #:make-app
 7    #:app-run
 8    #:app-quit
 9    #:app-destroy
10    ;; Content loading
11    #:load-html
12    #:load-url
13    #:load-file
14    ;; JavaScript evaluation
15    #:eval-js
16    ;; Bridge
17    #:register-handler
18    #:unregister-handler
19    ;; Window management
20    #:set-title
21    #:set-size
22    #:set-resizable
23    ;; App accessors
24    #:app-handle
25    #:*current-app*))

The C Shim

The C header (webkit_cl.h) defines a minimal interface. All functions take an opaque wkcl_app_t handle:

 1 typedef void* wkcl_app_t;
 2 
 3 /* Callback for bridge invocations from JavaScript */
 4 typedef const char* (*wkcl_bridge_callback_t)(const char* command,
 5                                               const char* payload,
 6                                               void* userdata);
 7 
 8 /* Lifecycle */
 9 wkcl_app_t wkcl_create(const char* title, int width, int height);
10 void wkcl_run(wkcl_app_t app);
11 void wkcl_quit(wkcl_app_t app);
12 void wkcl_destroy(wkcl_app_t app);
13 
14 /* Content loading */
15 void wkcl_load_html(wkcl_app_t app, const char* html);
16 void wkcl_load_url(wkcl_app_t app, const char* url);
17 void wkcl_load_file(wkcl_app_t app, const char* path);
18 
19 /* JavaScript & Bridge */
20 void wkcl_eval_js(wkcl_app_t app, const char* js);
21 void wkcl_set_bridge_callback(wkcl_app_t app,
22                                wkcl_bridge_callback_t callback,
23                                void* userdata);
24 
25 /* Window management */
26 void wkcl_set_title(wkcl_app_t app, const char* title);
27 void wkcl_set_size(wkcl_app_t app, int width, int height);
28 void wkcl_set_resizable(wkcl_app_t app, int resizable);

The Objective-C implementation (webkit_cl.m) creates an NSApplication with a WKWebView inside an NSWindow. The bridge works by injecting a JavaScript snippet at document start that defines window.webkit_cl.invoke(). This function posts messages to a WKScriptMessageHandler, which routes them to the registered C callback. The callback returns a malloc’d JSON string that is sent back to JavaScript via evaluateJavaScript:.

Here is the bridge JavaScript that gets injected into every page:

 1 window.webkit_cl = {
 2     _callbackId: 0,
 3     _callbacks: {},
 4     invoke: function(command, payload) {
 5         return new Promise(function(resolve, reject) {
 6             var id = String(++window.webkit_cl._callbackId);
 7             window.webkit_cl._callbacks[id] = {
 8                 resolve: resolve, reject: reject
 9             };
10             var msg = {
11                 command: command,
12                 payload: JSON.stringify(payload || {}),
13                 callbackId: id
14             };
15             window.webkit.messageHandlers.wkcl_bridge
16                 .postMessage(msg);
17         });
18     },
19     _resolveCallback: function(id, result) {
20         var cb = window.webkit_cl._callbacks[id];
21         if (cb) {
22             cb.resolve(result);
23             delete window.webkit_cl._callbacks[id];
24         }
25     }
26 };

Each invoke() call returns a Promise. The Objective-C handler calls the C callback, gets a JSON result, and resolves the Promise by evaluating window.webkit_cl._resolveCallback(id, result).

CFFI Bindings

The FFI layer maps the C API to Common Lisp. The library is loaded from the same directory as the source files:

 1 (cffi:define-foreign-library libwebkit-cl
 2   (:darwin (:or "libwebkit_cl.dylib"
 3                 (:default "libwebkit_cl")))
 4   (t (:default "libwebkit_cl")))
 5 
 6 (defun load-native-library ()
 7   "Load the webkit-cl native library."
 8   (let* ((this-file (or *compile-file-pathname* *load-pathname*))
 9          (lib-dir (when this-file
10                     (namestring
11                      (make-pathname
12                       :directory (pathname-directory this-file))))))
13     (when lib-dir
14       (pushnew (pathname lib-dir)
15                cffi:*foreign-library-directories*
16                :test #'equal))
17     (cffi:use-foreign-library libwebkit-cl)))
18 
19 (load-native-library)

The bridge callback is defined with cffi:defcallback. It dispatches to the Lisp-side handler registry:

1 (cffi:defcallback bridge-dispatch :pointer
2     ((command :string) (payload :string) (userdata :pointer))
3   "Master bridge callback that dispatches to registered Lisp handlers."
4   (declare (ignore userdata))
5   (let ((result (dispatch-bridge-command command payload)))
6     (if result
7         (cffi:foreign-string-alloc result)
8         (cffi:null-pointer))))

Note that the callback returns a cffi:foreign-string-alloc’d pointer — the C side will free() it after use.

The Bridge: JS <—> Lisp Communication

The bridge module maintains a hash table of named command handlers:

 1 (defvar *bridge-handlers* (make-hash-table :test 'equal)
 2   "Hash table mapping command names (strings) to handler functions.")
 3 
 4 (defun register-handler (command handler-fn)
 5   "Register a bridge handler for COMMAND.
 6    HANDLER-FN takes one argument: the parsed payload (alist from cl-json).
 7    It should return a JSON string to send back to JavaScript."
 8   (setf (gethash command *bridge-handlers*) handler-fn)
 9   command)
10 
11 (defun unregister-handler (command)
12   "Remove the bridge handler for COMMAND."
13   (remhash command *bridge-handlers*)
14   command)

When JavaScript calls window.webkit_cl.invoke("greet", {name: "World"}), the dispatch function looks up the handler by command name, parses the JSON payload with cl-json, calls the handler, and returns the result:

 1 (defun dispatch-bridge-command (command payload-json)
 2   "Dispatch a bridge command to the registered handler."
 3   (let ((handler (gethash command *bridge-handlers*)))
 4     (if handler
 5         (handler-case
 6             (let* ((payload (handler-case
 7                                 (json:decode-json-from-string payload-json)
 8                               (error () nil)))
 9                    (result (funcall handler payload)))
10               (if result result "null"))
11           (error (e)
12             (format nil "{\"error\": \"~a\"}"
13                     (substitute-json-chars (format nil "~a" e)))))
14         (format nil "{\"error\": \"unknown command: ~a\"}"
15                 (substitute-json-chars command)))))

The double handler-case nesting is deliberate: one catches JSON parse errors (which are non-fatal — the handler receives nil), the other catches handler execution errors and returns them as JSON error objects to JavaScript.

High-Level API

The main API provides a struct-based app object and a convenience macro:

1 (defvar *current-app* nil
2   "The currently active webkit-cl app handle.")
3 
4 (defstruct (app (:constructor %make-app-internal))
5   "A webkit-cl application instance."
6   (handle (cffi:null-pointer) :type cffi:foreign-pointer)
7   (title "webkit-cl" :type string)
8   (width 800 :type integer)
9   (height 600 :type integer))

The make-app function creates the native window and installs the bridge callback:

 1 (defun make-app (&key (title "webkit-cl") (width 800) (height 600))
 2   "Create a new webkit-cl application (does not start the event loop)."
 3   (%disable-fp-traps)
 4   (let* ((handle (%wkcl-create title width height))
 5          (app (%make-app-internal :handle handle
 6                                    :title title
 7                                    :width width
 8                                    :height height)))
 9     (%wkcl-set-bridge-callback handle
10                                 (cffi:callback bridge-dispatch)
11                                 (cffi:null-pointer))
12     app))

The %disable-fp-traps call is essential — macOS Cocoa/AppKit triggers floating-point operations that conflict with SBCL’s default trap settings.

The with-app macro is the recommended entry point. It creates the app, executes the body (where you register handlers and load content), runs the event loop, and cleans up:

 1 (defmacro with-app ((&key (title "webkit-cl") (width 800) (height 600))
 2                     &body body)
 3   "Create a webkit-cl app, execute BODY, then run the event loop.
 4    The app is automatically destroyed when the window is closed."
 5   (let ((app-var (gensym "APP")))
 6     `(let* ((,app-var (make-app :title ,title
 7                                 :width ,width :height ,height))
 8             (*current-app* ,app-var))
 9        (unwind-protect
10             (progn ,@body
11                    (app-run ,app-var))
12          (app-destroy ,app-var)))))

Content loading and JavaScript evaluation are thin wrappers around the C API:

 1 (defun load-html (html &optional (app *current-app*))
 2   "Load inline HTML content into the WebView."
 3   (when app
 4     (%wkcl-load-html (app-handle app) html)))
 5 
 6 (defun load-url (url &optional (app *current-app*))
 7   "Navigate the WebView to a URL."
 8   (when app
 9     (%wkcl-load-url (app-handle app) url)))
10 
11 (defun load-file (path &optional (app *current-app*))
12   "Load a local HTML file into the WebView."
13   (when app
14     (%wkcl-load-file (app-handle app) (namestring (truename path)))))
15 
16 (defun eval-js (js &optional (app *current-app*))
17   "Evaluate JavaScript in the WebView (fire-and-forget)."
18   (when app
19     (%wkcl-eval-js (app-handle app) js)))

Example 1: Hello World

The simplest webkit-cl app loads inline HTML into a native window:

 1 ;;; hello-world.lisp — Minimal webkit-cl example
 2 ;;;
 3 ;;; Displays a styled HTML page in a native macOS window.
 4 ;;; This is the simplest possible webkit-cl app.
 5 
 6 ;; Load the system
 7 (require :asdf)
 8 ;; Register the directory that holds webkit-cl.asd — the parent of examples/.
 9 ;; *load-truename* is absolute, so this works from any current directory.
10 (push (make-pathname
11        :directory (butlast (pathname-directory
12                             (or *load-truename* *load-pathname*))))
13       asdf:*central-registry*)
14 (asdf:load-system :webkit-cl)
15 
16 (webkit-cl:with-app (:title "Hello webkit-cl" :width 600 :height 400)
17   (webkit-cl:load-html
18    "<!DOCTYPE html>
19 <html>
20 <head>
21 <meta charset='utf-8'>
22 <style>
23   * { margin: 0; padding: 0; box-sizing: border-box; }
24   body {
25     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
26     background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
27     color: #e0e0e0;
28     display: flex;
29     align-items: center;
30     justify-content: center;
31     height: 100vh;
32     overflow: hidden;
33   }
34   .card {
35     text-align: center;
36     background: rgba(255,255,255,0.05);
37     backdrop-filter: blur(20px);
38     -webkit-backdrop-filter: blur(20px);
39     border: 1px solid rgba(255,255,255,0.1);
40     border-radius: 24px;
41     padding: 48px 64px;
42     box-shadow: 0 8px 32px rgba(0,0,0,0.3);
43     animation: fadeIn 0.8s ease-out;
44   }
45   @keyframes fadeIn {
46     from { opacity: 0; transform: translateY(20px) scale(0.95); }
47     to   { opacity: 1; transform: translateY(0) scale(1); }
48   }
49   h1 {
50     font-size: 2.5em;
51     font-weight: 700;
52     background: linear-gradient(90deg, #a78bfa, #60a5fa, #34d399);
53     -webkit-background-clip: text;
54     -webkit-text-fill-color: transparent;
55     margin-bottom: 12px;
56   }
57   p {
58     font-size: 1.1em;
59     color: rgba(255,255,255,0.6);
60     line-height: 1.6;
61   }
62   .badge {
63     display: inline-block;
64     margin-top: 20px;
65     padding: 6px 16px;
66     font-size: 0.85em;
67     background: rgba(167,139,250,0.15);
68     border: 1px solid rgba(167,139,250,0.3);
69     border-radius: 999px;
70     color: #a78bfa;
71   }
72 </style>
73 </head>
74 <body>
75   <div class='card'>
76     <h1>Hello, webkit-cl!</h1>
77     <p>A native macOS window powered by Common Lisp<br>
78        and WebKit (WKWebView).</p>
79     <span class='badge'>SBCL + Cocoa + WebKit</span>
80   </div>
81 </body>
82 </html>"))

Run it with:

1 sbcl --load examples/hello-world.lisp

A native macOS window appears with a gradient background, glassmorphism card, and gradient text — all rendered by the system WebKit engine.

Example 2: Counter App with Bridge

This example demonstrates bidirectional communication. Lisp manages the application state (a counter), and JavaScript provides the UI:

 1 (defvar *counter* 0)
 2 
 3 ;;; ── Bridge Handlers ────────────────────────────────────────────
 4 
 5 (webkit-cl:register-handler "increment"
 6   (lambda (payload)
 7     (declare (ignore payload))
 8     (incf *counter*)
 9     (format nil "{\"count\": ~d}" *counter*)))
10 
11 (webkit-cl:register-handler "decrement"
12   (lambda (payload)
13     (declare (ignore payload))
14     (decf *counter*)
15     (format nil "{\"count\": ~d}" *counter*)))
16 
17 (webkit-cl:register-handler "reset"
18   (lambda (payload)
19     (declare (ignore payload))
20     (setf *counter* 0)
21     (format nil "{\"count\": ~d}" *counter*)))
22 
23 (webkit-cl:register-handler "get-count"
24   (lambda (payload)
25     (declare (ignore payload))
26     (format nil "{\"count\": ~d}" *counter*)))
27 
28 (webkit-cl:register-handler "get-system-info"
29   (lambda (payload)
30     (declare (ignore payload))
31     (format nil "{\"lisp\": \"~a\", \"version\": \"~a\", \"machine\": \"~a\"}"
32             (lisp-implementation-type)
33             (lisp-implementation-version)
34             (machine-type))))

Each handler receives a parsed JSON payload (an alist from cl-json) and returns a JSON string. The JavaScript side calls these handlers through the bridge:

 1 const display = document.getElementById('counter');
 2 const info = document.getElementById('info');
 3 
 4 function updateDisplay(count) {
 5   display.textContent = count;
 6   display.classList.add('bump');
 7   setTimeout(() => display.classList.remove('bump'), 150);
 8 }
 9 
10 async function increment() {
11   const result = await window.webkit_cl.invoke('increment', {});
12   updateDisplay(result.count);
13 }
14 
15 async function decrement() {
16   const result = await window.webkit_cl.invoke('decrement', {});
17   updateDisplay(result.count);
18 }
19 
20 async function reset() {
21   const result = await window.webkit_cl.invoke('reset', {});
22   updateDisplay(result.count);
23 }
24 
25 // Load system info on startup
26 setTimeout(async () => {
27   try {
28     const sysInfo = await window.webkit_cl.invoke('get-system-info', {});
29     info.innerHTML =
30       'Powered by <span>' + sysInfo.lisp + ' ' + sysInfo.version + '</span>' +
31       '<br>Architecture: <span>' + sysInfo.machine + '</span>';
32   } catch(e) {
33     info.textContent = 'JS ↔ Lisp bridge active';
34   }
35 }, 500);
36   

The counter value lives entirely in Lisp — JavaScript only renders it. This pattern cleanly separates application logic (Lisp) from presentation (HTML/CSS/JS).

Run it with:

1 sbcl --load examples/counter-app.lisp

Example 3: Markdown File Viewer

The most complete example demonstrates filesystem access through the bridge. Two handlers let JavaScript list and read files:

 1 (defun read-text-file (path)
 2   "Return the entire contents of PATH as a string, decoded as UTF-8.
 3 
 4    FILE-LENGTH cannot be used to size the buffer: for a character stream it
 5    reports the length in *bytes*, so a file containing multi-byte characters
 6    would produce a string padded with NULs. Reading in chunks until EOF is
 7    correct regardless of how many bytes each character occupies."
 8   (with-open-file (stream path
 9                           :direction :input
10                           :element-type 'character
11                           :external-format :utf-8)
12     (with-output-to-string (out)
13       (let ((buffer (make-string 8192)))
14         (loop for count = (read-sequence buffer stream)
15               while (plusp count)
16               do (write-string buffer out :end count))))))
17 
18 (webkit-cl:register-handler "read-file"
19   (lambda (payload)
20     (let ((path (cdr (assoc :path payload))))
21       (if (and (stringp path) (probe-file path))
22           (format nil "{\"content\": ~a, \"path\": ~a}"
23                   (json:encode-json-to-string (read-text-file path))
24                   (json:encode-json-to-string path))
25           ;; Encode the message with cl-json so a path containing a quote
26           ;; or backslash cannot produce malformed JSON.
27           (format nil "{\"error\": ~a}"
28                   (json:encode-json-to-string
29                    (format nil "File not found: ~a" (or path "nil"))))))))
30 
31 (webkit-cl:register-handler "list-files"
32   (lambda (payload)
33     (let* ((dir (or (cdr (assoc :directory payload)) "."))
34            (pattern (merge-pathnames "*.md" (pathname dir)))
35            (files (directory pattern)))
36       (format nil "{\"files\": [~{~a~^, ~}]}"
37               (mapcar (lambda (f)
38                         (json:encode-json-to-string (namestring f)))
39                       files)))))

Reading a file correctly takes a little care. The obvious idiom — size a string with file-length and fill it with read-sequence — is wrong for text: file-length reports the length of a character stream in bytes, so any file containing multi-byte characters yields a string padded with NULs. Reading in chunks until read-sequence returns zero works for every file, whatever the byte-to-character ratio, which is why read-text-file does it that way. The :external-format :utf-8 argument makes the decoding explicit.

Both handlers then hand their results to json:encode-json-to-string rather than interpolating them with ~a. That matters for the file contents and for the error message: a path containing a double quote or a backslash would otherwise produce malformed JSON and the Promise on the JavaScript side would reject. The list-files handler uses directory with a wildcard pattern and the format directive ~{~a~^, ~} to build a JSON array from the results.

The UI is a split-pane layout with a file sidebar and content area. JavaScript calls the bridge on startup. Note that every value that reaches innerHTML — file names, paths, file contents, error text — is passed through escapeHtml first, and single quotes in a file name are escaped before being embedded in the onclick attribute:

 1 const fileList = document.getElementById('file-list');
 2 const content = document.getElementById('content');
 3 
 4 function escapeHtml(str) {
 5   return str.replace(/&/g, '&amp;')
 6             .replace(/</g, '&lt;')
 7             .replace(/>/g, '&gt;');
 8 }
 9 
10 function basename(path) {
11   return path.split('/').pop();
12 }
13 
14 async function loadFileList() {
15   try {
16     const result = await window.webkit_cl.invoke('list-files', { directory: '.' });
17     if (result.files && result.files.length > 0) {
18       fileList.innerHTML = result.files.map(f =>
19         '<div class="file-item" onclick="loadFile(\'' + f.replace(/'/g, '\\\'') + '\')">' +
20         '<div class="name">' + escapeHtml(basename(f)) + '</div>' +
21         '<div class="path">' + escapeHtml(f) + '</div>' +
22         '</div>'
23       ).join('');
24     } else {
25       fileList.innerHTML =
26         '<div class="empty-state" style="height:200px;font-size:0.85rem;">' +
27         'No .md files found</div>';
28     }
29   } catch(e) {
30     fileList.innerHTML =
31       '<div class="empty-state" style="height:200px;font-size:0.85rem;">' +
32       'Error: ' + e + '</div>';
33   }
34 }
35 
36 async function loadFile(path) {
37   // Highlight active item
38   document.querySelectorAll('.file-item').forEach(el => {
39     el.classList.remove('active');
40     if (el.querySelector('.path').textContent === path) {
41       el.classList.add('active');
42     }
43   });
44 
45   try {
46     const result = await window.webkit_cl.invoke('read-file', { path: path });
47     if (result.error) {
48       content.innerHTML = '<div class="empty-state">' + escapeHtml(result.error) + '</div>';
49     } else {
50       content.innerHTML = '<pre>' + escapeHtml(result.content) + '</pre>';
51     }
52   } catch(e) {
53     content.innerHTML = '<div class="empty-state">Error loading file</div>';
54   }
55 }
56 
57 // Load file list on startup
58 setTimeout(loadFileList, 300);
59   

Run it with:

1 sbcl --load examples/markdown-viewer.lisp

This opens a native window with a dark sidebar listing .md files from the current directory. Clicking a file reads its content via the Lisp bridge and displays it in a styled code panel. Try it on a file containing accented letters, em dashes or CJK text — the UTF-8 path is exercised by any of them.

API Reference Summary

The webkit-cl public API:

App lifecycle

Function Description
(with-app (&key title width height) &body body) Create app, run body, enter event loop, auto-cleanup
(make-app &key title width height) Create the window and install the bridge callback; does not run
(app-run app) Show the window and enter the event loop (blocks)
(app-quit &optional app) Request the application to quit
(app-destroy app) Destroy the app and free native resources

Content loading

Function Description
(load-html html &optional app) Load inline HTML string
(load-url url &optional app) Navigate to a URL
(load-file path &optional app) Load a local HTML file

JavaScript

Function Description
(eval-js js-string &optional app) Evaluate JavaScript (fire-and-forget)

Bridge

Function Description
(register-handler name fn) Register a bridge command handler
(unregister-handler name) Remove a bridge command handler

Window management

Function Description
(set-title title &optional app) Change window title
(set-size width height &optional app) Resize window
(set-resizable flag &optional app) Toggle window resizability

App accessors

Name Description
*current-app* The app bound for the duration of app-run and with-app
(app-handle app) The underlying wkcl_app_t foreign pointer
(app-title app), (app-width app), (app-height app) The struct slots

From JavaScript, call Lisp handlers with:

1 const result = await window.webkit_cl.invoke("command-name", {key: "value"});

Key Takeaways

  1. CFFI + Objective-C — Common Lisp can drive native macOS frameworks through a thin C shim compiled as a dynamic library
  2. WKWebView — The system WebKit engine provides a modern, full-featured rendering surface without bundling a browser
  3. Bridge pattern — Named command handlers with JSON message passing cleanly separate Lisp logic from JavaScript UI
  4. unwind-protect — The with-app macro ensures native resources are freed even if an error occurs
  5. Floating-point traps — SBCL’s default FP trap settings conflict with Cocoa; %disable-fp-traps is essential

This framework demonstrates that Common Lisp can build polished desktop applications. The web rendering layer handles the visual complexity while Lisp provides the computational backbone — a productive division of labor for tools, dashboards, and data viewers.

Optional Practice Problems

  1. Window Position and Center Helper: The current window management API in webkit-cl.lisp supports setting window size and title, but not positioning. Add foreign function declarations for setting window positions (wkcl_set_position or similar in the C shim) and export them in package.lisp and webkit-cl-ffi.lisp. Implement a Lisp-side function center-window that centers the app window on the screen using system screen width and height information.

  2. JavaScript Evaluation with Callback/Promise (Bidirectional eval-js): The eval-js function in webkit-cl.lisp is “fire-and-forget” because it calls the underlying C function without returning the evaluation results back to Lisp. Design an asynchronous evaluation function eval-js-async that takes a JS expression and a callback. Trigger a special bridge handler in Lisp containing the evaluation result when the JavaScript execution resolves, linking it to the original call.

  3. Asynchronous Multi-Threading File Reader: In the markdown viewer example in markdown-viewer.lisp, the Lisp bridge handler read-file reads file contents synchronously. If a file is very large, reading it synchronously blocks the Cocoa event loop, making the window temporarily unresponsive. Refactor this handler using SBCL threads (sb-thread:make-thread) to read the file asynchronously, and use eval-js to push the completed content back to the JavaScript frontend when finished.

  4. Bi-directional State Synchronization with JSON-diff: Write a state synchronization helper in bridge.lisp. Rather than registering manually separate handlers like increment, decrement, and reset (as in counter-app.lisp), design a state synchronization pattern. Store the application state as a nested alist in Lisp, and write a synchronization macro that broadcasts state diffs to the JavaScript frontend automatically whenever the state changes.

  5. Error Reporting with Stack Traces in the JS Console: The current command dispatch in dispatch-bridge-command (in bridge.lisp) catches Lisp errors and returns a simple JSON error string. If an error occurs deep in Lisp, the developer loses the stack trace. Enhance dispatch-bridge-command to capture the full SBCL stack trace using sb-debug:backtrace and format it as a formatted JSON error object. Inject this stack trace into the WebKit inspector console via a console.error call using eval-js.

  6. Custom URL Scheme Interceptor: Currently, loading local files relies on the C-level file loader (wkcl_load_file), which requires resolving absolute local paths. Implement a custom URL scheme handler (such as lisp-resource://) inside the native Objective-C shim and bind it via CFFI. This allows the WebKit browser instance to request files (e.g., <img src="lisp-resource://images/logo.png">) by intercepting these requests inside Common Lisp and streaming the binary content back directly from Lisp’s memory or an in-memory assets alist.