Current section

Files

Jump to
dala lib dala theme theme.ex
Raw

lib/dala/theme/theme.ex

defmodule Dala.Theme.Theme do
@compile {:nowarn_undefined, [:Nx]}
@moduledoc """
Design token system for Dala apps.
A theme is a compiled `%Dala.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 Dala.App`:
use Dala.App, theme: Dala.Theme.Obsidian
Override individual tokens without leaving the theme:
use Dala.App, theme: {Dala.Theme.Obsidian, primary: :rose_500}
Anyone can publish a theme as a Hex package — any module with `theme/0`
returning a `Dala.Theme.t()` works:
use Dala.App, theme: AcmeCorp.BrandTheme
## Building a theme from scratch
Pass a keyword list of overrides against the neutral base:
use Dala.App, theme: [primary: :emerald_500, type_scale: 1.1]
Or change the theme at runtime (e.g. for accessibility or user preference):
Dala.Theme.set(Dala.Theme.Obsidian)
Dala.Theme.set({Dala.Theme.Obsidian, type_scale: 1.2})
Dala.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
]
@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.
Dala.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 `%Dala.Theme{}` struct
- A theme module (`Dala.Theme.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(:dala, :theme, theme)
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(:dala, :theme, default())
@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 `dalaBridge.getColorScheme()` yet.
"""
@spec color_scheme() :: :light | :dark
def color_scheme do
case Dala.Platform.Native.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 Dala.Ui.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 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