# ltk theming

`ltk` loads a JSON theme document lazily — on the first theme accessor
call, not 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/<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 ordered list is queryable at runtime via `ltk::theme::search_paths()`.

The directory tree:

```text
<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
│   ├── apps/                   per-application icons (firefox.svg, …)
│   │   └── default.svg         generic app icon shipped with the set
│   └── catalogue/              symbolic glyph catalogue
│       ├── filled/             solid silhouettes — preferred by default
│       │   └── <category>/     general, system, window, …
│       └── line/               outlined variants (same names)
├── cursors/                    X cursor files (consumed by the
└── cursor.theme                compositor, not parsed by ltk itself)
```

Note a known mismatch: the `theme_app_default_icon()` fallback looks
for `icons/app-default.svg`, a path the shipped default theme does not
provide (its generic icon lives at `icons/apps/default.svg`), so that
fallback never resolves against the default theme.

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` should match the directory name by convention — lookup goes
by directory name and the id inside the JSON is not checked against it.
`theme.name` is shown in any theme-picker UI a shell builds on top of
`ltk`.

The other five sections are described below in order. The parser is
strict (`deny_unknown_fields`) at most levels — the document root,
`fonts`, `modes`, gradient stops, shadow entries — so typos there are
an error. It is *not* strict everywhere: unknown keys directly inside a
slot entry or inside a `meta` block are silently ignored, and the
`surface` / `typography` slot bodies lose the check to serde's
`#[serde(flatten)]`.

## `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.

