Files
cl-tty/org/theme.org
Hermes Agent ce7e9fbab0 literate: create org/render.org, org/theme.org, org/package.org
Follows the literate programming workflow:
  Overview → Contract → Tests → Implement → Tangle → Test (GREEN)

render.org covers render.lisp + render-tests.lisp (component protocol,
render dispatch, dirty propagation)
theme.org covers theme.lisp + theme-tests.lisp (theme class, presets,
color resolution)
package.org covers package.lisp (cl-tty.box defpackage)
2026-05-12 17:05:47 +00:00

10 KiB

Theme Engine

Overview

The theme engine provides semantic color tokens that decouple visual design from implementation code. Instead of writing :bright-yellow or \"#FFD700\" everywhere, components use :accent, :error, :background — semantic roles that resolve to concrete hex values through the current theme.

This means:

  • Themes are swappable at runtime (default dark/light, nord, etc.)
  • Components never reference hex values directly
  • A single load-preset call changes the entire application's look

The engine is intentionally simple: a theme class holding a hash table of role→hex mappings, a set of built-in presets defined via define-preset, and load-preset which populates both the theme and the backend's *theme-colors* for SGR resolution.

Contract

Theme class

  • (make-theme &key mode) — create a theme in :dark or :light mode
  • (theme-mode theme) — get current mode
  • (theme-color theme role) → hex string or nil
  • (setf (theme-color theme role) hex) — set a role

Presets

  • (define-preset name &key dark light) — register a preset with dark and light plists of role→hex pairs
  • (load-preset theme preset-name) — apply a preset to theme. Also populates cl-tty.backend:*theme-colors* so the backend can resolve semantic colors to hex at render time.
  • Unknown presets signal a warning (not an error).

Built-in presets

  • :default — gold/accent on dark blue-gray
  • :nord — cool blue nord palette

Tests

(in-package :cl-tty-box-test)
(in-suite box-suite)

