Files
ltk/docs/theming.md

29 KiB
Raw Permalink Blame History

ltk theming

ltk reads a JSON theme document at startup and exposes a process-wide active state for widgets and applications to query. This document describes the on-disk format, the runtime APIs, and the slot conventions that built-in widgets expect.

For background on why theming is process-global and how it interacts with the runtime, see docs/architecture.md. This file is the schema reference.

File layout

A theme is a directory under one of:

  1. LTK_THEMES_DIR/<id>/ — only when the env var is set; intended for development.
  2. $XDG_DATA_HOME/ltk/themes/<id>/ (defaults to ~/.local/share/ltk/themes/<id>/).
  3. /usr/share/ltk/themes/<id>/ — system-wide install path (ltk-theme-default Debian package).

The directory tree:

<id>/
├── theme.json                  required — the schema below
├── branding/                   mode-aware branded assets
│   ├── light/
│   │   ├── launcher.svg        launcher logo
│   │   ├── wallpaper.svg       homescreen wallpaper
│   │   ├── lockscreen.svg      greeter / lockscreen image
│   │   └── logo/               brand wordmark / icon variants
│   │       ├── logo.svg        primary logo (about / splash)
│   │       ├── square.svg      1:1 variant (avatars, app icons)
│   │       └── horizontal.svg  wordmark (header / sign-in bars)
│   └── dark/
│       └── (same set, dark variants)
└── icons/                      symbolic + app icons
    ├── app-default.svg         fallback icon for unknown app ids
    ├── apps/                   per-application icons (firefox.svg, …)
    └── catalogue/              symbolic glyph catalogue
        ├── filled/             solid silhouettes — preferred by default
        │   └── <category>/     general, system, window, …
        └── line/               outlined variants (same names)

Branded assets in branding/ and icons in icons/ are picked up by convention — see Branding assets and Icons below. Asset paths declared inside theme.json (e.g. a custom wallpaper override) resolve relative to the theme directory; a bare "path": "custom.png" is portable across install prefixes, absolute paths work for system fonts but break relocatable installs.

Top-level structure

{
  "theme":        { "id": "default", "name": "Default" },
  "fonts":        { ... },
  "colors":       { ... },
  "gradients":    { ... },
  "inset_stacks": { ... },
  "modes": {
    "light": { ... },
    "dark":  { ... }
  }
}

theme.id must match the directory name. theme.name is shown in any theme-picker UI a shell builds on top of ltk.

The other six sections are described below in order. The parser is strict (deny_unknown_fields): unknown keys at any level are an error so typos surface immediately.

fonts

"fonts": {
  "sora": {
    "name":      "Sora",
    "fallbacks": ["system-ui", "sans-serif"],
    "sources":   [
      { "weight": 300, "path": "/usr/share/fonts/opentype/sora/Sora-Light.otf"    },
      { "weight": 400, "path": "/usr/share/fonts/opentype/sora/Sora-Regular.otf"  },
      { "weight": 600, "path": "/usr/share/fonts/opentype/sora/Sora-SemiBold.otf" },
      { "weight": 700, "path": "/usr/share/fonts/opentype/sora/Sora-Bold.otf"     }
    ]
  }
}

Each entry defines a font family with one source per OpenType / TrueType weight. The map key ("sora") is the family alias used inside the theme ("font_family": "sora"); name is the human-readable label.

fallbacks is consulted when a glyph is missing from the primary font files at render time. The chain is walked in order; the first family that can rasterise the codepoint wins.

If none of the listed sources exist on disk, ltk falls back to its embedded Sora Regular (~50 KB, OFL 1.1) and stamps a red banner on every frame pointing at the missing-theme problem.

colors

A flat dictionary of named hex literals, used as building blocks elsewhere in the document via the @name reference syntax:

"colors": {
  "navy":      "#0A032E",
  "white":     "#FFFFFF",
  "cyan":      "#04D9FE",
  "danger":    "#E5484D",
  "glass-hi":  "#555555",
  "ink":       "#000000"
}

