# 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`](./architecture.md). This file is the schema reference. ## File layout A theme is a directory under one of: 1. `LTK_THEMES_DIR//` — only when the env var is set; intended for development. 2. `$XDG_DATA_HOME/ltk/themes//` (defaults to `~/.local/share/ltk/themes//`). 3. `/usr/share/ltk/themes//` — system-wide install path (`ltk-theme-default` Debian package). The directory tree: ```text / ├── 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 │ └── / general, system, window, … └── line/ outlined variants (same names) ``` Branded assets in `branding/` and icons in `icons/` are picked up by convention — see [Branding assets](#branding-assets) and [Icons](#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 ```json { "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` ```json "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](#references): ```json "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`). ```json "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: ```json "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](#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: ```json "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` ```json "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: ```json "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`](../src/widget/window_button.rs) 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`](#icons) 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` ```json "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/` for the palette layer and `effect//` for everything else. #### `shadows` ```json "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` ```json "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. ```rust,no_run # use ltk::{ column, container, text, Element }; # #[ derive( Clone ) ] enum Msg {} # struct MyApp; # impl MyApp { fn view( &self ) -> Element { let palette = ltk::theme_palette(); column::() .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` | 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` | Slot may be a colour or a gradient — promotes a colour to `Paint::Solid` automatically. | | `theme_surface( id )` | `Option` | Surface slot (fill + shadows + insets + backdrop). | | `theme_resolve_surface( id )` | `Option<( Surface, Vec )>` | 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>` | Outer shadow stack. | | `theme_text_style( id )` | `Option` | Typography slot (size, weight, line-height). | | `theme_window_controls()` | `WindowControlsSpec` | Per-mode chrome tokens for the title-bar buttons. | | `theme_wallpaper()` / `theme_lockscreen()` | `Option` | Full-screen branded images (SVG), with the convention fallback chain. | | `theme_branding_image( name, sw, sh )` | `Option` | 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` | Raster-only variant of the above; returns `None` only when no rasters exist at all. | | `theme_branding_asset( name, ext )` | `Option` | Generic branded asset lookup (any extension). Powers `theme_launcher_icon`, `theme_wallpaper`, `theme_lockscreen`. | | `theme_launcher_icon()` | `Option` | Launcher logo SVG path, with the convention fallback. | | `theme_logo()` | `Option` | Primary brand logo SVG (`branding/{mode}/logo/logo.svg`) — about dialogs, splash screens. | | `theme_logo_square()` | `Option` | Square 1:1 logo variant (`logo/square.svg`) — app icons, login avatars, lockscreen badges. | | `theme_logo_horizontal()` | `Option` | Wordmark logo variant (`logo/horizontal.svg`) — header bars, sign-in screens. | | `theme_app_icon( name )` / `theme_app_default_icon()` | `Option` | Per-app icons under `icons/apps/`. | | `theme_icon_path( "category/name" )` | `Option` | Catalogue icon path (filled-then-line lookup). | | `theme_icon_rgba( "category/name", size )` | `Option<( Arc>, 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: ```rust,no_run // 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: ```rust,no_run # 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().` 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 `PathBuf`s 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 ```text 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.` (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.` filename (`` ∈ {`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 ```rust // SVG resolution — mode-aware fallback chain on the file path. let launcher = ltk::theme_launcher_icon(); // Option let wallpaper = ltk::theme_wallpaper(); // Option let lockscreen = ltk::theme_lockscreen(); // Option // Sized raster (WebP / PNG / JPEG) — pick the smallest covering variant. let raster = ltk::theme_branding_raster( "wallpaper", 1920, 1080 ); // -> Option // 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 — primary let app_icon = ltk::theme_logo_square(); // Option — 1:1 let header_lg = ltk::theme_logo_horizontal(); // Option — 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/", "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//.svg`. Convention: `` at the root and an explicit fill on the path (e.g. ``). 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//.svg`. 3. Reference it from widget code by its slash-separated stem, no extension: `theme::icon_path( "general/down-simple" )`. ### Runtime API ```rust,no_run # fn _ex() -> Option<()> { // Path resolution. Tries filled/.svg first, then line/.svg. let path = ltk::theme::icon_path( "window/close" ); // -> Option // 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/.yaml`. English is the fallback. Currently shipped: `en`, `es`, `fr`, `it`, `de`, `pt`, `pt_BR`. ### Existing keys ```yaml 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_` 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/.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( "" )`. ## 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//.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.