14 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-presetcall 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:darkor:lightmode(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 totheme. Also populatescl-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
Test header
Package declaration and test suite registration.
(in-package :cl-tty-box-test)
(in-suite box-suite)
Test: theme-create-default
Verifies basic construction of a theme with default :dark mode. The
make-theme constructor should return an instance of the theme
class with :dark as the initial mode.
(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
Verifies explicit :light mode works. Both modes must produce themes
ready to accept color role assignments.
(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
Confirms setf on theme-color stores a value and that reading it
back returns the same string. This is the core read/write contract
for the theme's role map.
(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
Unassigned roles must return nil rather than signaling an error.
This allows components to degrade gracefully when a theme doesn't
define every possible role.
(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 preset in :dark mode must populate a set of
expected roles with their documented hex values. We spot-check
:primary, :background, and :error.
(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
The light variant of :default must produce different values (warm
tones on near-white). This validates the mode dispatch inside
load-preset.
(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
The :nord preset must produce a distinct cool-blue palette,
different from the :default gold scheme. This validates independent
preset data.
(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
An unknown preset name must signal a warning (not an error) and
leave the theme's roles unpopulated. This ensures graceful degradation
when a preset is missing.
(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 the mode at runtime and re-loading the same preset must
produce the other variant's colors. This validates that load-preset
reads the current theme-mode each time, not a cached value.
(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.
defclass theme
The class has two slots: mode (defaulting to :dark, with an
:initarg and accessor for reads and writes) and roles (a hash
table storing role→hex mappings, lazily initialized to an empty
hash table). Using make-hash-table as the :initform ensures each
instance gets its own table instead of sharing one.
(in-package :cl-tty.box)
(defclass theme ()
((mode :initform :dark :initarg :mode :accessor theme-mode)
(roles :initform (make-hash-table) :accessor theme-roles)))
defun make-theme
A convenience constructor that delegates to make-instance. Wrapping
this in a function lets us change the constructor signature without
breaking callers. Mode defaults to :dark, suitable for dark-background
terminals; callers pass :mode :light for light backgrounds.
(defun make-theme (&key (mode :dark))
(make-instance 'theme :mode mode))
Color resolution
defun theme-color
Reads a semantic role from the theme's roles hash table. Uses
gethash which returns nil for unknown roles — so missing roles
degrade gracefully rather than crashing. The backend treats nil as
"use default."
(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)
The setter companion to theme-color. Storing via setf writes
directly into the roles hash table. Uses setf on gethash which
creates the entry if it doesn't exist.
(defun (setf theme-color) (hex theme role)
"Set the hex color for a semantic ROLE in THEME."
(setf (gethash role (theme-roles theme)) hex))
Global preset registry
A hash table (keyed by eq-comparable keywords) stores all registered
presets. Using #\\' (quoted list) instead of an alist or nested hash
table keeps preset data inline and readable.
defparameter presets
Global storage for preset definitions. The eq test matches keyword
identity, which is the fastest hash test for keywords.
(defparameter *presets* (make-hash-table :test #'eq))
defmacro define-preset
Registers a preset by name (keyword) at macro-expansion time. The
check-type enforces that names are keywords. The macro expands to a
setf of gethash, storing a plist of :dark and :light variants.
Using a quoted list (not an alist or hash) keeps the data compact.
(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)))
Loading presets
defun load-preset
The central function that applies a named preset to a theme. Does
double duty: 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.
(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))))
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"))