Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Litmus test your terminal themes — preview them across all your apps before you commit.

The Problem

Switching terminal themes is a frustrating loop:

  1. You find a theme that looks nice in your terminal’s preview
  2. You edit configs for kitty/wezterm, neovim, zellij, tig, delta…
  3. You discover git diff is unreadable, or jjui’s text blends into the background
  4. You revert everything and try the next theme
  5. Repeat

The core issue: you can’t see how a theme actually looks across your real workflow until after you’ve fully set it up. Kitty’s built-in theme preview shows ANSI swatches, but that doesn’t tell you whether a complex git diff or a tig log view will be readable. The decision is visual, but the evaluation process is mechanical and slow — especially on NixOS where config changes require a home-manager rebuild.

The Solution

A web app that lets you preview any theme across all your terminal apps instantly, with realistic sample content that exposes real readability issues.

Pick a theme. See exactly how git diff, neovim, tig, zellij, and more will look — before touching a single config file.

Current Scale

  • 29 themes across 15 families (Catppuccin, Tokyo Night, Gruvbox, Dracula, Nord, Rose Pine, and more)
  • 8 scenes simulating real terminal output (shell prompt, git diff, ls colors, cargo build, neovim, Python REPL, log viewer, htop)
  • 3 crates: litmus-model (shared data model), litmus-cli (TUI prototype), litmus-web (Dioxus WASM web app)
  • Accessibility tooling: WCAG contrast checking, color blindness simulation (CVD), keyboard navigation, screen reader support
  • Config export: generate kitty.conf, TOML, or Nix for any theme

Project Status

The web app is the primary interface — the TUI was the M0 prototype that informed the data model design. 13 milestones have been completed, covering everything from the core data model through theme browsing, comparison, export, accessibility, and CVD simulation. See Milestones for the full history.

Architecture

litmus is a Rust workspace with three crates that share a common data model.

Workspace layout

crates/
  litmus-model/   — shared data model, theme parsing, scenes, validation
  litmus-cli/     — TUI binary (ratatui + crossterm) — the M0 prototype
  litmus-web/     — web frontend (Dioxus, targets wasm32) — the primary interface

Both litmus-cli and litmus-web depend on litmus-model. The model crate has no UI dependencies and compiles for any target.

Data model (litmus-model)

Core types

The model centers on three types in lib.rs:

  • Color — an RGB triplet (r: u8, g: u8, b: u8) with to_hex() / from_hex() conversion.
  • AnsiColors — the 16 named ANSI terminal colors as individual fields (black, red, …, bright_white). Provides from_array() / as_array() for indexed access.
  • Theme — the complete theme definition: name, background, foreground, cursor, selection_background, selection_foreground, and an AnsiColors struct.

Theme format

Themes are TOML files with a flat structure:

name = "Dracula"

[colors]
background = "#282a36"
foreground = "#f8f8f2"
cursor = "#f8f8f2"
selection_background = "#44475a"
selection_foreground = "#f8f8f2"

[colors.ansi]
black = "#21222c"
red = "#ff5555"
# ... all 16 ANSI colors

The toml_format module handles parsing. Additional parsers exist for kitty and base16 formats. Exporters can write themes as kitty.conf, TOML, or Nix attribute sets.

Theme embedding

Themes are embedded at compile time. In crates/litmus-web/src/themes.rs, a static array uses include_str! to inline each theme’s TOML:

#![allow(unused)]
fn main() {
static THEME_DATA: &[&str] = &[
    include_str!("../../../themes/ayu/dark.toml"),
    include_str!("../../../themes/ayu/light.toml"),
    // ...
];
}

load_embedded_themes() parses these strings at startup and returns a sorted Vec<Theme>.

Theme families

Themes are grouped into families by name prefix. The family.rs module defines known family prefixes:

#![allow(unused)]
fn main() {
static FAMILIES: &[&str] = &[
    "Ayu", "Catppuccin", "Everforest", "Gruvbox", "Kanagawa",
    "Rose Pine", "Rosé Pine", "Solarized", "Tokyo Night",
];
}