Sources that fail to read are skipped with a stderr warning; when the
whole block yields nothing, text falls back to the embedded Sora
Regular (~50 KB, OFL 1.1). Missing fonts never trigger the red warning
banner — that banner is reserved for the case where the `default`
theme document itself cannot be found (see [Common
errors](#common-errors)). The registry that backs this is built by
`ltk::theme::build_font_registry()`, which the draw loop already calls
at canvas creation time.

## `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.

Inline colour values elsewhere in the document (slot values, gradient
stops, shadow colours, window-controls tokens) additionally accept the
functional forms `rgb(R, G, B)` and `rgba(R, G, B, A)` — R/G/B as
integers or floats in `0..=255`, A as a float in `0.0..=1.0`. The
top-level `colors` map is the asymmetric exception: its entries must
be hex literals; a functional form there is rejected.

## `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`
(alias: `position`; 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 `"linear-rgb"` (interpolate in linear-light space — the
default, physically correct and fast), `"srgb"` (interpolate in
gamma-encoded sRGB; reproduces designs authored against it, but
midpoints of saturated gradients look muddy) or `"oklab"` (perceptual;
best for high-chroma brand gradients, slightly more expensive).
`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`,
`overlay`, `multiply`, or `screen`).

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

### Responsive sizing

Pixel values in a theme are *logical* design pixels: they resolve
through the `Length` / `WidgetScaling` system (`Length::dp` for
constant-physical sizes, `Length::fluid` for surface-proportional
ones, `set_density`, and `Canvas::geom_px` / `Canvas::font_px` at draw
time) — see
[`docs/architecture.md#responsive-sizing`](./architecture.md#responsive-sizing).
For text, the `theme::typography` module ships the `H0`…`BODY_XS` px
constants plus the fluid `h0()`…`body_xs()` `Length` builders that
scale with the surface's smaller dimension.

## `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"`, `"tile"` (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": {
  "bar_bg":         "@off-white",
  "icon":           "#5F5F68",
  "hover_bg":       "@navy/14",
  "pressed_bg":     "@navy/24",
  "close_hover_bg": "@danger",
  "close_icon":     "@white",
  "focus_ring":     "@teal"
}
```

`bar_bg` is the background fill of the server-side-decoration title
bar strip the buttons sit on; the six remaining keys style the buttons
themselves.

Consumed by the [`window_button`](../src/widget/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`](#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.

Six slot variants: `color`, `linear`, `radial`, `shadows`, `surface`
and `typography`.

#### `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/<role>` for the palette layer and
`effect/<group>/<name>` for everything else. Besides `semantic`, `meta`
also carries three optional free-form fields the runtime ignores:
`fluent` (equivalent token name in another design system), `usage`
(guidance on where to use the slot) and `note`.

#### `linear` / `radial`

```json
"chip-active": {
  "type":      "linear",
  "angle_deg": 90,
  "stops": [
    { "pos": 0, "color": "@cyan" },
    { "pos": 1, "color": "@teal" }
  ]
}
```

Inline gradient slots — the same fields as a [`gradients`](#gradients)
entry (`angle_deg` + `stops` + optional `space` for `linear`; `center` +
`radius` + `stops` + optional `space` for `radial`), declared directly
in the slot table instead of being named and referenced. Resolved by
widgets via [`theme_paint( id )`], which also promotes plain `color`
slots to a solid paint.

#### `typography`

```json
"body-m": {
  "type":        "typography",
  "family":      { "ref": "sora" },
  "weight":      400,
  "size":        16,
  "line_height": { "px": 24 }
}
```

A resolved text style, looked up via [`theme_text_style( id )`].
`family.ref` names an entry of the top-level [`fonts`](#fonts) block;
`weight` and `size` are required. `line_height` is either `{ "px": n }`
(absolute) or `{ "mul": n }` (multiplier of the size). Optional fields
with defaults: `style` (`normal` / `italic`), `letter_spacing`
(`0`), `transform` (`none` / `uppercase` / `lowercase` /
`capitalize`), `decoration` (`none` / `underline` / `strikethrough`)
and `color` (a slot-id string).

#### `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",
  "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.

## References

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

- `@cyan` — looks up `gradients.cyan` / `inset_stacks.cyan` first,
  then `colors.cyan`. A name defined in both `gradients` and
  `inset_stacks` is an error; a name defined in both `colors` and one
  of the token sections is *not* — the token silently wins, so avoid
  reusing names across sections. 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 |
| `danger` | destructive / error foreground | color |
| `danger-bg` | soft fill behind error states | color |

The default theme defines `danger` but not `danger-bg`, which
therefore resolves to the built-in pink-wash default from the palette
projection.

**Effects (optional):**

| id | consumer | type |
| --- | --- | --- |
| `surface-slider-track` | `Slider` / `VSlider` track background | surface |
| `surface-slider-fill` | `Slider` / `VSlider` filled portion | surface |
| `surface-card` | no built-in widget reads it automatically — apps opt in via `Container::surface("surface-card")` | surface |
| `shadows-glass` | referenced from the theme's own surface slots; no direct widget consumer | shadows |
| `surface-panel`, `surface-toggle-active`, `surface-card-flat`, `surface-slider-track-flat`, `surface-slider-fill-flat` | reserved — currently unconsumed by any built-in widget | surface |

Only the slider track / fill pair is looked up by widgets on their
own. The `-flat` variants are *not* wired to the software backend
automatically; a caller can pass one explicitly (e.g.
`Slider::track_surface("surface-slider-track-flat")`), but nothing in
`ltk` selects them per backend.

## 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<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). |
| `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. |
| `theme_icon_tinted( name, size, tint )` | `Option<ImageData>` | One-call rasterise + tint (`theme_icon_rgba` + `tint_symbolic`). |
| `decode_svg_bytes( bytes, size )` | `Option<( Arc<Vec<u8>>, u32, u32 )>` | Rasterise arbitrary SVG bytes (uncached, no path resolution); the primitive under `theme_icon_rgba`. |
| `active_document()` | `Arc<ThemeDocument>` | The whole loaded document, for slot-typed lookups the per-slot helpers do not cover (e.g. iterating slots). |
| `active_theme_id()` | `String` | Id of the active theme. |
| `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 );
```

`ThemeDocument::find( id )` walks the standard search paths;
`ThemeDocument::load_from_dir( dir )` loads a specific directory
(useful for previews or tests). Once loaded, `doc.mode( ThemeMode::Dark
)` projects the per-mode content without installing the document.

For the phone-style "auto" setting, `ThemePreference` (`Light` /
`Dark` / `Auto`) is the shell-side persisted preference:
`pref.resolve( hour )` maps it to a concrete `ThemeMode`, with `Auto`
delegating to `ThemeMode::from_hour( hour )` — dark between 19:00 and
06:59 local time, light otherwise. The toolkit itself only ever sees
the resolved `ThemeMode`.

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().<field>` for the ten 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.<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

```rust
// 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. Note the catalogue SVGs' viewBoxes are cropped to the
   ink, not to a uniform grid: the rasteriser scales the *longest*
   edge to the requested size preserving aspect ratio, so a non-square
   glyph comes out smaller than `size` on its other axis.
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

```rust,no_run
# 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 );

// Or the one-call form combining both steps:
let image = ltk::theme_icon_tinted( "window/close", 16, 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. `tint_symbolic`
multiplies the source alpha by `tint.a`, so a translucent tint
attenuates the whole glyph rather than just recolouring it.

## 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

```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_<n>` is indexed from each locale's first day of week
(Sunday-first in `en`, Monday-first in `es` / `fr` / `it` / `de` /
`pt` / `pt_BR`). The `Locale` struct's `first_dow` field is
crate-private; apps pick a start day through the public
`Locale::MONDAY_FIRST` / `Locale::SUNDAY_FIRST` constants 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 theme**: route the `surface-*` slots to solid
  `colors` references and drop the shadow / inset decorations. A
  `color` slot promotes to a plain surface automatically, so the
  built-in widgets keep working with the same ids.

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