Values are 6- or 8-digit hex (#RRGGBB or #RRGGBBAA). Names use kebab-case. There is no distinction between "palette" colours (used in many places) and "raw" colours (single-use) — any hex can live here, and single-use literals are equally valid inline.

gradients

Named paints. Two variants: linear and radial. Used both as fills for surfaces and as values referenced from a slot. Stops carry a pos (in [0, 1], but stop positions outside that range are accepted and used for extrapolation) and a color (literal hex or @reference).

"gradients": {
  "fill-cyan": {
    "type":      "linear",
    "angle_deg": 270,
    "space":     "linear-rgb",
    "stops": [
      { "pos": 0, "color": "@cyan"      },
      { "pos": 1, "color": "@cyan-soft" }
    ]
  }
}

space is either "srgb" (perceptually quick, the default) or "linear-rgb" (interpolate in linear-light space, more uniform mid-tones for high-saturation gradients). angle_deg is the conventional CSS gradient angle (0 = up, 90 = right, 180 = down, 270 = left).

Radial gradients use center: [x, y] (relative to the painted rect, both in [0, 1]) and radius: r (also relative).

Soft cap: a single gradient may carry 64 stops. Beyond that the parser truncates the tail with a stderr warning, so a hostile or mistakenly-large gradient cannot blow up CPU + memory at parse time. Realistic designs use 2 6 stops.

inset_stacks

Named lists of inset shadows reused across surfaces. Convenient for the "glass" stack used by every translucent slot in the default theme:

"inset_stacks": {
  "glass-insets": [
    { "offset": [0, 1], "blur": 4, "color": "@ink/0F", "blend": "normal" }
  ]
}

Each entry has offset: [x, y] (logical pixels), blur (Gaussian sigma × 2, matching the CSS convention), optional spread (default 0), color (literal or @ref), and blend (normal, plus-lighter, or overlay).

Reference an entire stack from a slot with "inset_shadows": "@glass-insets", or inline the array if you only use it once.

modes

Two required entries: light and dark. Each carries the look the theme applies in that lighting mode plus its own slots table.

wallpaper / lockscreen

Both are optional. The runtime resolves them in two steps:

  1. Convention — looks for branding/{mode}/wallpaper.svg and branding/{mode}/lockscreen.svg, with the standard mode → opposite-mode → no-mode fallback (see Branding assets).
  2. Override — if theme.json declares an explicit block, that path wins over the convention. Useful for raster wallpapers or non-conventional locations:
"wallpaper":  { "path": "custom/path.png", "fit": "cover" }

path resolves against the theme directory. fit is one of "cover", "contain", "stretch", "center" (default "cover"). The wallpaper bundle helper ([ltk::WallpaperBundle::for_size]) returns the right crop for landscape or portrait surfaces, so a single landscape SVG / PNG covers both.

launcher

"launcher": { "background": "@white/E6", "border_radius": 24.0 }

background is any color reference (@name[/AA]). border_radius is the corner radius for the launcher panel, in logical pixels.

window_controls

Per-mode tokens for the title-bar control buttons:

"window_controls": {
  "icon":           "#5F5F68",
  "hover_bg":       "@navy/14",
  "pressed_bg":     "@navy/24",
  "close_hover_bg": "@danger",
  "close_icon":     "@white",
  "focus_ring":     "@teal"
}

Consumed by the window_button widget through [theme_window_controls()].

The actual SVG glyphs (close, maximize, minimize, restore) live in icons/catalogue/filled/window/ and are tinted at runtime with the icon colour from this block (and close_icon on close-hover). Themes don't need to ship per-mode variants of these glyphs — the symbolic tinting handles light vs dark colouring. They also don't need a line/window/ variant; chrome controls should look the same regardless of the theme's overall icon style preference, and the existing filled fallback in icon_path already covers the case.

slots

The mode's slot table. Each entry is keyed by a stable id; widgets look their slot up by id and the slot's meta.semantic field supplies a human-readable hint that's useful in theme inspectors.

Three slot variants:

color

"text-primary": {
  "type":  "color",
  "value": "@navy",
  "meta":  { "semantic": "palette/text_primary" }
}

Plain colour values. value is a literal hex or @reference. meta is optional but conventional: palette/<role> for the palette layer and effect/<group>/<name> for everything else.

shadows

"shadows-glass": {
  "type": "shadows",
  "shadows": [
    { "offset": [0, 0], "blur": 9, "color": "@glass-elev/1F" }
  ]
}

Outer drop shadows applied via [theme_shadows(id)]. Same field shape as inset_stacks entries.

surface

"surface-card": {
  "type":          "surface",
  "fill":          "@surface-glass-dark",
  "shadows":       "shadows-glass",
  "inset_shadows": "@glass-insets",
  "backdrop":      { "blur_px": 22.5 },
  "meta":          { "semantic": "effect/glass/card" }
}

The most expressive slot kind. Composes:

  • fill — a paint reference (@gradient-name) or inline gradient / solid colour. Required.
  • shadows — id of a shadows slot or an inline list. Optional.
  • inset_shadows — id of an inset_stacks entry (@glass-insets) or inline list. Optional.
  • backdrop{ "blur_px": <σ × 2> } for backdrop blur. Optional; GLES backend renders it, software backend ignores it (documented parity gap).

References

The @name syntax substitutes a palette / gradient / inset-stack value in place of an inline literal:

  • @cyan — looks up colors.cyan, gradients.cyan or inset_stacks.cyan (collisions across sections are an error). When the resolved value is a colour, the alpha channel comes from the original.
  • @cyan/80 — the /AA suffix is a colour-only alpha override (two hex digits). Lets a single base @navy serve @navy/14, @navy/24, @navy/99, @navy/D9 etc. without a separate entry per alpha.

References inside gradient stops or inset shadows are resolved at parse time, so each downstream substitution at a slot call site is a flat clone with no recursion at runtime.

Unknown references fail loud: parsing aborts with ThemeError:: UnknownColorRef("foo").

Canonical slot ids

The default widgets look up these slot ids; a custom theme that omits any of them falls back to embedded defaults.

Palette (every mode must define):

id role type
bg-page window background color
surface card / panel surface color
surface-alt text-input field background color
text-primary regular text colour color
text-secondary muted / placeholder text color
accent toggle on, slider fill, focus ring color
divider separator, toggle off, list item border color
icon icon-button glyph colour color

Effects (optional but used by built-in widgets):

id consumer type
shadows-glass every surface that opts into elevation shadows
surface-card Container::surface("surface-card") surface
surface-card-flat flat variant for software backend surface
surface-panel overlay panels surface
surface-slider-track Slider track background surface
surface-slider-fill Slider filled portion surface
surface-slider-track-flat software-backend slider track surface
surface-slider-fill-flat software-backend slider fill surface
surface-toggle-active Toggle on-state surface surface

The -flat variants are used by the software backend, which lacks backdrop blur; the GLES backend uses the non-flat ones.

Using the theme from app code

The active theme is process-global mutable state: a (ThemeDocument, ThemeMode) pair guarded by a RwLock behind the ltk::theme API. Widgets and apps read it through cheap accessors that clone an Arc or project a small struct out of the slot table — designed so it's fine to call them dozens of times per frame from inside view().

The canonical pattern: read in view(), never cache

Read the theme at the top of every view() and let the next frame re-resolve automatically. Storing a Color in your app state freezes it at the moment you captured it: a later set_active_mode( Dark ) will repaint everyone who reads palette per-frame and skip the widgets that read a stale field.

# use ltk::{ column, container, text, Element };
# #[ derive( Clone ) ] enum Msg {}
# struct MyApp;
# impl MyApp {
fn view( &self ) -> Element<Msg>
{
    let palette = ltk::theme_palette();

    column::<Msg>()
        .push( text( "Hola" ).color( palette.text_primary ) )
        .push( text( "subtítulo" ).color( palette.text_secondary ) )
        .push(
            container( text( "tarjeta" ).color( palette.text_primary ) )
                .background( palette.surface )
                .radius( 12.0 ),
        )
        .into()
}
# }

Helpers reference

Helper Returns When to use
theme_palette() Palette Common case — named colour fields (bg, surface, surface_alt, text_primary, text_secondary, accent, divider, icon, danger, danger_bg). Cheap projection, ideal at the top of view().
theme_color( id ) Option<Color> Pull a single colour slot by id when it's not in the palette ("surface-card-border", custom theme tokens).
theme_color_or( id, fallback ) Color Same, with a baked-in default — ergonomic in widget defaults so missing slots don't return None.
theme_paint( id ) Option<Paint> Slot may be a colour or a gradient — promotes a colour to Paint::Solid automatically.
theme_surface( id ) Option<Surface> Surface slot (fill + shadows + insets + backdrop).
theme_resolve_surface( id ) Option<( Surface, Vec<Shadow> )> Same, but pre-resolves a ShadowsRef::Named reference to a flat Vec. Use this when you call canvas.fill_surface directly.
theme_shadows( id ) Option<Vec<Shadow>> Outer shadow stack.
theme_text_style( id ) Option<TextStyle> Typography slot (size, weight, line-height).
theme_window_controls() WindowControlsSpec Per-mode chrome tokens for the title-bar buttons.
theme_wallpaper() / theme_lockscreen() Option<WallpaperSpec> Full-screen branded images (SVG), with the convention fallback chain.
theme_branding_image( name, sw, sh ) Option<PathBuf> Sized branded image: smallest covering raster (WebP / PNG / JPEG) under branding/{mode}/{name}/, or the largest available raster if none cover, falling back to the SVG only when no rasters exist. Pass (0, 0) for the smallest available (startup before surface-configure).
theme_branding_raster( name, sw, sh ) Option<PathBuf> Raster-only variant of the above; returns None only when no rasters exist at all.
theme_branding_asset( name, ext ) Option<PathBuf> Generic branded asset lookup (any extension). Powers theme_launcher_icon, theme_wallpaper, theme_lockscreen.
theme_launcher_icon() Option<PathBuf> Launcher logo SVG path, with the convention fallback.
theme_logo() Option<PathBuf> Primary brand logo SVG (branding/{mode}/logo/logo.svg) — about dialogs, splash screens.
theme_logo_square() Option<PathBuf> Square 1:1 logo variant (logo/square.svg) — app icons, login avatars, lockscreen badges.
theme_logo_horizontal() Option<PathBuf> Wordmark logo variant (logo/horizontal.svg) — header bars, sign-in screens.
theme_app_icon( name ) / theme_app_default_icon() Option<PathBuf> Per-app icons under icons/apps/.
theme_icon_path( "category/name" ) Option<PathBuf> Catalogue icon path (filled-then-line lookup).
theme_icon_rgba( "category/name", size ) Option<( Arc<Vec<u8>>, u32, u32 )> Rasterised + cached RGBA. Pair with theme::tint_symbolic for chrome glyphs.
is_fallback_active() bool true when the embedded B/W fallback theme is in force (no theme on disk). Useful to disable a theme-picker UI or warn the user.

Switching mode or document at runtime

Mutators live next to the readers. They take effect on the next render:

// Light → dark.
ltk::set_active_mode( ltk::ThemeMode::Dark );

// Replace the whole document (user picked a different theme id in a
// settings panel, etc.).
let doc = ltk::ThemeDocument::find( "midnight" )
    .expect( "midnight theme not installed" );
ltk::set_active_document( doc );

The conventional wiring is to dispatch a message from the UI:

# struct MyApp;
enum Msg { ToggleTheme }

# impl MyApp {
fn update( &mut self, msg: Msg )
{
    match msg
    {
        Msg::ToggleTheme =>
        {
            let next = match ltk::active_mode()
            {
                ltk::ThemeMode::Light => ltk::ThemeMode::Dark,
                ltk::ThemeMode::Dark  => ltk::ThemeMode::Light,
            };
            ltk::set_active_mode( next );
        }
    }
}
# }

Every widget that read theme_palette() / theme_surface(...) / ... in view() automatically gets the new colours on the next frame — there is no manual invalidation step, no observer registration, no list of subscribed widgets. The entire reactivity story is "ltk re-runs view() every frame and you read fresh values".

Do / don't

  • Do read palette / surfaces / icons inside view(), every frame.
  • Do use theme_color_or( id, fallback ) for non-palette slots so a custom theme that omits a slot still paints something sane.
  • Do use theme_palette().<field> for the eight common roles — the palette is precomputed and cheap; named slot lookups only beat it when you genuinely need a non-canonical token.
  • Don't store Color, Surface, Paint, or icon PathBufs in your app state. They snapshot the moment of capture and stop responding to mode changes.
  • Don't hard-code Color::hex( ... ) for chrome that should adapt to mode — route it through a palette token or a custom slot.
  • Don't call set_active_mode from view() (it's a side effect; do it in update() from a Msg).