group_by_family() partitions themes — those matching a prefix are grouped together, the rest become single-theme families. This drives the grouped layout on the home page.

Scene system

The scene system is the key abstraction that makes litmus work. Scenes simulate real terminal output using semantic color references instead of hardcoded values.

ThemeColor enum

#![allow(unused)]
fn main() {
pub enum ThemeColor {
    Foreground,
    Background,
    Cursor,
    SelectionBackground,
    SelectionForeground,
    Ansi(u8),  // 0–15
}
}

ThemeColor::resolve(&self, theme: &Theme) -> &Color maps a semantic reference to the actual color in a given theme. This indirection means a single scene definition renders correctly across all themes.

Composition

  • StyledSpan — text + optional fg/bg ThemeColor + style flags (bold, italic, dim, underline). Built with StyledSpan::plain(), StyledSpan::colored(), and chainable .bold(), .dim(), .italic(), .on(bg).
  • SceneLine — a vector of StyledSpans representing one terminal row.
  • Scene — id, name, description, and a vector of SceneLines.

Built-in scenes

The 8 scenes in scenes.rs simulate common terminal contexts:

SceneSimulates
Shell PromptBash/zsh prompt with git branch
Git DiffUnified diff output with adds/removes
LS ColorsColorized directory listing
Cargo BuildRust compiler errors and warnings
Log ViewerStructured log output with levels
NeovimEditor UI with line numbers and syntax
Python REPLInteractive Python session
htopSystem monitor with resource bars

Rendering flow

  1. A scene defines styled spans with ThemeColor references
  2. The renderer (web or TUI) iterates spans
  3. Each ThemeColor is resolved against the current Theme to get an RGB Color
  4. The resolved color is applied as a CSS inline style (web) or terminal escape (TUI)

Web app (litmus-web)

The web frontend is a Dioxus WASM application.

Router

Four routes handle the main views:

RouteComponentPurpose
/ThemeListHome — filterable grid of theme cards
/theme/:slugThemeDetailSingle theme with scene tabs, palette, export
/scene/:scene_idSceneAcrossThemesOne scene rendered across all themes
/compare/:slugsCompareThemesSide-by-side comparison (2–4 themes)

Component hierarchy

App                        — context providers (CompareSelection)
└── Shell                  — navigation bar + content area
    ├── ThemeList           — search, filters, family-grouped cards
    │   └── ThemeCard       — swatch strip + mini scene preview
    ├── ThemeDetail         — scene tabs, palette, export buttons
    │   ├── AllScenesView   — renders all scenes for a theme
    │   └── ExportButtons   — kitty.conf / TOML / Nix export
    ├── SceneAcrossThemes   — grid of one scene across themes
    └── CompareThemes       — 2–4 themes side by side

Scene rendering

scene_renderer.rs resolves ThemeColor references to CSS inline styles. The SceneView component renders a scene as a <pre> block, applying color and background-color from the resolved theme colors, plus font-weight, font-style, and opacity for text styles.

Filters and features

  • Search: filters themes by name or family
  • Variant filter: All / Dark / Light (based on background luminance)
  • Contrast filter: only themes passing WCAG AA readability checks
  • CVD simulation: view themes as seen with protanopia, deuteranopia, or tritanopia
  • Compare accumulator: press ‘c’ or click to collect up to 4 themes for comparison
  • Keyboard navigation: arrow keys to cycle scenes on the detail page

Styling

style.css uses CSS custom properties for layout tokens and provides responsive breakpoints for mobile. The actual theme colors are applied as inline styles by the scene renderer, not through CSS classes.

Accessibility and CVD

WCAG contrast checking

contrast.rs implements WCAG 2.1 contrast ratio calculation:

  • relative_luminance() converts sRGB to linear luminance
  • contrast_ratio() computes the ratio between two colors
  • validate_theme_readability() checks a theme’s foreground/background against WCAG AA (4.5:1) and AAA (7.0:1) thresholds
  • validate_scene_contrast() checks every span in a scene against the theme

The contrast filter on the home page uses validate_theme_readability() to surface only themes with adequate contrast.

CVD simulation

