Packages
mob
0.7.8
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/theme.ex
defmodule Mob.Theme do
@moduledoc """
Design token system for Mob apps.
A theme is a compiled `%Mob.Theme{}` struct — a flat map of semantic tokens
for colors, spacing, radii, and scale factors. The renderer resolves these
tokens at render time so every component picks up the active theme
automatically.
## Using a named theme
Named themes are plain modules that export `theme/0`. Pass the module to
`use Mob.App`:
use Mob.App, theme: MobThemes.Obsidian # (the mob_themes style package)
Override individual tokens without leaving the theme:
use Mob.App, theme: {MobThemes.Obsidian, primary: :rose_500}
Anyone can publish a theme as a Hex package — any module with `theme/0`
returning a `Mob.Theme.t()` works:
use Mob.App, theme: AcmeCorp.BrandTheme
## Building a theme from scratch
Pass a keyword list of overrides against the neutral base:
use Mob.App, theme: [primary: :emerald_500, type_scale: 1.1]
Or change the theme at runtime (e.g. for accessibility or user preference):
Mob.Theme.set(MobThemes.Obsidian)
Mob.Theme.set({MobThemes.Obsidian, type_scale: 1.2})
Mob.Theme.set(primary: :pink_500)
## Base theme
When no theme is set the renderer uses the neutral base — plain dark grays
with a standard blue primary. Functional, not opinionated. Good enough for
hello world; swap in a named theme when you want personality.
## Token reference
### Semantic color tokens
:primary — main action colour (default :blue_500)
:on_primary — text/icons on primary (default :white)
:secondary — secondary action colour (default :gray_600)
:on_secondary — text/icons on secondary (default :white)
:background — page/screen background (default :gray_900)
:on_background — text on background (default :gray_100)
:surface — card / sheet background (default :gray_800)
:surface_raised — elevated card background (default :gray_700)
:on_surface — text/icons on surface (default :gray_100)
:muted — secondary/placeholder text (default :gray_500)
:error — error state colour (default :red_500)
:on_error — text/icons on error (default :white)
:border — dividers and outlines (default :gray_700)
### Spacing tokens (scaled by `space_scale`)
:space_xs → 4 × scale
:space_sm → 8 × scale
:space_md → 16 × scale
:space_lg → 24 × scale
:space_xl → 32 × scale
### Radius tokens
:radius_sm → theme.radius_sm (default 6)
:radius_md → theme.radius_md (default 10)
:radius_lg → theme.radius_lg (default 16)
:radius_pill → theme.radius_pill (default 100)
### Scale factors
type_scale: 1.0 # multiply all text sizes by this
space_scale: 1.0 # multiply all spacing tokens by this
"""
@type color_value :: atom() | non_neg_integer()
defstruct [
# ── Semantic colors ──────────────────────────────────────────────────────
primary: :blue_500,
on_primary: :white,
secondary: :gray_600,
on_secondary: :white,
surface: :gray_800,
surface_raised: :gray_700,
on_surface: :gray_100,
muted: :gray_500,
background: :gray_900,
on_background: :gray_100,
error: :red_500,
on_error: :white,
border: :gray_700,
# ── Scale factors ─────────────────────────────────────────────────────
type_scale: 1.0,
space_scale: 1.0,
# ── Corner radii (dp / pt) ─────────────────────────────────────────────
radius_sm: 6,
radius_md: 10,
radius_lg: 16,
radius_pill: 100,
# ── Material / effect flags ────────────────────────────────────────────
# When true, surface-style nodes (currently `Box` with a `background:` set)
# render with a translucent material instead of a solid fill:
#
# * iOS 26+: Liquid Glass via `.glassEffect()`
# * iOS 17–25: graceful fallback to `.ultraThinMaterial` background
# * Android: no-op (the flag is plumbed but Material 3's glassy-surface
# story isn't first-class yet — left as a follow-up)
#
# Off by default; opt in via a preset (`MobThemes.ObsidianGlass`, the mob_themes package) or by
# passing `glass: true` to `Mob.Theme.build/1`.
glass: false
]
@type t :: %__MODULE__{}
@spacing_base %{
space_xs: 4,
space_sm: 8,
space_md: 16,
space_lg: 24,
space_xl: 32
}
@doc """
Build a theme from a keyword list of overrides against the neutral base.
Mob.Theme.build(primary: :emerald_500, type_scale: 1.1)
"""
@spec build(keyword()) :: t()
def build(overrides \\ []), do: struct(__MODULE__, overrides)
@doc "Return the neutral base theme."
@spec default() :: t()
def default, do: %__MODULE__{}
@doc """
Set the active theme. Accepts:
- A compiled `%Mob.Theme{}` struct
- A theme module (any module exporting `theme/0`, e.g. `MobThemes.Obsidian`)
- A `{module, overrides}` tuple
- A keyword list of overrides against the neutral base
"""
@spec set(t() | module() | {module(), keyword()} | keyword()) :: :ok
def set(%__MODULE__{} = theme) do
Application.put_env(:mob, :theme, theme)
notify_native(theme)
:ok
end
def set(mod) when is_atom(mod) do
set(mod.theme())
end
def set({mod, overrides}) when is_atom(mod) and is_list(overrides) do
set(struct(mod.theme(), overrides))
end
def set(overrides) when is_list(overrides) do
set(build(overrides))
end
@doc "Return the currently active theme (or the neutral base if none is set)."
@spec current() :: t()
def current, do: Application.get_env(:mob, :theme, default())
@doc """
Returns the active theme's palette resolved to ARGB integers — semantic
tokens (`:primary`, `:on_surface`, …) walked through the theme's color
map and then through `Mob.Renderer.colors/0`. Used to push concrete
values to the native side (`Mob.Theme.set/1` does this automatically;
callers usually don't need to invoke this directly).
"""
@spec resolved_palette(t()) :: %{atom() => non_neg_integer()}
def resolved_palette(theme \\ current()) do
palette = Mob.Renderer.colors()
theme
|> color_map()
|> Map.new(fn {key, value} -> {key, resolve_color(value, palette)} end)
end
defp resolve_color(value, palette) when is_atom(value) do
case Map.get(palette, value) do
nil -> value
int -> int
end
end
defp resolve_color(value, _palette) when is_integer(value), do: value
defp resolve_color(value, _palette), do: value
# Push the resolved palette + theme flags to the native side so Compose
# MaterialTheme / SwiftUI environment can follow runtime theme changes.
# Wrapped in try/rescue/catch because the NIF isn't loaded on the host
# BEAM (tests, IEx without a device) and we don't want `Mob.Theme.set/1`
# to crash in those contexts.
defp notify_native(theme) do
payload = Map.put(resolved_palette(theme), :_glass, theme.glass)
json = IO.iodata_to_binary(:json.encode(stringify_keys(payload)))
try do
:mob_nif.set_theme(json)
rescue
_ -> :ok
catch
_, _ -> :ok
end
end
defp stringify_keys(map) do
Map.new(map, fn {k, v} -> {Atom.to_string(k), v} end)
end
@doc """
Returns the current OS appearance: `:light` or `:dark`.
Reads from the platform NIF (`UITraitCollection.userInterfaceStyle` on
iOS, `Configuration.uiMode & UI_MODE_NIGHT_MASK` on Android). Falls back
to `:light` when running on the host BEAM (no NIF loaded), on platforms
that don't expose appearance, or on legacy Android apps that haven't
added `MobBridge.getColorScheme()` yet.
"""
@spec color_scheme() :: :light | :dark
def color_scheme do
case :mob_nif.color_scheme() do
:dark -> :dark
_ -> :light
end
rescue
# NIF not loaded (host BEAM), wrong arity, or platform doesn't implement
_ -> :light
end
# ── Token maps (used by Mob.Renderer) ─────────────────────────────────────
@doc false
@spec color_map(t()) :: %{atom() => color_value()}
def color_map(%__MODULE__{} = t) do
%{
primary: t.primary,
on_primary: t.on_primary,
secondary: t.secondary,
on_secondary: t.on_secondary,
surface: t.surface,
surface_raised: t.surface_raised,
on_surface: t.on_surface,
muted: t.muted,
background: t.background,
on_background: t.on_background,
error: t.error,
on_error: t.on_error,
border: t.border
}
end
@doc false
@spec spacing_map(t()) :: %{atom() => non_neg_integer()}
def spacing_map(%__MODULE__{space_scale: scale}) do
Map.new(@spacing_base, fn {k, v} -> {k, round(v * scale)} end)
end
@doc false
@spec flags_map(t()) :: %{atom() => boolean()}
def flags_map(%__MODULE__{glass: glass}), do: %{glass: glass}
@doc false
@spec radius_map(t()) :: %{atom() => non_neg_integer()}
def radius_map(%__MODULE__{} = t) do
%{
radius_sm: t.radius_sm,
radius_md: t.radius_md,
radius_lg: t.radius_lg,
radius_pill: t.radius_pill
}
end
end