Branding assets

The branding/ directory holds the theme's identity assets — launcher logo, wallpaper, lockscreen, brand wordmark — keyed by mode. The runtime loader looks them up by convention so themes don't need to declare them in theme.json.

Layout

branding/
├── light/
│   ├── launcher.svg
│   ├── wallpaper.svg
│   ├── wallpaper/                  optional pre-rendered raster variants
│   │   ├── 1280x720.webp
│   │   ├── 1920x1080.webp
│   │   └── 3840x2160.webp
│   ├── lockscreen.svg
│   ├── lockscreen/
│   │   └── (same WIDTHxHEIGHT.webp set)
│   └── logo/                       brand wordmark / icon variants
│       ├── logo.svg                primary (about, splash)
│       ├── square.svg              1:1 (app icon, avatar)
│       └── horizontal.svg          wordmark (header bar, sign-in)
└── dark/
    └── (same structure)

The SVG is the canonical asset. Sized raster variants in the same-named subdirectory are an optimisation: the loader prefers a pre-rendered WebP / PNG / JPEG that already covers the surface (no upscale, no runtime SVG rasterisation), falling back to the SVG when no raster fits. Filenames must be WIDTHxHEIGHT.<ext> (literal numeric form, lower-case x); the parser is strict so a bad name is silently ignored rather than misclassified.

