Source code for rational.styling.theme

"""Themes: semantic design tokens compiled to CSS custom properties."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from typing import Literal

ThemeMode = Literal["light", "dark", "system"]

COLOR_TOKENS = (
    "primary",
    "primary_hover",
    "on_primary",
    "secondary",
    "secondary_hover",
    "on_secondary",
    "background",
    "on_background",
    "surface",
    "surface_alt",
    "on_surface",
    "border",
    "muted",
    "danger",
    "danger_hover",
    "on_danger",
    "success",
    "warning",
    "focus",
)

SPACE = {"none": "0", "xs": "4px", "sm": "8px", "md": "16px", "lg": "24px", "xl": "32px", "2xl": "48px", "3xl": "64px"}
RADIUS = {"none": "0", "sm": "4px", "md": "8px", "lg": "12px", "xl": "20px", "full": "9999px"}
FONT_SIZE = {
    "xs": "0.75rem",
    "sm": "0.875rem",
    "md": "1rem",
    "lg": "1.125rem",
    "xl": "1.375rem",
    "2xl": "1.75rem",
    "3xl": "2.25rem",
    "4xl": "3rem",
}
FONTS = {
    "body": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
    "heading": "inherit",
    "mono": 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace',
}
SHADOWS = {
    "none": "none",
    "sm": "0 1px 2px rgb(0 0 0 / 0.06)",
    "md": "0 2px 8px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04)",
    "lg": "0 12px 32px rgb(0 0 0 / 0.12), 0 2px 6px rgb(0 0 0 / 0.06)",
}


[docs] @dataclass(frozen=True) class Theme: name: str light: Mapping[str, str] dark: Mapping[str, str] space: Mapping[str, str] = field(default_factory=lambda: dict(SPACE)) radius: Mapping[str, str] = field(default_factory=lambda: dict(RADIUS)) font_size: Mapping[str, str] = field(default_factory=lambda: dict(FONT_SIZE)) fonts: Mapping[str, str] = field(default_factory=lambda: dict(FONTS)) shadows: Mapping[str, str] = field(default_factory=lambda: dict(SHADOWS)) motion: str = "160ms" density: float = 1.0 def __post_init__(self) -> None: for mode in ("light", "dark"): missing = set(COLOR_TOKENS) - set(getattr(self, mode)) if missing: raise ValueError(f"theme {self.name!r} ({mode}) is missing colors: {sorted(missing)}")
[docs] def with_colors(self, *, light: Mapping[str, str] | None = None, dark: Mapping[str, str] | None = None) -> Theme: """Return a copy with some color tokens overridden (or new ones added).""" return replace(self, light={**self.light, **(light or {})}, dark={**self.dark, **(dark or {})})
@property def color_names(self) -> frozenset[str]: return frozenset(self.light) | frozenset(self.dark) def css(self, mode: ThemeMode = "system") -> str: def colors(values: Mapping[str, str]) -> str: return "".join(f"--rw-color-{_dash(k)}:{v};" for k, v in values.items()) scales = "".join( [f"--rw-space-{k}:{_scale(v, self.density)};" for k, v in self.space.items()] + [f"--rw-radius-{k}:{v};" for k, v in self.radius.items()] + [f"--rw-font-size-{k}:{v};" for k, v in self.font_size.items()] + [f"--rw-font-{k}:{v};" for k, v in self.fonts.items()] + [f"--rw-shadow-{k}:{v};" for k, v in self.shadows.items()] + [f"--rw-motion:{self.motion};"] ) light, dark = colors(self.light), colors(self.dark) parts = [f":root{{{scales}{light}color-scheme:light;}}"] if mode == "system": parts.append( f"@media (prefers-color-scheme: dark){{:root:not([data-theme=light]){{{dark}color-scheme:dark;}}}}" ) parts.append(f":root[data-theme=dark]{{{dark}color-scheme:dark;}}") parts.append(f":root[data-theme=light]{{{light}color-scheme:light;}}") return "".join(parts) + BASE_CSS
def _dash(name: str) -> str: return name.replace("_", "-") def _scale(value: str, density: float) -> str: if density == 1.0 or not value.endswith("px"): return value return f"{round(float(value[:-2]) * density, 2):g}px" BASE_CSS = ( "*,*::before,*::after{box-sizing:border-box}" "html{-webkit-text-size-adjust:100%;text-size-adjust:100%}" "body{margin:0;min-height:100vh;font-family:var(--rw-font-body);font-size:var(--rw-font-size-md);" "line-height:1.55;background:var(--rw-color-background);color:var(--rw-color-on-background);" "-webkit-font-smoothing:antialiased}" "h1,h2,h3,h4,h5,h6{font-family:var(--rw-font-heading);line-height:1.2;margin:0}" "p{margin:0}" "img,svg,video{display:block;max-width:100%;height:auto}" "button,input,select,textarea{font:inherit;color:inherit}" "a{color:var(--rw-color-primary)}" ":focus-visible{outline:2px solid var(--rw-color-focus);outline-offset:2px}" "[hidden]{display:none!important}" "[aria-invalid=true]{border-color:var(--rw-color-danger)!important}" ".rw-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;" "clip:rect(0,0,0,0);white-space:nowrap;border:0}" "[aria-busy=true]{cursor:progress}" "@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:0.01ms!important;" "animation-iteration-count:1!important;transition-duration:0.01ms!important;scroll-behavior:auto!important}}" ) MODERN = Theme( name="modern", light={ "primary": "#4f46e5", "primary_hover": "#4338ca", "on_primary": "#ffffff", "secondary": "#eef2ff", "secondary_hover": "#e0e7ff", "on_secondary": "#3730a3", "background": "#f8fafc", "on_background": "#0f172a", "surface": "#ffffff", "surface_alt": "#f1f5f9", "on_surface": "#0f172a", "border": "#e2e8f0", "muted": "#64748b", "danger": "#dc2626", "danger_hover": "#b91c1c", "on_danger": "#ffffff", "success": "#16a34a", "warning": "#d97706", "focus": "#6366f1", }, dark={ "primary": "#818cf8", "primary_hover": "#a5b4fc", "on_primary": "#0b1020", "secondary": "#1e1b4b", "secondary_hover": "#312e81", "on_secondary": "#c7d2fe", "background": "#0b1020", "on_background": "#e2e8f0", "surface": "#131a2e", "surface_alt": "#1a2238", "on_surface": "#e2e8f0", "border": "#26304a", "muted": "#94a3b8", "danger": "#f87171", "danger_hover": "#fca5a5", "on_danger": "#1a0505", "success": "#4ade80", "warning": "#fbbf24", "focus": "#a5b4fc", }, ) MINIMAL = Theme( name="minimal", light={ "primary": "#111111", "primary_hover": "#333333", "on_primary": "#ffffff", "secondary": "#f2f2f2", "secondary_hover": "#e6e6e6", "on_secondary": "#111111", "background": "#ffffff", "on_background": "#111111", "surface": "#ffffff", "surface_alt": "#f7f7f7", "on_surface": "#111111", "border": "#e5e5e5", "muted": "#6b6b6b", "danger": "#c62828", "danger_hover": "#a31f1f", "on_danger": "#ffffff", "success": "#2e7d32", "warning": "#b26a00", "focus": "#111111", }, dark={ "primary": "#f5f5f5", "primary_hover": "#d4d4d4", "on_primary": "#111111", "secondary": "#1f1f1f", "secondary_hover": "#2a2a2a", "on_secondary": "#f5f5f5", "background": "#0d0d0d", "on_background": "#ededed", "surface": "#141414", "surface_alt": "#1b1b1b", "on_surface": "#ededed", "border": "#2a2a2a", "muted": "#9a9a9a", "danger": "#ef5350", "danger_hover": "#e57373", "on_danger": "#111111", "success": "#66bb6a", "warning": "#ffb74d", "focus": "#f5f5f5", }, radius={**RADIUS, "sm": "2px", "md": "4px", "lg": "6px", "xl": "10px"}, shadows={**SHADOWS, "sm": "none", "md": "none", "lg": "0 8px 24px rgb(0 0 0 / 0.08)"}, ) SOFT = Theme( name="soft", light={ "primary": "#0d9488", "primary_hover": "#0f766e", "on_primary": "#ffffff", "secondary": "#e6f4f1", "secondary_hover": "#d3ece7", "on_secondary": "#115e59", "background": "#fbf8f4", "on_background": "#292524", "surface": "#ffffff", "surface_alt": "#f5f0ea", "on_surface": "#292524", "border": "#ebe3d9", "muted": "#78716c", "danger": "#e11d48", "danger_hover": "#be123c", "on_danger": "#ffffff", "success": "#059669", "warning": "#d97706", "focus": "#14b8a6", }, dark={ "primary": "#2dd4bf", "primary_hover": "#5eead4", "on_primary": "#042f2e", "secondary": "#123a37", "secondary_hover": "#17504b", "on_secondary": "#99f6e4", "background": "#1c1917", "on_background": "#e7e5e4", "surface": "#262220", "surface_alt": "#2e2926", "on_surface": "#e7e5e4", "border": "#3a3431", "muted": "#a8a29e", "danger": "#fb7185", "danger_hover": "#fda4af", "on_danger": "#1c0a10", "success": "#34d399", "warning": "#fbbf24", "focus": "#5eead4", }, radius={**RADIUS, "sm": "8px", "md": "14px", "lg": "20px", "xl": "28px"}, ) PROFESSIONAL = Theme( name="professional", light={ "primary": "#1d4ed8", "primary_hover": "#1e40af", "on_primary": "#ffffff", "secondary": "#e8eef8", "secondary_hover": "#d7e1f2", "on_secondary": "#1e3a8a", "background": "#f4f6f9", "on_background": "#111827", "surface": "#ffffff", "surface_alt": "#f0f2f5", "on_surface": "#111827", "border": "#d8dde5", "muted": "#5b6474", "danger": "#b91c1c", "danger_hover": "#991b1b", "on_danger": "#ffffff", "success": "#15803d", "warning": "#b45309", "focus": "#2563eb", }, dark={ "primary": "#60a5fa", "primary_hover": "#93c5fd", "on_primary": "#081225", "secondary": "#16243d", "secondary_hover": "#1e3254", "on_secondary": "#bfdbfe", "background": "#0d131c", "on_background": "#e5e7eb", "surface": "#141b26", "surface_alt": "#1a2330", "on_surface": "#e5e7eb", "border": "#273244", "muted": "#9ca3af", "danger": "#f87171", "danger_hover": "#fca5a5", "on_danger": "#1f0707", "success": "#4ade80", "warning": "#fbbf24", "focus": "#93c5fd", }, radius={**RADIUS, "sm": "3px", "md": "6px", "lg": "8px", "xl": "12px"}, density=0.9, ) PRESETS: dict[str, Theme] = {t.name: t for t in (MODERN, MINIMAL, SOFT, PROFESSIONAL)} def get_theme(theme: str | Theme) -> Theme: if isinstance(theme, Theme): return theme try: return PRESETS[theme] except KeyError: raise ValueError(f"unknown theme {theme!r}; presets: {sorted(PRESETS)}") from None