///|
///|
pub(all) enum ColorScheme {
Light
Dark
} derive(Eq, Debug, ToJson)
///|
/// Full semantic color palette. Extended from the original 8-role palette to
/// the complete on*/container matrix used by Material 3 / Fluent 2 so a
/// branded design system can express primary/secondary/tertiary/error
/// container pairs and their on-colors instead of deriving them ad hoc with
/// `multiply_alpha`/`lerp`. Semantic colors (success/warning/danger/info) are
/// folded in directly; the separate `SemanticColorScale` is removed.
///
/// Role naming follows Compose Material 3 `ColorScheme`:
/// - `on_` is the text/icon color drawn on top of ``.
/// - `_container` is a tinted surface variant; `on__container`
/// is the text color on that container.
/// `on_foreground` is an explicit alias of `background` for symmetry.
pub(all) struct ColorPalette {
// Base surfaces.
foreground : Color
background : Color
surface : Color
surface_variant : Color
// Primary family.
primary : Color
on_primary : Color
primary_container : Color
on_primary_container : Color
// Secondary family.
secondary : Color
on_secondary : Color
secondary_container : Color
on_secondary_container : Color
// Tertiary family.
tertiary : Color
on_tertiary : Color
tertiary_container : Color
on_tertiary_container : Color
// Error family.
error : Color
on_error : Color
error_container : Color
on_error_container : Color
// Surface text roles.
on_foreground : Color
on_surface : Color
on_surface_variant : Color
// Outlines.
outline : Color
outline_variant : Color
muted : Color
// Semantic pairs (success/warning/danger/info with on-colors).
success : Color
on_success : Color
warning : Color
on_warning : Color
info : Color
on_info : Color
danger : Color
on_danger : Color
// Focus + scrim.
focus : Color
scrim : Color
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SpacingScale {
xs : Double
sm : Double
md : Double
lg : Double
xl : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct RadiusScale {
sm : Double
md : Double
lg : Double
pill : Double
} derive(Eq, Debug, ToJson)
///|
pub(all) struct TypographyScale {
/// Largest tier; page-level display numbers or hero headlines.
display : FontSpec
/// Section/page headlines.
headline : FontSpec
/// Card or panel titles.
title : FontSpec
/// Secondary title beneath a title or a list-section label.
subtitle : FontSpec
/// Default body copy.
body : FontSpec
/// Compact emphasized text for buttons, tabs, and form labels.
label : FontSpec
/// Small ancillary text. Kept as an alias-friendly name alongside `caption`;
/// `caption` is the historical field, `overline` adds an even smaller
/// uppercase-style tier for eyebrows and metadata labels.
caption : FontSpec
/// Tiny all-caps-style metadata label (eyebrows, table headers).
overline : FontSpec
/// Legacy alias for `label`: emphasized control text (buttons, tabs).
control : FontSpec
} derive(Eq, Debug, ToJson)
///|
pub(all) struct ShadowScale {
sm : ShadowStyle
md : ShadowStyle
lg : ShadowStyle
} derive(Eq, Debug, ToJson)
///|
pub(all) struct MotionScale {
fast_ms : Double
normal_ms : Double
slow_ms : Double
easing : Easing
} derive(Eq, Debug, ToJson)
///|
pub(all) struct SurfaceScale {
base : Brush
raised : Brush
overlay : Brush
} derive(Eq, Debug, ToJson)
///|
pub(all) enum SurfaceLevel {
Base
Raised
Overlay
} derive(Eq, Debug, ToJson)
///|
pub(all) enum LayoutDirection {
LeftToRight
RightToLeft
} derive(Eq, Debug, ToJson)
///|
pub(all) enum AccessibilityContrast {
Standard
High
} derive(Eq, Debug, ToJson)
///|
pub(all) enum ContentSizeCategory {
Small
Medium
Large
ExtraLarge
Accessibility
} derive(Eq, Debug, ToJson)
///|
/// Resolved, drawable neutral theme. A `Theme` is the output of
/// `resolve_theme(spec, system_scheme)`: it carries the palette, all scale
/// tiers, surfaces, and motion — the **neutral** theme base. Concrete
/// control theme tokens (`ButtonTheme`, `TextFieldTheme`, …) are **not** on
/// `Theme`; they live in `moui/views` as `ControlThemeSet` (ADR 0017) so
/// `core` carries no control vocabulary. Controls read a `ControlThemeSet`
/// resolved views-side from this neutral `Theme` at paint time; `style?` on
/// a control constructor is a one-shot override.
pub(all) struct Theme {
scheme : ColorScheme
palette : ColorPalette
spacing_scale : SpacingScale
radius_scale : RadiusScale
typography : TypographyScale
shadow_scale : ShadowScale
motion : MotionScale
surfaces : SurfaceScale
} derive(Eq, Debug, ToJson)
///|
/// Runtime environment snapshot. Carries the user's `theme_spec` (intent), the
/// current `system_scheme` (host-reported), and the resolved `theme` derived
/// from both. Changing `system_scheme` rebuilds the full theme via
/// `with_system_scheme`; the legacy `with_color_scheme` (which only flipped a
/// flag and left the palette stale) is removed.
pub(all) struct Environment {
theme_spec : ThemeSpec
system_scheme : ColorScheme
theme : Theme
scale_factor : Double
text_scale : Double
locale : String
layout_direction : LayoutDirection
accessibility_contrast : AccessibilityContrast
reduced_motion : Bool
content_size_category : ContentSizeCategory
enabled : Bool
} derive(Eq, Debug, ToJson)
// ---------------------------------------------------------------------------
// ColorPalette factories
// ---------------------------------------------------------------------------
///|
/// Build a palette from a primary color and scheme, deriving every on*/container
/// role programmatically. This is the shared derivation used by both the
/// Minimal preset and (optionally) branded addons that want a seed-driven
/// palette without hand-writing 30+ colors.
///
/// Derivation rules:
/// - `on_primary`/`on_secondary`/`on_tertiary`/`on_error` = white on dark
/// primaries, near-black on light primaries (contrast-aware).
/// - `_container` = the role color lerped toward the surface by ~0.85
/// (a soft tint); `on__container` = the role color darkened (light) or
/// lightened (dark) for legibility on the tint.
/// - `secondary`/`tertiary` default to a desaturated primary analog so the
/// palette stays coherent when only a seed is supplied.
pub fn ColorPalette::from_seed(
primary~ : Color,
scheme : ColorScheme,
foreground? : Color,
background? : Color,
surface? : Color,
surface_variant? : Color,
outline? : Color,
muted? : Color,
) -> ColorPalette {
let (fg, bg, surf, surf_var, outline_color, muted_color) = match scheme {
Light =>
(
foreground.unwrap_or(Color::rgba(r=0.145, g=0.145, b=0.149)),
background.unwrap_or(Color::rgba(r=1.0, g=1.0, b=1.0)),
surface.unwrap_or(Color::rgba(r=0.984, g=0.984, b=0.988)),
surface_variant.unwrap_or(Color::rgba(r=0.957, g=0.957, b=0.961)),
outline.unwrap_or(Color::rgba(r=0.898, g=0.898, b=0.906)),
muted.unwrap_or(Color::rgba(r=0.553, g=0.553, b=0.588)),
)
Dark =>
(
foreground.unwrap_or(Color::rgba(r=0.953, g=0.953, b=0.953)),
background.unwrap_or(Color::rgba(r=0.090, g=0.090, b=0.090)),
surface.unwrap_or(Color::rgba(r=0.145, g=0.145, b=0.145)),
surface_variant.unwrap_or(Color::rgba(r=0.212, g=0.212, b=0.212)),
outline.unwrap_or(Color::rgba(r=0.290, g=0.290, b=0.290)),
muted.unwrap_or(Color::rgba(r=0.643, g=0.643, b=0.643)),
)
}
// Contrast-aware on-color for a fill: white on dark fills, near-black on
// light fills. Uses perceived luminance as the threshold.
fn on_for(fill : Color) -> Color {
let lum = fill.r * 0.299 + fill.g * 0.587 + fill.b * 0.114
if lum < 0.55 {
Color::white()
} else {
Color::rgba(r=0.10, g=0.10, b=0.12)
}
}
// Container tint: fill lerped toward the surface by 0.85.
fn container_for(fill : Color) -> Color {
fill.lerp(surf, 0.85)
}
// On-container: fill darkened (light scheme) / lightened (dark scheme) for
// legibility on the soft tint.
fn on_container_for(fill : Color) -> Color {
match scheme {
Light => fill.darken(0.30)
Dark => fill.lighten(0.35)
}
}
let secondary = primary.lerp(surf, 0.45)
let tertiary = primary.lerp(Color::rgba(r=0.55, g=0.30, b=0.70), 0.40)
let error = match scheme {
Light => Color::rgba(r=0.76, g=0.16, b=0.18)
Dark => Color::rgba(r=1.0, g=0.52, b=0.50)
}
let success = match scheme {
Light => Color::rgba(r=0.10, g=0.50, b=0.30)
Dark => Color::rgba(r=0.38, g=0.76, b=0.50)
}
let warning = match scheme {
Light => Color::rgba(r=0.74, g=0.46, b=0.08)
Dark => Color::rgba(r=0.98, g=0.78, b=0.36)
}
let info = primary
let danger = error
let focus = primary
let scrim = match scheme {
Light => Color::rgba(r=0.0, g=0.0, b=0.0, a=0.38)
Dark => Color::rgba(r=0.0, g=0.0, b=0.0, a=0.62)
}
let outline_variant = match scheme {
Light => outline_color.lerp(surf, 0.55)
Dark => outline_color.lerp(surf, 0.45)
}
{
foreground: fg,
background: bg,
surface: surf,
surface_variant: surf_var,
primary,
on_primary: on_for(primary),
primary_container: container_for(primary),
on_primary_container: on_container_for(primary),
secondary,
on_secondary: on_for(secondary),
secondary_container: container_for(secondary),
on_secondary_container: on_container_for(secondary),
tertiary,
on_tertiary: on_for(tertiary),
tertiary_container: container_for(tertiary),
on_tertiary_container: on_container_for(tertiary),
error,
on_error: on_for(error),
error_container: container_for(error),
on_error_container: on_container_for(error),
on_foreground: bg,
on_surface: fg,
on_surface_variant: muted_color,
outline: outline_color,
outline_variant,
muted: muted_color,
success,
on_success: on_for(success),
warning,
on_warning: on_for(warning),
info,
on_info: on_for(info),
danger,
on_danger: on_for(danger),
focus,
scrim,
}
}
///|
/// Minimal light palette (zinc neutrals, near-black primary). Equivalent to
/// the legacy hand-written palette but now fills the full on*/container matrix
/// via `from_seed`.
pub fn ColorPalette::light() -> ColorPalette {
ColorPalette::from_seed(
primary=Color::rgba(r=0.145, g=0.145, b=0.149),
ColorScheme::Light,
)
}
///|
/// Minimal dark palette (zinc neutrals, near-white primary). Mirrors light.
pub fn ColorPalette::dark() -> ColorPalette {
ColorPalette::from_seed(
primary=Color::rgba(r=0.953, g=0.953, b=0.953),
ColorScheme::Dark,
)
}
// ---------------------------------------------------------------------------
// Scale defaults
// ---------------------------------------------------------------------------
///|
pub fn SpacingScale::default() -> SpacingScale {
{ xs: 4.0, sm: 8.0, md: 12.0, lg: 16.0, xl: 24.0 }
}
///|
pub fn RadiusScale::default() -> RadiusScale {
// shadcn/minimal: sharp, small radii. Controls read as crisp rectangles,
// not soft pills. `pill` retained for chips/badges that need full rounding.
{ sm: 4.0, md: 6.0, lg: 8.0, pill: 999.0 }
}
///|
pub fn TypographyScale::default() -> TypographyScale {
let label = FontSpec::new(size=16.0, weight=600)
{
display: FontSpec::new(size=38.0, weight=600),
headline: FontSpec::new(size=27.0, weight=600),
title: FontSpec::new(size=21.0, weight=600),
subtitle: FontSpec::new(size=18.0, weight=600),
body: FontSpec::new(size=16.0, weight=400),
label,
caption: FontSpec::new(size=13.0, weight=400),
overline: FontSpec::new(size=11.0, weight=600),
// `control` is the historical name for emphasized control text; alias it
// to `label` so existing callers keep the same visuals.
control: label,
}
}
///|
pub fn ShadowScale::default(
scheme? : ColorScheme = ColorScheme::Light,
) -> ShadowScale {
// shadcn/minimal: near-zero shadows by default. Cards and buttons rely on
// borders, not elevation. `sm` stays flat so controls read as crisp
// rectangles. `md` carries a hair of depth so floating mid layers (picker
// popups, feedback panels) separate without imposing Material-style
// elevation. `lg` (popovers/dialogs/menus) uses a soft, diffuse shadow so
// floating layers lift gently rather than reading as a hard drop.
let alpha = match scheme {
Light => 0.06
Dark => 0.50
}
{
sm: ShadowStyle::new(
color=Color::rgba(r=0.0, g=0.0, b=0.0, a=alpha * 0.5),
offset=Point::new(x=0.0, y=0.0),
blur_radius=0.0,
),
md: ShadowStyle::new(
color=Color::rgba(r=0.0, g=0.0, b=0.0, a=alpha),
offset=Point::new(x=0.0, y=2.0),
blur_radius=8.0,
),
lg: ShadowStyle::new(
color=Color::rgba(r=0.0, g=0.0, b=0.0, a=alpha * 1.4),
offset=Point::new(x=0.0, y=2.0),
blur_radius=16.0,
spread=0.0,
),
}
}
///|
pub fn MotionScale::default() -> MotionScale {
{
fast_ms: 120.0,
normal_ms: 180.0,
slow_ms: 280.0,
easing: Easing::EaseInOut,
}
}
///|
pub fn SurfaceScale::default(
palette : ColorPalette,
scheme : ColorScheme,
) -> SurfaceScale {
ignore(scheme)
// shadcn/minimal: flat solid surfaces, no gradients. Layer separation comes
// from border tone, not surface sheen. Raised = surface, overlay = variant.
{
base: Brush::solid(palette.surface),
raised: Brush::solid(palette.surface),
overlay: Brush::solid(palette.surface_variant),
}
}
// ---------------------------------------------------------------------------
// Theme factories
// ---------------------------------------------------------------------------
///|
/// Neutral fallback theme. Equivalent to resolving `ThemeSpec::default()` with
/// a light system scheme. Carries the neutral palette + scales only; concrete
/// control tokens are resolved views-side as a `ControlThemeSet` (ADR 0017).
pub fn Theme::neutral() -> Theme {
resolve_minimal_theme(ThemeSpec::minimal_light(), ColorScheme::Light)
}
///|
pub fn Theme::with_palette(self : Theme, palette : ColorPalette) -> Theme {
let surfaces = SurfaceScale::default(palette, self.scheme)
{ ..self, palette, surfaces }
}
///|
pub fn Theme::with_spacing_scale(
self : Theme,
spacing_scale : SpacingScale,
) -> Theme {
{ ..self, spacing_scale, }
}
///|
pub fn Theme::with_radius_scale(
self : Theme,
radius_scale : RadiusScale,
) -> Theme {
{ ..self, radius_scale, }
}
///|
pub fn Theme::with_typography(
self : Theme,
typography : TypographyScale,
) -> Theme {
{ ..self, typography, }
}
///|
pub fn Theme::with_shadow_scale(
self : Theme,
shadow_scale : ShadowScale,
) -> Theme {
{ ..self, shadow_scale, }
}
///|
pub fn Theme::with_motion(self : Theme, motion : MotionScale) -> Theme {
{ ..self, motion, }
}
///|
pub fn Theme::with_surfaces(self : Theme, surfaces : SurfaceScale) -> Theme {
{ ..self, surfaces, }
}
///|
pub fn Theme::surface_brush(self : Theme, level : SurfaceLevel) -> Brush {
match level {
Base => self.surfaces.base
Raised => self.surfaces.raised
Overlay => self.surfaces.overlay
}
}
// ---------------------------------------------------------------------------
// Environment factories
// ---------------------------------------------------------------------------
///|
/// Neutral environment: Minimal theme following a light system scheme, standard
/// density/contrast, full motion. The `theme_spec` records the intent so a
/// later `ThemeChanged(Dark)` event rebuilds the full dark theme instead of
/// leaving a stale light palette.
pub fn Environment::neutral() -> Environment {
let spec = ThemeSpec::default()
let theme = resolve_minimal_theme(spec, ColorScheme::Light)
{
theme_spec: spec,
system_scheme: ColorScheme::Light,
theme,
scale_factor: 1.0,
text_scale: 1.0,
locale: "en-US",
layout_direction: LayoutDirection::LeftToRight,
accessibility_contrast: AccessibilityContrast::Standard,
reduced_motion: false,
content_size_category: ContentSizeCategory::Medium,
enabled: true,
}
}
///|
/// Replace the theme. Also updates `system_scheme` to match the theme's scheme
/// so the two stay consistent. Use `with_theme_spec` + `with_system_scheme`
/// for the resolver-driven path; this helper is for callers that already have
/// a fully resolved `Theme` (e.g. a branded addon that built one directly).
pub fn Environment::with_theme(
self : Environment,
theme : Theme,
) -> Environment {
{ ..self, theme, system_scheme: theme.scheme }
}
///|
/// Replace the system color scheme and rebuild the full theme from
/// `theme_spec`. This is the host-facing entry point: a `ThemeChanged(Dark)`
/// event calls this so the palette/surfaces/shadows actually switch to dark,
/// fixing the old bug where only the scheme flag flipped.
pub fn Environment::with_system_scheme(
self : Environment,
system_scheme : ColorScheme,
) -> Environment {
let theme = resolve_theme(self.theme_spec, system_scheme)
{ ..self, system_scheme, theme }
}
///|
/// Replace the theme spec and rebuild the full theme against the current
/// system scheme. App code calls this when the user picks a new preset,
/// density, or contrast.
pub fn Environment::with_theme_spec(
self : Environment,
theme_spec : ThemeSpec,
) -> Environment {
let theme = resolve_theme(theme_spec, self.system_scheme)
{ ..self, theme_spec, theme }
}
///|
/// Read the effective color scheme (from the resolved theme). Kept as a
/// convenience accessor so callers do not have to reach into `theme.scheme`.
pub fn Environment::color_scheme(self : Environment) -> ColorScheme {
self.theme.scheme
}
///|
pub fn Environment::with_scale_factor(
self : Environment,
scale_factor : Double,
) -> Environment {
{ ..self, scale_factor: max_double(0.1, scale_factor) }
}
///|
pub fn Environment::with_text_scale(
self : Environment,
text_scale : Double,
) -> Environment {
{ ..self, text_scale: max_double(0.1, text_scale) }
}
///|
pub fn Environment::with_locale(
self : Environment,
locale : String,
) -> Environment {
{ ..self, locale, }
}
///|
pub fn Environment::with_layout_direction(
self : Environment,
layout_direction : LayoutDirection,
) -> Environment {
{ ..self, layout_direction, }
}
///|
/// Update accessibility contrast. Also updates `theme_spec.contrast` so a
/// subsequent system-scheme change re-resolves with the new contrast.
pub fn Environment::with_accessibility_contrast(
self : Environment,
accessibility_contrast : AccessibilityContrast,
) -> Environment {
let spec = self.theme_spec.with_contrast(accessibility_contrast)
self.with_theme_spec(spec)
}
///|
/// Update reduced motion. Also updates `theme_spec.reduced_motion` so a
/// subsequent re-resolve zeroes motion durations.
pub fn Environment::with_reduced_motion(
self : Environment,
reduced_motion : Bool,
) -> Environment {
let spec = self.theme_spec.with_reduced_motion(reduced_motion)
self.with_theme_spec(spec)
}
///|
pub fn Environment::with_content_size_category(
self : Environment,
content_size_category : ContentSizeCategory,
) -> Environment {
{
..self,
content_size_category,
text_scale: content_size_category.text_scale(),
}
}
///|
pub fn Environment::with_enabled(
self : Environment,
enabled : Bool,
) -> Environment {
{ ..self, enabled, }
}
///|
pub fn ContentSizeCategory::text_scale(self : ContentSizeCategory) -> Double {
match self {
Small => 0.9
Medium => 1.0
Large => 1.15
ExtraLarge => 1.3
Accessibility => 1.6
}
}
///|
pub fn ViewStyle::from_theme(theme : Theme) -> ViewStyle {
{
font: Some(theme.typography.body),
foreground: Some(theme.palette.foreground),
background: Some(theme.surface_brush(SurfaceLevel::Base)),
corner_radius: Some(theme.radius_scale.md),
}
}