Fallback chain — SVG

When a branded SVG asset is requested for the active mode, the runtime tries three locations in order:

  1. branding/{active_mode}/{name}.svg — preferred variant.
  2. branding/{opposite_mode}/{name}.svg — graceful degradation when the theme only ships one mode of the asset.
  3. branding/{name}.svg — mode-agnostic asset, for themes that don't bother with light/dark variants.

Returns None when none of the candidates exist; consumers fall back to whatever default they prefer (solid bg-page for wallpapers, no launcher decoration, etc.).

Resolution — raster

When the surface size is known, [theme_branding_raster] looks for a pre-rendered raster under branding/{mode}/{name}/. The same three-step mode chain applies to the directory: tries the active mode first, then the opposite mode, then the mode-less directory. Within the first existing directory it parses every WIDTHxHEIGHT.<ext> filename (<ext> ∈ {webp, png, jpg, jpeg}) and picks:

  • the smallest entry whose two dimensions cover the surface, if any;
  • otherwise the largest entry available — a fast-decoding upscaled raster beats paying the SVG rasterisation cost.

The directory does not cross over to the opposite-mode tree once an existing directory is found: a colour-wrong raster (light-mode asset served in dark mode or vice versa) would be more jarring than an upscaled same-mode raster. Cross-mode degradation only happens at the SVG layer, where the vector form re-paints crisply at any size.

