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

NameHueUsage
Purple285Primary brand color, backgrounds
Orange55Warm accent, bottom-left glow
Blue235Cool accent, top-right glow

Variations

VariationLightnessChromaUsage
Vivid0.680.18Gradients, accents
Standard0.55790.1147Tag-compatible
Deep0.420.14Backgrounds, shadows

Hex Values

ColorOKLCHHex
Purple Vividoklch(0.68 0.18 285)#9083FF
Purple Deepoklch(0.42 0.14 285)#483C95
Orange Vividoklch(0.68 0.18 55)#E97300
Blue Vividoklch(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:

  1. Base layer: Purple Deep (#483C95)
  2. Bottom-left radial: Orange Vivid (#E97300) fading to transparent
  3. 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 utilities
  • TagColorGenerator.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 2 before Tag 10), with no locale, so the app and the CLI agree.
  • Active tags (spreadColors(for:)): tag i of N active tags in name order gets hue 360 / 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:

PathHow
TimeTrackingService.getAllTags()returns colored(storage.loadTags()); every app and CLI read of tags goes through it (lists, widgets, Live Activity, reports, exports, tags list)
AppStateevery 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 sheetpreviews 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:

  1. Extract alphabetic characters from tag name
  2. Use first detected alphabet (Latin priority, then Cyrillic, etc.)
  3. 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
  4. 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

AlphabetCharactersSizeExample
Latina-z26work, 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ജോലി
Digits0-910123 (fallback)

Fallbacks

  1. If tag has Latin letters → use Latin alphabet
  2. Else if Cyrillic → use Cyrillic
  3. … (check each alphabet in order)
  4. If only digits → use digit alphabet (36° per digit)
  5. 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 NameApprox HueColor Family
admin~0Red
backend~14Orange
development~42Yellow
meeting~173Cyan
personal~214Blue
work~319Purple
работа~165Cyan (Cyrillic)
123~54Yellow (digits)

Backward Compatibility

  • Stored hex colors are ignored for display, whatever algorithm or person produced them; an upgrade recolors every tag at once
  • Tag.color field 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 LMS
  • toRGB() - Apply gamma correction
  • toHex() - Format as “#RRGGBB”

Sources