A Python library for theming facilities
Project description
Armonia
ἁρμονία
Ancient Greek; noun
The principle of order and beauty through balanced relationships.
A joining, joint; harmony, agreement, concord.
A Python library for elegant theme management with dynamic computed colors, font definitions, and powerful transformation functions.
Overview
Armonia provides a sophisticated yet simple system for managing color and font themes in Python applications.
Built on top of polychromos, it offers:
- Centralized Theme Management: Define all your colors and fonts in one place
- Dynamic Computed Colors: Colors that automatically update when base colors change
- Dynamic Computed Fonts: Fonts derived from other fonts (varying size, weight, and italic style)
- Color Functions: Comprehensive color transformation library (lighter, darker, mix, contrast, etc.)
- Font Functions: Font transformation library (scale_size, bolder, lighter, italic, roman, etc.)
- Palette System: Organize colors into named collections
- Logotype Management: Store and manage logo URIs alongside your theme
- CSS Export: Generate CSS custom properties (colors) and classes (fonts) directly from the theme
- Serialization Support: Save and load themes from JSON/YAML
- Safety First: Recursion protection and conflict detection for both colors and fonts
- Type-Safe: Full type hints and mypy compatibility
Installation
pip install armonia
Or using uv:
uv add armonia
Quick Start
from armonia import Theme
from armonia import colorfunctions as cf
from polychromos.color import HSLColor
# Create a theme
theme = Theme()
# Set base colors
theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("background", HSLColor.from_hex("#ffffff"))
theme.set_color("text", HSLColor.from_hex("#1f2937"))
# Create computed colors that automatically derive from base colors
theme.set_computed_color("primary_light", cf.lighter("primary", 0.2))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.2))
theme.set_computed_color("text_muted", cf.mix("text", "background", 0.4))
theme.set_computed_color("on_primary", cf.contrast("primary"))
# Get colors - they resolve automatically
print(theme.get_color("primary_light").to_css_hex()) # #6b95f1
# Change the base color - computed colors update automatically!
theme.set_color("primary", HSLColor.from_hex("#7c3aed"))
print(theme.get_color("primary_light").to_css_hex()) # Now reflects new primary color
Key Features
1. Dynamic Color Computation
Computed colors are functions that derive their values from other colors in the theme. When you change a base color, all computed colors update automatically:
# Define once
theme.set_color("brand", HSLColor.from_hex("#ff6b6b"))
theme.set_computed_color("brand_hover", cf.darker("brand", 0.1))
theme.set_computed_color("brand_subtle", cf.alpha("brand", 0.3))
# Update anywhere - computed colors follow
theme.set_color("brand", HSLColor.from_hex("#4ecdc4"))
# brand_hover and brand_subtle automatically reflect the new brand color
2. Comprehensive Color Functions
Over 40 built-in transformation functions organized by purpose:
Lightness & Darkness
cf.lighter("color", 0.2) # Increase lightness
cf.darker("color", 0.2) # Decrease lightness
cf.brighten("color", 0.3) # Significantly lighter
cf.dim("color", 0.3) # Significantly darker
Saturation
cf.saturate("color", 0.2) # More vivid
cf.desaturate("color", 0.2) # More gray
cf.muted("color", 0.4) # Significantly less saturated
cf.grayscale("color") # Complete desaturation
Mixing & Blending
cf.mix("color1", "color2", 0.5) # Blend two colors
cf.tint("color", 0.2) # Mix with white
cf.shade("color", 0.2) # Mix with black
cf.tone("color", 0.2) # Mix with gray
cf.softer("color", "background", 0.3) # Shift toward background
Opacity
cf.alpha("color", 0.5) # Set opacity
cf.fade("color", 0.3) # Fade (alias for alpha)
Advanced Transformations
cf.rotate_hue("color", 180) # Rotate hue by degrees
cf.complementary("color") # Opposite on color wheel
cf.invert("color") # Invert lightness
cf.contrast("color") # Choose black or white for contrast
cf.alias("color") # Reference another color
Scaling Functions
# Multiply mode (direct multiplication)
cf.scale_lightness("color", 0.7) # new = old * 0.7
cf.scale_saturation("color", 0.5) # new = old * 0.5
# Screen mode (gentler, preserves more original color)
cf.screen_lightness("color", 0.6) # new = 1 - (1-old) * 0.6
cf.screen_saturation("color", 0.4) # new = 1 - (1-old) * 0.4
Multi-Step Transformations
# Chain multiple transformations
theme.set_computed_color(
"accent_complex",
cf.multi(
"accent",
lambda c: c.delta(0, 0, 0.1), # Lighter
lambda c: c.delta(0, -0.2, 0), # Less saturated
)
)
3. Color Palettes
Organize related colors into named palettes:
# Define a palette
theme.set_palette("primary_scale", [
"primary_dark",
"primary",
"primary_light",
"primary_bright"
])
# Get all colors in the palette
colors = theme.get_palette("primary_scale") # Returns List[HSLColor]
# Use for UI scales, gradients, etc.
theme.set_palette("grays", ["gray_900", "gray_700", "gray_500", "gray_300", "gray_100"])
4. Logotype Management
Store and manage logotype URIs in your theme alongside colors:
# Add logotypes with different URI schemes
theme.set_logotype("company_logo", "https://example.com/logo.svg")
theme.set_logotype("dark_logo", "https://example.com/logo-dark.svg")
theme.set_logotype("favicon", "https://example.com/favicon.ico")
# File URIs for local assets
theme.set_logotype("local_logo", "file:///path/to/logo.png")
# Data URIs for inline resources
theme.set_logotype("inline_icon", "data:image/svg+xml,<svg>...</svg>")
# Retrieve logotype URIs
logo_uri = theme.get_logotype("company_logo") # Returns the URI string
print(logo_uri) # https://example.com/logo.svg
# Remove logotypes
theme.remove_logotype("old_logo")
# URI validation
theme.set_logotype("invalid", "not-a-uri") # Raises InvalidURIError
Logotypes accept any valid URI scheme including https://, http://, file://, data:, ftp://, and others. URIs are validated to ensure they follow standard format.
5. Font Management
Define user fonts and computed fonts that derive from them:
from armonia import Theme, typography
from armonia import fontfunctions as ff
theme = Theme()
# Define base fonts
theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))
# Derive computed fonts — only size, weight, and italic can vary; family is always inherited
theme.set_computed_font("body_bold", ff.bolder("body", 300)) # weight 400 -> 700
theme.set_computed_font("body_italic", ff.italic("body")) # italic enabled
theme.set_computed_font("body_small", ff.adjust_size("body", -4.0)) # 16px -> 12px
theme.set_computed_font("body_large", ff.scale_size("body", 1.5)) # 16px -> 24px
theme.set_computed_font("caption", ff.adjust("body", size_delta=-3.0, weight_delta=-100))
# Retrieve fonts
font = theme.get_font("body_bold")
print(font.family) # "Inter"
print(font.size) # 16.0
print(font.weight) # 700
print(font.italic) # False
# Get all fonts sorted by size
for entry in theme.get_all_fonts(sort_by="size"):
style = "italic" if entry.font.italic else "roman"
print(f"{entry.name}: {entry.font.family} {entry.font.size}px w{entry.font.weight} {style} ({entry.source})")
Font Functions
| Function | Description |
|---|---|
ff.scale_size(key, factor) |
Multiply size by factor (must be > 0) |
ff.adjust_size(key, delta) |
Add delta pixels to size |
ff.bolder(key, amount=100) |
Increase weight, clamped to 1000 |
ff.lighter(key, amount=100) |
Decrease weight, clamped to 1 |
ff.italic(key) |
Enable italic style |
ff.roman(key) |
Disable italic style (upright) |
ff.adjust(key, size_delta, weight_delta, make_italic) |
Adjust multiple properties at once |
ff.alias(key) |
Reference another font by name |
Computed fonts can also be chained — a computed font can derive from another computed font:
theme.set_computed_font("body_bold", ff.bolder("body", 300))
theme.set_computed_font("body_bold_italic", ff.italic("body_bold"))
6. Get All Colors
Retrieve and sort all theme colors:
# Get all colors sorted by name (alphabetically)
colors = theme.get_all_colors(sort_by="name")
# Sort by hue (color wheel order)
colors = theme.get_all_colors(sort_by="hue")
# Sort by saturation (most vivid to most muted)
colors = theme.get_all_colors(sort_by="saturation", reverse=True)
# Sort by lightness (dark to light)
colors = theme.get_all_colors(sort_by="lightness")
# Each entry includes name, color, and source
for entry in colors:
print(f"{entry.name}: {entry.color.to_css_hex()} ({entry.source})")
# Example: "primary: #2563eb (manual)"
# Example: "primary_light: #6b95f1 (computed)"
7. Flexible Color Resolution
The get_color() method resolves colors with a smart fallback chain:
# 1. Theme colors (highest priority)
theme.set_color("brand", HSLColor.from_hex("#ff6b6b"))
theme.get_color("brand")
# 2. Computed colors
theme.set_computed_color("brand_light", cf.lighter("brand"))
theme.get_color("brand_light")
# 3. Web colors (CSS/HTML standard colors)
theme.get_color("tomato") # Falls back to web color
theme.get_color("skyblue")
# 4. Hex colors (direct parsing)
theme.get_color("#3498db") # Parses as hex
8. Serialization Support
Load themes from dictionaries (compatible with JSON/YAML):
# Export theme to dictionary
theme_dict = {
"colors": {
"primary": "#2563eb",
"secondary": "#7c3aed"
},
"computed_colors": {
"primary_light": {
"function": "lighter",
"args": ["primary", 0.15]
}
},
"palettes": {
"primary_scale": ["primary_dark", "primary", "primary_light"]
},
"logotypes": {
"company_logo": "https://example.com/logo.svg",
"favicon": "https://example.com/favicon.ico"
},
"fonts": {
"body": {"family": "Inter", "size": 16, "weight": 400, "italic": False},
"heading": {"family": "Georgia", "size": 32, "weight": 700, "italic": False}
},
"computed_fonts": {
"body_bold": {
"function": "bolder",
"args": ["body", 300]
},
"body_italic": {
"function": "italic",
"args": ["body"]
},
"caption": {
"function": "adjust",
"args": {"font_key": "body", "size_delta": -4.0, "weight_delta": -100}
}
}
}
# Load from dictionary
theme = Theme.from_dict(theme_dict)
# Or load from JSON
import json
with open("theme.json", "r") as f:
theme = Theme.from_dict(json.load(f))
# Access fonts
body_bold = theme.get_font("body_bold")
logo_uri = theme.get_logotype("company_logo")
9. CSS Export
Export theme colors as CSS custom property declarations and fonts as CSS classes.
Colors
to_css_colors(prefix="--color-") returns bare declarations — no wrapping block — so you can embed them in any CSS rule (:root, .dark, a media query, etc.):
theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("accent", HSLColor.from_hex("#f59e0b"))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.15))
print(":root {")
print(theme.to_css_colors())
print("}")
# :root {
# --color-accent: hsl(45deg 96% 53%);
# --color-primary: hsl(221deg 83% 53%);
# --color-primary_dark: hsl(221deg 83% 38%);
# }
# Custom prefix
print(theme.to_css_colors(prefix="--brand-"))
# --brand-accent: hsl(45deg 96% 53%);
# --brand-primary: hsl(221deg 83% 53%);
# --brand-primary_dark: hsl(221deg 83% 38%);
Colors with opacity < 1 automatically use the / alpha syntax: hsl(0deg 100% 50% / 50%).
Fonts
to_css_fonts(prefix="font-", size_unit="px") returns one CSS class block per font, separated by blank lines:
theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))
theme.set_computed_font("body_bold", ff.bolder("body", 300))
print(theme.to_css_fonts())
# .font-body {
# font-family: "Inter";
# font-size: 16px;
# font-weight: 400;
# font-style: normal;
# }
#
# .font-body_bold {
# font-family: "Inter";
# font-size: 16px;
# font-weight: 700;
# font-style: normal;
# }
#
# .font-heading {
# font-family: "Georgia";
# font-size: 32px;
# font-weight: 700;
# font-style: normal;
# }
# Custom prefix and unit
print(theme.to_css_fonts(prefix="text-", size_unit="rem"))
# .text-body { font-size: 16rem; ... }
10. Safety & Validation
Armonia protects against common errors:
# Prevents color naming conflicts
theme.set_color("red", ...) # Error: 'red' is a web color
theme.set_color("primary", ...)
theme.set_computed_color("primary", ...) # Error: already exists as manual color
# Detects circular color dependencies
theme.set_computed_color("a", cf.lighter("b"))
theme.set_computed_color("b", cf.darker("a"))
theme.get_color("a") # Raises ColorRecursionError
# Prevents font naming conflicts
theme.set_font("body", ...)
theme.set_computed_font("body", ...) # Error: already exists as manual font
# Detects circular font dependencies
theme.set_computed_font("x", ff.bolder("y"))
theme.set_computed_font("y", ff.lighter("x"))
theme.get_font("x") # Raises FontRecursionError
# Clear error messages
theme.get_color("nonexistent") # ColorNotFoundError with helpful message
theme.get_font("nonexistent") # FontNotFoundError with helpful message
Complete Example
Here's a complete example of a modern design system:
from armonia import Theme, typography
from armonia import colorfunctions as cf
from armonia import fontfunctions as ff
from polychromos.color import HSLColor
# Create theme
theme = Theme()
# Base colors
theme.set_color("primary", HSLColor.from_hex("#2563eb"))
theme.set_color("secondary", HSLColor.from_hex("#7c3aed"))
theme.set_color("accent", HSLColor.from_hex("#f59e0b"))
theme.set_color("background", HSLColor.from_hex("#ffffff"))
theme.set_color("surface", HSLColor.from_hex("#f3f4f6"))
theme.set_color("text", HSLColor.from_hex("#1f2937"))
# Primary variations
theme.set_computed_color("primary_light", cf.lighter("primary", 0.15))
theme.set_computed_color("primary_dark", cf.darker("primary", 0.15))
theme.set_computed_color("primary_muted", cf.muted("primary", 0.4))
theme.set_computed_color("primary_vivid", cf.saturate("primary", 0.2))
# Semantic colors
theme.set_computed_color("on_primary", cf.contrast("primary"))
theme.set_computed_color("on_secondary", cf.contrast("secondary"))
theme.set_computed_color("on_accent", cf.contrast("accent"))
# Surface variations
theme.set_computed_color("surface_light", cf.lighter("surface", 0.05))
theme.set_computed_color("surface_dark", cf.darker("surface", 0.05))
# Text hierarchy
theme.set_computed_color("text_muted", cf.mix("text", "background", 0.4))
theme.set_computed_color("text_subtle", cf.alpha("text", 0.6))
# Define palettes
theme.set_palette("primary_scale", [
"primary_dark",
"primary",
"primary_light",
"primary_vivid"
])
theme.set_palette("surfaces", [
"background",
"surface",
"surface_light",
"surface_dark"
])
theme.set_palette("text_hierarchy", [
"text",
"text_muted",
"text_subtle"
])
# Add logotypes
theme.set_logotype("company_logo", "https://example.com/logo.svg")
theme.set_logotype("dark_logo", "https://example.com/logo-dark.svg")
theme.set_logotype("favicon", "https://example.com/favicon.ico")
# Base fonts
theme.set_font("body", typography.Font("Inter", 16.0, 400, False))
theme.set_font("heading", typography.Font("Georgia", 32.0, 700, False))
theme.set_font("mono", typography.Font("JetBrains Mono", 14.0, 400, False))
# Computed fonts
theme.set_computed_font("body_bold", ff.bolder("body", 300))
theme.set_computed_font("body_italic", ff.italic("body"))
theme.set_computed_font("body_small", ff.adjust_size("body", -4.0))
theme.set_computed_font("caption", ff.adjust("body", size_delta=-3.0, weight_delta=-100))
theme.set_computed_font("heading_italic", ff.italic("heading"))
# Use colors
print(f"Primary: {theme.get_color('primary').to_css_hex()}")
print(f"Primary Light: {theme.get_color('primary_light').to_css_hex()}")
print(f"Text on Primary: {theme.get_color('on_primary').to_css_hex()}")
# Get a palette
primary_colors = theme.get_palette("primary_scale")
for i, color in enumerate(primary_colors):
print(f" {i}: {color.to_css_hex()}")
# Get logotypes
print(f"Logo: {theme.get_logotype('company_logo')}")
# Get fonts
body_bold = theme.get_font("body_bold")
print(f"Body Bold: {body_bold.family} {body_bold.size}px w{body_bold.weight}")
Integration Examples
Flask/Django (CSS Custom Properties)
from armonia import Theme
from polychromos.color import HSLColor
theme = Theme()
# ... setup theme ...
def generate_css():
"""Generate CSS custom properties and font classes from theme."""
return "\n".join([
":root {",
theme.to_css_colors(),
"}",
"",
theme.to_css_fonts(),
])
# In your Flask/Django view
@app.route('/theme.css')
def theme_css():
return generate_css(), 200, {'Content-Type': 'text/css'}
TailwindCSS Integration
import json
def generate_tailwind_colors(theme):
"""Generate Tailwind color configuration."""
colors = {}
for entry in theme.get_all_colors():
colors[entry.name] = entry.color.to_css_hex()
config = {
"theme": {
"extend": {
"colors": colors
}
}
}
with open("tailwind.theme.json", "w") as f:
json.dump(config, f, indent=2)
Terminal/CLI Applications
from armonia import Theme
from polychromos.color import HSLColor
theme = Theme()
# ... setup theme ...
# Get ANSI color codes for terminal output using polychromos
def color_text(text, color_name):
color = theme.get_color(color_name)
# Use 24-bit true color ANSI codes for best color accuracy
ansi_code = color.to_ansi_color(foreground=True, bits=24)
return f"{ansi_code}{text}\033[0m"
# For backgrounds
def color_background(text, bg_color_name, fg_color_name="white"):
bg_color = theme.get_color(bg_color_name)
fg_color = theme.get_color(fg_color_name)
bg_ansi = bg_color.to_ansi_color(foreground=False, bits=24)
fg_ansi = fg_color.to_ansi_color(foreground=True, bits=24)
return f"{bg_ansi}{fg_ansi}{text}\033[0m"
print(color_text("Success!", "success"))
print(color_text("Warning!", "warning"))
print(color_background(" INFO ", "primary", "white"))
Live Example
Check out the interactive example in examples/color-showcase.py:
cd examples
uv run color-showcase.py
Then open http://localhost:5000 to see a live demonstration with:
- Interactive color pickers
- Real-time computed color updates
- Light/dark theme switching
- All colors panel with sorting options
Development
This project uses uv for dependency management.
Setup
# Clone the repository
git clone https://gitlab.com/Kencho1/armonia.git
cd armonia
# Install dependencies
uv sync
# Install with test dependencies
uv sync --extra test
Testing
# Run tests
uv run pytest
# Run with coverage
uv run pytest --cov=armonia
# Type checking
uv run mypy armonia
# Linting
uv run ruff check armonia
License
MIT License - see LICENSE file for details.
Links
- Repository: https://gitlab.com/Kencho1/armonia
- Issues: https://gitlab.com/Kencho1/armonia/-/issues
- Changelog: CHANGELOG.md
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file armonia-1.2.0.tar.gz.
File metadata
- Download URL: armonia-1.2.0.tar.gz
- Upload date:
- Size: 36.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d9a606dc4e55c71c0789d08c790555e3ee61ecb30cd7c0b206904f4b986154cb
|
|
| MD5 |
6e2a836a8a4228d0321de04c578d5b50
|
|
| BLAKE2b-256 |
df0455793e7fb3f351ab964af489766dc8e693591646f830b37a4feeaf7998b2
|
File details
Details for the file armonia-1.2.0-py3-none-any.whl.
File metadata
- Download URL: armonia-1.2.0-py3-none-any.whl
- Upload date:
- Size: 22.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
43bdeb3134f46acfbb69f700a6639580ac1fed31460c7c2a08ce52b2928bf4f4
|
|
| MD5 |
acbff7abfa80ddb2535c1d3a902206c7
|
|
| BLAKE2b-256 |
2b9b47d287079b988dfe00a2f477d1d087d51cff437fe77cae835a220cc9246d
|