Metadata-Version: 2.4
Name: armonia
Version: 1.2.0
Summary: A Python library for theming facilities
Project-URL: Homepage, https://gitlab.com/Kencho1/armonia
Project-URL: Repository, https://gitlab.com/Kencho1/armonia.git
Project-URL: Issues, https://gitlab.com/Kencho1/armonia/-/issues
Author: Jesús Alonso Abad
License: MIT
License-File: LICENSE
Keywords: color-management,colors,polychromos,theme,theming
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: polychromos~=1.4
Provides-Extra: test
Requires-Dist: mypy>=1.0.0; extra == 'test'
Requires-Dist: pytest>=8.0.0; extra == 'test'
Requires-Dist: ruff>=0.6.0; extra == 'test'
Requires-Dist: tox>=4.0.0; extra == 'test'
Description-Content-Type: text/markdown

# 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.

[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Overview

**Armonia** provides a sophisticated yet simple system for managing color and font themes in Python applications.

Built on top of [polychromos](https://pypi.org/project/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

```bash
pip install armonia
```

Or using `uv`:

```bash
uv add armonia
```

## Quick Start

```python
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:

```python
# 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
```python
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
```python
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
```python
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
```python
cf.alpha("color", 0.5)        # Set opacity
cf.fade("color", 0.3)         # Fade (alias for alpha)
```

#### Advanced Transformations
```python
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
```python
# 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
```python
# 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:

```python
# 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:

```python
# 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:

```python
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:

```python
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:

```python
# 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:

```python
# 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):

```python
# 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.):

```python
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:

```python
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:

```python
# 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:

```python
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)

```python
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

```python
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

```python
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`:

```bash
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

```bash
# 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

```bash
# 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](LICENSE) file for details.

## Links

- **Repository**: https://gitlab.com/Kencho1/armonia
- **Issues**: https://gitlab.com/Kencho1/armonia/-/issues
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)