theme_branding_image(name, sw, sh) composes raster-then-SVG: tries theme_branding_raster first, falls back to theme_branding_asset(name, "svg") only when no raster files exist anywhere in the chain. Pass (0, 0) to get the smallest available raster — every entry trivially covers a zero-sized surface. Useful at startup before the surface-configure event arrives, so the first frame paints from a fast-decoded lightweight raster (typically a few ms) instead of the SVG (typically 1-2 s for a gradient-heavy abstract wallpaper).

Runtime API

// SVG resolution — mode-aware fallback chain on the file path.
let launcher   = ltk::theme_launcher_icon();   // Option<PathBuf>
let wallpaper  = ltk::theme_wallpaper();       // Option<WallpaperSpec>
let lockscreen = ltk::theme_lockscreen();      // Option<WallpaperSpec>

// Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant.
let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 );
//                                                            -> Option<PathBuf>

// Combined: raster first, SVG fallback. Recommended for wallpaper /
// lockscreen consumers that have the surface size at decode time.
let path = ltk::theme_branding_image( "wallpaper", 1920, 1080 );

// Brand wordmark / icon — three named variants under branding/{mode}/logo/.
let about_logo = ltk::theme_logo();              // Option<PathBuf> — primary
let app_icon   = ltk::theme_logo_square();       // Option<PathBuf> — 1:1
let header_lg  = ltk::theme_logo_horizontal();   // Option<PathBuf> — wordmark

// Generic helper for arbitrary branded SVG assets (splash, watermarks, …).
let splash = ltk::theme_branding_asset( "splash", "svg" );

branding_asset(name, ext) is the underlying SVG helper; branding_raster and branding_image add the size-aware layer on top. The three theme_logo* helpers are thin wrappers over branding_asset( "logo/<variant>", "svg" ) — the name argument freely accepts a subdir/file shape, so any other sub-grouped asset family follows the same pattern.

Icons

Two parallel trees under icons/:

  • icons/apps/ — per-application icons (firefox.svg, calculator.svg, …). Each app's brand identity, mode-agnostic. Looked up via [theme::app_icon( "firefox" )].
  • icons/catalogue/ — symbolic glyph catalogue intended for tinting at runtime. Two style variants:
    • filled/ — solid silhouettes. Preferred by default.
    • line/ — outlined variants. Used as fallback when filled doesn't ship a given icon. A theme that prefers a line aesthetic puts its SVGs in filled/ (where the lookup goes first); the directory name reflects the style of the asset, not the lookup precedence.

Categories under catalogue/{filled,line}/: accessibility, actions, archives, communication, controls, customisation, energy, features, feedback, general, hardware, keyboard, multimedia, navigation, safety, session, system, window.