cvd.rs simulates color vision deficiency using Machado et al. 2009 transformation matrices:

  • Protanopia — reduced red sensitivity
  • Deuteranopia — reduced green sensitivity
  • Tritanopia — reduced blue sensitivity

The simulation converts sRGB to linear RGB, applies a 3x3 transformation matrix, and converts back. simulate_theme() applies the transform to every color in a theme, producing a new Theme that can be rendered normally through the existing scene pipeline.

Semantic HTML and ARIA

The web app uses semantic HTML elements and ARIA attributes for screen reader compatibility. Interactive elements have visible focus indicators via :focus-visible styles. The layout is responsive down to mobile viewports.

Core Concepts

The Three-Layer Theme Model

Terminal apps relate to themes in fundamentally different ways. Understanding this is key to the system’s design.

1. Theme Providers

Apps that define a complete color palette independently. They are the “source of truth” for colors in their ecosystem.

Examples: kitty, wezterm, alacritty, neovim, helix

A provider’s theme fully determines what you see — both for itself and for any consumer apps running inside it.

2. Theme Consumers

Apps that inherit colors from a provider. They use ANSI color codes or reference the provider’s palette rather than defining their own.

Examples: git diff, delta, ls --color, tig, bat, fd, most CLI tools

A consumer must be previewed within the context of a provider. Showing git diff output alone is meaningless — it looks completely different under kitty+Tokyo Night vs kitty+Gruvbox.

3. Theme Silos (and Dual-Mode Apps)

Apps that define their own isolated theme, used only by themselves. Some apps can operate in both modes — e.g., jjui can use its own built-in theme (silo mode) or fall back to terminal ANSI colors (consumer mode).

The preview system should be able to show both modes for dual-mode apps.

Provider Ecosystems

The provider/consumer relationship creates natural ecosystems:

  • Terminal ecosystem: kitty (provider) → git diff, delta, tig, ls, bat, fd, jjui (consumers)
  • Editor ecosystem: neovim (provider) → nvim-tree, telescope, lualine, which-key (consumers)
  • Editor ecosystem: helix (provider) → (built-in UI elements as consumers)

A theme preview is most useful when it shows an entire ecosystem together — the provider plus its consumers rendering realistic content.

Development

This chapter covers setting up a development environment and working on litmus.

Prerequisites

  • Rust toolchain: defined in rust-toolchain.toml — stable channel with the wasm32-unknown-unknown target
  • Nix (optional): the flake.nix provides a reproducible dev shell with all dependencies
  • mise: task runner used for all dev commands

Quick start

# Start the web app (Dioxus dev server, port 8883)
mise run dev

# Run the TUI prototype
mise run _cli

# Serve the docs with live reload (port 8882)
mise run _docs-serve

mise tasks

TaskDescription
devStart Dioxus dev server (port 8883)
build-webBuild web release
build-cliBuild CLI release
checkRun cargo check across workspace
fmtFormat code with cargo fmt
docs-serveServe mdbook with live reload (port 8882)
docs-buildBuild static docs
bacon-claude-diagnosticsExport compiler diagnostics to .bacon-claude-diagnostics

Development loop

The recommended workflow uses bacon for continuous compilation feedback:

  1. Start bacon in a terminal pane: mise run bacon-claude-diagnostics
  2. Edit code
  3. bacon watches for changes and writes compiler diagnostics to .bacon-claude-diagnostics
  4. Read that file to see errors with exact file/line/column locations
  5. Fix and repeat

For quick one-off checks: mise run check (type-check) or mise run fmt (format).

Project structure

See the Architecture chapter for details on the three crates, data model, scene system, and web app structure.

Work tracking

litmus uses beans, an agentic-first issue tracker. Work is organized into milestones, each containing focused task and feature beans. See Milestones for the full history and Agentic Workflow for how beans fits into the development process.

Adding Themes

This guide walks through adding a new terminal color theme to litmus.

1. Create the TOML file

Themes live in the themes/ directory. Standalone themes go directly in themes/, while family members go in a subdirectory:

themes/dracula.toml          # standalone
themes/catppuccin/mocha.toml  # family member