(test theme-create-default
  "A theme can be created with default mode"
  (let ((th (make-theme)))
    (is (typep th 'theme))
    (is (eql (theme-mode th) :dark))))

(test theme-create-light
  "A theme can be created in light mode"
  (let ((th (make-theme :mode :light)))
    (is (eql (theme-mode th) :light))))

(test theme-color-set-and-get
  "theme-color setf/get works"
  (let ((th (make-theme)))
    (setf (theme-color th :primary) "#FFD700")
    (is (string= (theme-color th :primary) "#FFD700"))))

(test theme-color-unknown-returns-nil
  "Unknown roles return nil"
  (let ((th (make-theme)))
    (is (null (theme-color th :nonexistent)))))

(test load-default-dark-preset
  "Loading the default dark preset populates roles"
  (let ((th (make-theme :mode :dark)))
    (load-preset th :default)
    (is (string= (theme-color th :primary) "#FFD700"))
    (is (string= (theme-color th :background) "#1A1A2E"))
    (is (string= (theme-color th :error) "#FF4444"))))

(test load-default-light-preset
  "Light variant has different colors"
  (let ((th (make-theme :mode :light)))
    (load-preset th :default)
    (is (string= (theme-color th :primary) "#B8860B"))
    (is (string= (theme-color th :background) "#F8F9FA"))))

(test load-nord-preset
  "Nord preset has different colors than default"
  (let ((th (make-theme :mode :dark)))
    (load-preset th :nord)
    (is (string= (theme-color th :primary) "#88C0D0"))
    (is (string= (theme-color th :background) "#2E3440"))))

(test load-preset-unknown-warns
  "Unknown preset warns but doesn't error"
  (let ((th (make-theme)))
    (signals warning (load-preset th :nonexistent))
    (is (null (theme-color th :primary)))))

(test preset-switch-mode
  "Switching mode and reloading changes colors"
  (let ((th (make-theme :mode :dark)))
    (load-preset th :default)
    (is (string= (theme-color th :background) "#1A1A2E"))
    (setf (theme-mode th) :light)
    (load-preset th :default)
    (is (string= (theme-color th :background) "#F8F9FA"))))

Implementation

Theme class

The theme class holds a mode flag (:dark~/:light~) and a hash table of role→hex mappings. The hash table gives O(1) lookups for theme-color and clean iteration for load-preset.

(in-package :cl-tty.box)

;; ── Theme Engine ──────────────────────────────────────────────

(defclass theme ()
  ((mode :initform :dark :initarg :mode :accessor theme-mode)
   (roles :initform (make-hash-table) :accessor theme-roles)))

(defun make-theme (&key (mode :dark))
  (make-instance 'theme :mode mode))

The mode defaults to :dark. Applications can initialize with :light for terminals with light backgrounds. The mode controls which variant load-preset selects.

Color resolution

(defun theme-color (theme role)
  "Resolve a semantic ROLE to a hex color string in THEME."
  (gethash role (theme-roles theme)))

(defun (setf theme-color) (hex theme role)
  "Set the hex color for a semantic ROLE in THEME."
  (setf (gethash role (theme-roles theme)) hex))

Uses gethash for both getter and setter. Unknown roles return nil, which the backend treats as "use default" — so missing roles degrade gracefully rather than crashing.

Preset system

Presets are stored in a global hash table keyed by keyword name. The define-preset macro registers a preset at macro-expansion time.

(defparameter *presets* (make-hash-table :test #'eq))

(defmacro define-preset (name &key dark light)
  "Define a theme preset with DARK and LIGHT variants.
NAME should be a keyword (e.g., :default, :nord)."
  (check-type name keyword)
  `(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))

Using #\' (quoted list) instead of an alist or hash table keeps the preset data inline and easy to read. The eq hash table test matches keyword identity.

(defun load-preset (theme preset-name)
  "Load PRESET-NAME colors into THEME.
Side-effect: populates cl-tty.backend:*theme-colors* so that semantic
color roles resolve to hex at SGR generation time."
  (let ((preset (gethash preset-name *presets*)))
    (if preset
        (let* ((colors (if (eql (theme-mode theme) :dark)
                           (getf preset :dark)
                           (getf preset :light)))
               ;; Populate backend theme color map
               (theme-map cl-tty.backend:*theme-colors*))
          ;; Set theme colors
          (loop for (role hex) on colors by #'cddr
                do (setf (theme-color theme role) hex)
                   (setf (gethash role theme-map) hex)))
        (warn "Unknown preset: ~S" preset-name))))

load-preset does double duty: it populates the theme's role map and the backend's *theme-colors*. This second step is what makes semantic colors work at the SGR level — when the backend renders :accent, it looks up *theme-colors* to get the hex, then generates the escape sequence.

The loop for (role hex) on colors by #'cddr iterates the plist in pairs, setting both the theme entry and the backend entry.

If the preset doesn't exist, warn is called instead of error — a missing preset shouldn't crash the application.

Built-in presets

Two presets are built in:

Default preset

Gold/accent palette on dark navy background. The light variant inverts to warm tones on near-white.

(define-preset :default
  :dark  (:primary "#FFD700" :secondary "#B8860B" :accent "#FFA500"
          :error "#FF4444" :warning "#FF8800" :success "#44BB44" :info "#4488FF"
          :text "#FFFFFF" :text-muted "#888888"
          :background "#1A1A2E" :background-panel "#16213E" :background-element "#0F3460"
          :border "#334155" :border-active "#FFD700"
          :diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#1A1A2E"
          :markdown-heading "#FFD700" :markdown-code "#334155"
          :markdown-link "#4488FF" :markdown-quote "#888888"
          :syntax-keyword "#FF79C6" :syntax-function "#50FA7B"
          :syntax-string "#F1FA8C" :syntax-number "#BD93F9"
          :syntax-comment "#6272A4" :syntax-type "#8BE9FD")
  :light (:primary "#B8860B" :secondary "#8B6914" :accent "#FF8C00"
          :error "#CC0000" :warning "#CC6600" :success "#228B22" :info "#0055CC"
          :text "#1A1A2E" :text-muted "#888888"
          :background "#F8F9FA" :background-panel "#FFFFFF" :background-element "#E9ECEF"
          :border "#DEE2E6" :border-active "#B8860B"
          :diff-added "#DFD" :diff-removed "#FDD" :diff-context "#F8F9FA"
          :markdown-heading "#B8860B" :markdown-code "#E9ECEF"
          :markdown-link "#0055CC" :markdown-quote "#888888"
          :syntax-keyword "#D63384" :syntax-function "#198754"
          :syntax-string "#FFC107" :syntax-number "#6F42C1"
          :syntax-comment "#6C757D" :syntax-type "#0DCAF0"))

Nord preset

Cool blue palette inspired by Arctic Studio's Nord theme. Softer contrast than default, designed for reduced eye strain.

(define-preset :nord
  :dark  (:primary "#88C0D0" :secondary "#81A1C1" :accent "#5E81AC"
          :error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
          :text "#ECEFF4" :text-muted "#616E88"
          :background "#2E3440" :background-panel "#3B4252" :background-element "#434C5E"
          :border "#4C566A" :border-active "#88C0D0"
          :diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#2E3440"
          :markdown-heading "#88C0D0" :markdown-code "#3B4252"
          :markdown-link "#81A1C1" :markdown-quote "#616E88"
          :syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
          :syntax-string "#EBCB8B" :syntax-number "#B48EAD"
          :syntax-comment "#616E88" :syntax-type "#88C0D0")
  :light (:primary "#5E81AC" :secondary "#81A1C1" :accent "#88C0D0"
          :error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
          :text "#2E3440" :text-muted "#8F9BB3"
          :background "#ECEFF4" :background-panel "#FFFFFF" :background-element "#E5E9F0"
          :border "#D8DEE9" :border-active "#5E81AC"
          :diff-added "#DFD" :diff-removed "#FDD" :diff-context "#ECEFF4"
          :markdown-heading "#5E81AC" :markdown-code "#E5E9F0"
          :markdown-link "#81A1C1" :markdown-quote "#8F9BB3"
          :syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
          :syntax-string "#D08770" :syntax-number "#B48EAD"
          :syntax-comment "#8F9BB3" :syntax-type "#88C0D0"))