Color Palette
Overview
Colors in Minuta are based on the OKLCH color space for perceptual uniformity. This includes:
- Tag colors: Computed from the tag list, never chosen or stored for display
- Brand colors: Fixed palette for app icon, buttons, and gradients
Brand Palette
The brand palette uses three harmonious hues with consistent OKLCH parameters.
Hues
| Name | Hue | Usage |
|---|---|---|
| Purple | 285 | Primary brand color, backgrounds |
| Orange | 55 | Warm accent, bottom-left glow |
| Blue | 235 | Cool accent, top-right glow |
Variations
| Variation | Lightness | Chroma | Usage |
|---|---|---|---|
| Vivid | 0.68 | 0.18 | Gradients, accents |
| Standard | 0.5579 | 0.1147 | Tag-compatible |
| Deep | 0.42 | 0.14 | Backgrounds, shadows |
Hex Values
| Color | OKLCH | Hex |
|---|---|---|
| Purple Vivid | oklch(0.68 0.18 285) | #9083FF |
| Purple Deep | oklch(0.42 0.14 285) | #483C95 |
| Orange Vivid | oklch(0.68 0.18 55) | #E97300 |
| Blue Vivid | oklch(0.68 0.18 235) | #00A6F6 |
Usage in Code
// OKLCH.swift - brand color accessors
OKLCH.purpleVivid // oklch(0.68 0.18 285)
OKLCH.purpleDeep // oklch(0.42 0.14 285)
OKLCH.orangeVivid // oklch(0.68 0.18 55)
OKLCH.blueVivid // oklch(0.68 0.18 235)
// Color+Hex.swift - SwiftUI colors
Color.brandPurpleVivid
Color.brandPurpleDeep
Color.brandOrangeVivid
Color.brandBlueVivid App Icon Gradient
The app icon uses a layered gradient effect:
- Base layer: Purple Deep (
#483C95) - Bottom-left radial: Orange Vivid (
#E97300) fading to transparent - Top-right radial: Blue Vivid (
#00A6F6) fading to transparent
Tag Colors
A tag’s color is not chosen and not read from storage: it follows from the tag list. Active tags are spread evenly around the OKLCH hue circle in name order, so the list reads as a gradient and neighbours get clearly different colors. Adding, renaming, archiving or deleting a tag recolors the others.
Why OKLCH
- Perceptually uniform: Equal steps in OKLCH values produce equal perceived differences
- Predictable: The same tag list always produces the same colors, in the app and the CLI
- Wide gamut: Supports more colors than sRGB
- Gradient-friendly: Alphabetically sorted tags form smooth color progressions
Implementation
Location: Shared/Sources/MinutaShared/Utilities/
OKLCH.swift- Color space conversion utilitiesTagColorGenerator.swift- List-to-color algorithm
Base Color
All tag colors use the same lightness and chroma, varying only in hue:
oklch(0.5579 0.1147 H) - Lightness (L): 0.5579 - mid-range, works on both light/dark backgrounds
- Chroma (C): 0.1147 - moderate saturation, not too vibrant
- Hue (H): 0-360 degrees, spread around the circle in name order
Algorithm
// The colors tags are shown in, same order; stored colors are ignored
TagColorGenerator.colored(tags)
// The color an active tag would be shown in once added
TagColorGenerator.color(for: "beta", among: tags) - Order:
TagColorGenerator.precedes(_:_:)compares names ignoring case and diacritics, digits by value (Tag 2beforeTag 10), with no locale, so the app and the CLI agree. - Active tags (
spreadColors(for:)): tagiofNactive tags in name order gets hue360 / N * (i + 0.5). One tag sits at 180, two at 90 and 270. - Archived tags take no place in the spread: each sits in the middle of the hue gap between its active neighbours in name order (the list wraps around: a name after the last active tag sits between the last and the first). With no active tag the hue comes from the name (
color(for:), below). - Hues around cyan (180-225 degrees) fall outside sRGB at this lightness and chroma; the hex is clipped and reads back up to about 3 degrees off.
Where colors are applied:
| Path | How |
|---|---|
TimeTrackingService.getAllTags() | returns colored(storage.loadTags()); every app and CLI read of tags goes through it (lists, widgets, Live Activity, reports, exports, tags list) |
AppState | every change to tags goes through publishTags, which recolors the whole list |
createTag(name:), getOrCreateTag(name:) | return the tag with the color it is shown in |
| Add Tag sheet | previews color(for:among: appState.tags) |
Stored color
Tag.color is still written: a new tag stores the color it is shown in at creation (color(for:among:) under the write lock), and an update stores whatever color the caller’s tag carries, which is the computed one. The field is kept for older app versions on other devices, which still display it. Nothing in this version reads it for display, and there is no way to set it by hand: Reset Colors, CLI tags recolor and tags add --color were removed.
Name-based hue (no active tag)
TagColorGenerator.color(for:) derives a hue from the name alone:
- Extract alphabetic characters from tag name
- Use first detected alphabet (Latin priority, then Cyrillic, etc.)
- Hierarchically subdivide 360-degree hue range:
- First letter picks 1/26th of range (for Latin)
- Second letter subdivides that into 1/26th
- Continue until characters exhausted
- Return midpoint of final range
Names that share a long prefix get the same hue this way, and frequent first letters crowd one part of the circle; that is why it is used only for archived tags when no tag is active.
Supported Alphabets
| Alphabet | Characters | Size | Example |
|---|---|---|---|
| Latin | a-z | 26 | work, meeting |
| Cyrillic | а-я + ё | 33 | работа |
| Greek | α-ω | 24 | εργασία |
| Hebrew | א-ת | 27 | שלום |
| Arabic | ا-ي | 28 | عمل |
| Georgian | ა-ჰ | 33 | სამუშაო |
| Armenian | ա-ֆ | 38 | աdelays |
| Thai | ก-ฮ | 44 | งาน |
| Devanagari | अ-ह | 46 | काम |
| Bengali | অ-হ | 43 | কাজ |
| Tamil | அ-ஹ | 35 | வேலை |
| Telugu | అ-హ | 41 | పని |
| Kannada | ಅ-ಹ | 41 | ಕೆಲಸ |
| Malayalam | അ-ഹ | 41 | ജോലി |
| Digits | 0-9 | 10 | 123 (fallback) |
Fallbacks
- If tag has Latin letters → use Latin alphabet
- Else if Cyrillic → use Cyrillic
- … (check each alphabet in order)
- If only digits → use digit alphabet (36° per digit)
- If no recognized chars → hash-based hue
Usage
Colors for a tag list
let shown = TagColorGenerator.colored(tags) // same order, color filled in
let hex = TagColorGenerator.color(for: "work", among: tags) // as if "work" were added Name-based color
let hex = TagColorGenerator.color(for: "work") // deterministic Read a stored color
let oklch = OKLCH(hex: tag.color) // nil unless "#RRGGBB"
let hue = oklch?.hue Compute hue only
let hue = TagColorGenerator.computeHue(from: "work") // ~319.5 Text Contrast
When displaying text on colored backgrounds, use Color.contrastingTextColor(for:):
// In Color+Hex.swift
static func contrastingTextColor(for hex: String) -> Color {
// Returns .black or .white based on luminance threshold (0.5)
}
// Or with OKLCH directly
static func contrastingTextColor(for oklch: OKLCH) -> Color {
// Uses OKLCH lightness for more accurate contrast
} Name-based Hue Examples
| Tag Name | Approx Hue | Color Family |
|---|---|---|
| admin | ~0 | Red |
| backend | ~14 | Orange |
| development | ~42 | Yellow |
| meeting | ~173 | Cyan |
| personal | ~214 | Blue |
| work | ~319 | Purple |
| работа | ~165 | Cyan (Cyrillic) |
| 123 | ~54 | Yellow (digits) |
Backward Compatibility
- Stored hex colors are ignored for display, whatever algorithm or person produced them; an upgrade recolors every tag at once
Tag.colorfield remains a hex string in storage, written for older app versions
OKLCH Conversion Chain
OKLCH → OKLab → Linear RGB → sRGB (gamma) → Hex
Hex → sRGB → Linear RGB → LMS → OKLab → OKLCH (OKLCH(hex:)) Key conversions in OKLCH.swift:
toOKLab()- Convert to OKLab (L, a, b)toLinearRGB()- Convert to linear RGB via LMStoRGB()- Apply gamma correctiontoHex()- Format as “#RRGGBB”
Sources
Related
- 201-tag - Tag model (uses color field)
- 306-report - Report charts use tag colors