Adding an icon

  1. Drop a monochrome SVG into catalogue/filled/<category>/<name>.svg. Convention: <svg fill="none"> at the root and an explicit fill on the path (e.g. <g fill="#000000">). The rasteriser keeps only the alpha channel for symbolic tinting, so the actual RGB of the source doesn't matter — pick #000000 for consistency with the rest of the catalogue.
  2. Optionally ship the line/ variant in catalogue/line/<category>/<name>.svg.
  3. Reference it from widget code by its slash-separated stem, no extension: theme::icon_path( "general/down-simple" ).

Runtime API

# fn _ex() -> Option<()> {
// Path resolution. Tries filled/<name>.svg first, then line/<name>.svg.
let path = ltk::theme::icon_path( "window/close" );
//                                              -> Option<PathBuf>

// Rasterise to RGBA8 (cached per (path, size) for the lifetime of the
// active document — set_active_document flushes the cache).
let ( rgba, w, h ) = ltk::theme::icon_rgba( "window/close", 16 )?;

// Tint a symbolic icon: keep the alpha, replace the RGB with `tint`.
// `palette.icon` is the catalogue tint; for window-chrome glyphs use
// `ltk::theme_window_controls().icon` instead.
let palette = ltk::theme_palette();
let tinted  = ltk::theme::tint_symbolic( &rgba, palette.icon );
# Some( () )
# }

The icon_rgba + tint_symbolic pair is the standard pipeline for catalogue and chrome icons: rasterise once, recolour per mode via palette tokens. Themes ship a single SVG per glyph and the per-mode look comes from code, not from duplicated assets.

Localisation

ltk integrates rust-i18n for built-in widget strings (context-menu labels, calendar month / day-of-week names, …). Locale files live in ltk/locales/<lang>.yaml. English is the fallback. Currently shipped: en, es, fr, it, de, pt, pt_BR.

Existing keys

context_menu:
  copy:   "Copy"
  cut:    "Cut"
  paste:  "Paste"
  delete: "Delete"

date_picker:
  month_1:     "January"
  ...
  month_12:    "December"
  dow_short_0: "S"
  ...
  dow_short_6: "S"

dow_short_<n> is indexed from each locale's first_dow (Sunday-first in en, Monday-first in es / fr / it / de / pt / pt_BR). The Locale struct ships first_dow: u8 and the date picker indexes dow_short_* accordingly.

Built-in widgets read these via rust_i18n::t!( "context_menu.copy" ) at render time, so switching locale at runtime via rust_i18n::set_locale( "es" ) flips the UI on the next frame without reconstructing widgets.

Adding a string

  1. Pick a key like my_widget.label.
  2. Add it to every file under ltk/locales/ so each language has a translation (English at minimum is required — it's the fallback).
  3. Read it from widget code with rust_i18n::t!( "my_widget.label" ).

Adding a language

  1. Create ltk/locales/<code>.yaml with all existing keys translated.
  2. The i18n!("locales", fallback = "en") macro at the crate root picks it up automatically — no registration step needed.
  3. Apps select the locale at startup via rust_i18n::set_locale( "<code>" ).

Common errors

Symptom Cause Fix
Red banner on every frame No theme found at any of the three search paths Install ltk-theme-default Debian package or set LTK_THEMES_DIR
unknown reference @foo at startup Typo in @name reference Check the colors / gradients / inset_stacks section spelling
unknown field 'foo' Stale schema after a ltk upgrade Compare against this document and the default theme
Slot is rendering with the wrong colour after set_active_mode App caches a Color from a previous frame Read palette / surface inside view() each frame; the per-frame Arc clone is cheap

Custom themes

A custom theme directory can live anywhere under $XDG_DATA_HOME/ltk/themes/. Three practical recipes:

  • Repaint for a brand: copy themes/default/, then change:
    • the colors palette in theme.json (everything else cascades),
    • the up-to-six SVGs in branding/{light,dark}/ (launcher, wallpaper, lockscreen),
    • leave the gradients, inset stacks, slot wiring and the entire icons/ tree untouched.
  • Override a single icon: drop a replacement SVG at the same catalogue path under your theme's icons/catalogue/filled/<category>/<name>.svg. The icon_path lookup resolves against whichever theme is active, so a partial catalogue overlays the default cleanly without forking the rest.
  • Build a flat-only theme: drop every backdrop block from the slots and route every surface-*-flat slot to a solid colors reference. Visual parity with the software backend is automatic.

All three recipes keep the slot ids and reference shapes intact, so the built-in widgets continue to work without code changes.