Every theme TOML has the same structure — a name, a [colors] table with special colors, and a [colors.ansi] table with the 16 ANSI colors:

name = "Dracula"

[colors]
background = "#282a36"
foreground = "#f8f8f2"
cursor = "#f8f8f2"
selection_background = "#44475a"
selection_foreground = "#f8f8f2"

[colors.ansi]
black = "#21222c"
red = "#ff5555"
green = "#50fa7b"
yellow = "#f1fa8c"
blue = "#bd93f9"
magenta = "#ff79c6"
cyan = "#8be9fd"
white = "#f8f8f2"
bright_black = "#6272a4"
bright_red = "#ff6e6e"
bright_green = "#69ff94"
bright_yellow = "#ffffa5"
bright_blue = "#d6acff"
bright_magenta = "#ff92df"
bright_cyan = "#a4ffff"
bright_white = "#ffffff"

All color values are hex strings (#RRGGBB). Every field is required.

2. Register in themes.rs

Open crates/litmus-web/src/themes.rs and add an include_str! entry to the THEME_DATA array:

#![allow(unused)]
fn main() {
static THEME_DATA: &[&str] = &[
    // ... existing themes ...
    include_str!("../../../themes/dracula.toml"),
];
}

The path is relative to the themes.rs file. For family themes in subdirectories:

#![allow(unused)]
fn main() {
include_str!("../../../themes/catppuccin/mocha.toml"),
}

This embeds the theme at compile time — no runtime file I/O needed.

3. If adding a new family

If your theme belongs to a family that doesn’t exist yet (e.g. you’re adding the first “Kanagawa” variant), register the family prefix in crates/litmus-web/src/family.rs:

#![allow(unused)]
fn main() {
static FAMILIES: &[&str] = &[
    "Ayu",
    "Catppuccin",
    // ... add your family name here ...
    "Kanagawa",
];
}

The family grouping uses prefix matching — a theme named “Kanagawa Wave” will match the “Kanagawa” family prefix.

4. Verify

  1. Run cargo check (or read .bacon-claude-diagnostics if bacon is running)
  2. Start the web app with mise run dev
  3. Confirm your theme appears in the theme list
  4. Check that all scenes render correctly on the theme detail page
  5. If the theme is part of a family, verify it groups correctly

Adding Scenes

Scenes are simulated terminal outputs that showcase how a theme looks in real-world contexts (git diff, shell prompt, code editors, etc.). This guide covers adding new scenes.

How scenes work

A scene is built from three primitives defined in crates/litmus-model/src/scene.rs:

  • ThemeColor — a semantic color reference (e.g. Ansi(1) for red, Foreground, Background). Scenes never contain hardcoded RGB values; they reference theme slots that get resolved at render time.
  • StyledSpan — a chunk of text with optional foreground color, background color, and text style (bold, italic, dim, underline).
  • SceneLine — a row of styled spans.
  • Scene — an id, name, description, and a list of scene lines.

1. Add a scene function in scenes.rs

Open crates/litmus-model/src/scenes.rs and add a function that returns a Scene. Here’s a minimal example:

#![allow(unused)]
fn main() {
fn my_tool_scene() -> Scene {
    use ThemeColor::*;

    let lines = vec![
        SceneLine::new(vec![
            StyledSpan::colored("$ ", Ansi(2)).bold(),
            StyledSpan::colored("my-tool", Ansi(4)).bold(),
            StyledSpan::colored(" --flag", Ansi(6)),
        ]),
        SceneLine::new(vec![
            StyledSpan::colored("OK", Ansi(2)),
            StyledSpan::plain(": operation complete"),
        ]),
        SceneLine::new(vec![
            StyledSpan::colored("WARN", Ansi(3)),
            StyledSpan::plain(": 2 items skipped"),
        ]),
    ];

    Scene {
        id: "my-tool".into(),
        name: "My Tool".into(),
        description: "My tool output showing status messages".into(),
        lines,
    }
}
}

Builder API reference

#![allow(unused)]
fn main() {
// Plain text (uses theme foreground)
StyledSpan::plain("hello")

// Colored text (ANSI 0-15)
StyledSpan::colored("error", Ansi(1))       // red
StyledSpan::colored("success", Ansi(2))     // green
StyledSpan::colored("warning", Ansi(3))     // yellow
StyledSpan::colored("info", Ansi(4))        // blue

// Special theme colors
StyledSpan::colored("text", Foreground)
StyledSpan::colored("text", Cursor)

// Style modifiers (chainable)
StyledSpan::colored("bold red", Ansi(1)).bold()
StyledSpan::plain("subtle").dim()
StyledSpan::plain("emphasis").italic()

// Background color
StyledSpan::colored("selected", Ansi(0)).on(SelectionBackground)
}

The key rule: only use ThemeColor references, never hardcoded RGB. This ensures every scene adapts to every theme.

ANSI color mapping

IndexColorBright variant
0Black8 — Bright Black
1Red9 — Bright Red
2Green10 — Bright Green
3Yellow11 — Bright Yellow
4Blue12 — Bright Blue
5Magenta13 — Bright Magenta
6Cyan14 — Bright Cyan
7White15 — Bright White

2. Register in all_scenes()

Add your scene function to the all_scenes() vector in the same file:

#![allow(unused)]
fn main() {
pub fn all_scenes() -> Vec<Scene> {
    vec![
        shell_prompt_scene(),
        git_diff_scene(),
        // ... existing scenes ...
        my_tool_scene(),  // add here
    ]
}
}

3. Verify

No web-side changes are needed — scenes automatically appear in all views (theme detail tabs, scene-across-themes grid, compare mode).

  1. Run cargo check (or read .bacon-claude-diagnostics)
  2. Start the web app with mise run dev
  3. Verify your scene appears in the scene tabs on any theme detail page
  4. Check it renders well across a few different themes (light and dark)

Milestones

litmus development is organized into milestones, each delivering a focused set of features. 13 milestones have been completed, taking the project from a TUI prototype to a full-featured web app with 29 themes, 8 scenes, accessibility tooling, and config export.

M0: TUI Prototype

Built a terminal-based preview tool using ratatui to explore theme rendering before committing to a data model. Parsed kitty.conf files directly, displayed ANSI color swatches, and rendered hardcoded terminal mock-ups with theme colors applied.

M1: Theme Data Model & Parsing

Defined the unified theme representation — Color, AnsiColors (16 named fields), and Theme struct. Established TOML as the canonical theme format. Implemented parsers for kitty.conf and base16 YAML. Added validation and unit tests.

M2: Theme Curation

Built the initial curated theme library with ~15 high-quality themes: Catppuccin (4 variants), Tokyo Night (3), Gruvbox (2), Dracula, Nord, Rose Pine (3), Solarized (2), Kanagawa, and Everforest (2). Organized by theme family with prefix-based grouping.

M3: Terminal Ecosystem Rendering

Created the scene system — the core abstraction where ThemeColor enum references semantic color slots rather than hardcoded RGB values. Built StyledSpan builder API and initial scenes (shell prompt, git diff, ls colors). Implemented the web renderer that resolves theme colors to CSS inline styles.

M4: Theme Browsing UI

Launched the Dioxus WASM web app with a theme listing page (family-grouped cards with swatch strips), single-theme detail page showing all scenes, and a responsive monospace layout.

M5: Comparison & Polish

Added side-by-side theme comparison, theme-first and provider-first navigation, and visual polish for edge cases (very bright/dark themes, low-contrast text).

M6: Filters & Mini Previews

Added mini scene previews to theme cards on the home page. Implemented search filtering by theme name/family. Added light/dark variant filter and WCAG contrast quality filter.

M7: Theme Detail Redesign

Redesigned the theme detail page with tabbed scene views. Added keyboard navigation (arrow keys to cycle scenes). Introduced the compare accumulator — press ‘c’ to collect themes for comparison.

M8: Scene Grid & Compact Rendering

Implemented scene grid layout for viewing scenes side by side. Added compact rendering mode for denser previews. Introduced scene tabs for quick switching between scenes.

M9: Multi-Theme Compare

Extended comparison from 2 to 2–4 themes side by side. Added a color diff table showing how palette values differ across compared themes.

M10: Config Export

Added export functionality — generate kitty.conf, TOML, or Nix attribute set for any theme. Built a share/choose flow for copying or downloading the exported config.

M11: Accessibility & Mobile

CSS custom properties for layout tokens. Semantic HTML elements and ARIA attributes for screen reader support. :focus-visible indicators for keyboard users. Responsive layout down to mobile viewports.

M12: Theme & Scene Expansion

Expanded from 19 to 29 themes — added Ayu, Horizon, Material, Monokai, Moonlight, Nightfox, One Dark, Palenight, and additional Kanagawa variants. Grew from 5 to 8 scenes — added cargo build output, Python REPL, and htop scenes.

M13: Color Blindness Simulation

Implemented CVD (color vision deficiency) simulation using Machado et al. 2009 transformation matrices. Three modes: protanopia, deuteranopia, tritanopia. Transforms entire themes through the simulation pipeline so all existing scenes and views work without modification.

Agentic Workflow

litmus is developed with Claude Code as the primary coding agent. This chapter documents the tools and patterns that make this workflow effective.

Overview

The development process pairs a human who defines scope and reviews output with an AI agent that implements, tests, and iterates. Over 13 milestones and 60+ completed beans, this workflow produced the entire codebase — from data model to WASM web app.

CLAUDE.md

The CLAUDE.md file in the project root provides persistent context for the agent across conversations. It contains:

  • Workspace layout: which crates exist, what each one does
  • Bacon diagnostics workflow: how to read .bacon-claude-diagnostics instead of running cargo check
  • Conventions: anything the agent needs to remember between sessions

CLAUDE.md is loaded automatically at the start of every conversation. Keeping it concise and up-to-date is critical — it’s the agent’s “working memory” for the project.

Beans

Beans is an agentic-first issue tracker designed for AI agent workflows. Each bean is a Markdown file with YAML frontmatter tracking status, type, priority, and relationships.

Key properties:

  • File-based: beans live in the repo as .md files, readable by both humans and agents
  • CLI-driven: beans create, beans update, beans list — all with --json for machine parsing
  • Structured relationships: parent/child hierarchies, blocking/blocked-by dependencies
  • Milestone organization: beans group into milestones (M1, M2, …) for phased delivery

The agent creates beans before starting work, updates them as tasks complete, and marks them done when finished. This creates a clear audit trail of what was done and why.

Development loop

A typical milestone follows this cycle:

  1. Human defines scope — describes what the milestone should achieve, key features, constraints
  2. Agent creates beans — decomposes the scope into concrete, focused tasks
  3. Agent implements — works through beans sequentially, committing code and updating bean status
  4. Agent marks done — checks off todo items within beans, marks beans as completed
  5. Human reviews — examines the result, provides feedback, identifies follow-up work
  6. Archive — completed beans are archived with beans archive to keep the working set clean

Bacon diagnostics

The .bacon-claude-diagnostics file provides machine-readable compiler feedback. Instead of running cargo check (which takes compile time and produces human-formatted output), the agent reads this file to get structured diagnostics:

error|:|src/main.rs|:|42|:|42|:|mismatched types|:|<full rendered output>

This is faster (bacon is already watching) and parseable (pipe-delimited fields with severity, file, line range, and message).

What worked well

  • Structured decomposition: breaking milestones into small, focused beans keeps each task tractable and creates natural commit boundaries
  • CLAUDE.md as persistent instructions: the agent retains project-specific knowledge across conversations without re-explanation
  • Machine-readable tool output: .bacon-claude-diagnostics and beans --json give the agent structured data instead of text to parse
  • File-based everything: beans, themes, and CLAUDE.md are all files in the repo — no external services, no context lost between sessions

Lessons learned

  • Keep CLAUDE.md concise: when it grows too long, the agent spends context window on instructions rather than work. Prune aggressively.
  • Decompose into focused tasks: a bean that tries to do three things often gets confused. One bean, one concern.
  • Prefer file-based feedback over interactive tools: the agent works best reading files, not interacting with prompts or TUIs
  • Review early, not just at the end: catching a wrong direction after one bean is much cheaper than after five