Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/update_frontend_colors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Update Frontend Colors
on:
workflow_dispatch:
schedule:
# Weekly. Regenerates FrontendColors.swift from the frontend `dev` branch
# and opens a PR when the palette changed.
- cron: '0 7 * * 1'

permissions:
contents: read

jobs:
update_colors:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ssh-key: ${{ secrets.HOMEASSISTANT_SSH_DEPLOY_KEY }}

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.3.0
with:
python-version: '3.x'

- name: Regenerate FrontendColors.swift
run: python3 Tools/BuildFrontendColors.py

- name: Commit changes
id: commit
run: |
git config --global user.name 'Home Assistant Bot'
git config --global user.email 'hello@home-assistant.io'
git add Sources/HADesignSystem/Sources/Colors/FrontendColors.swift
if ! git diff --cached --quiet; then
git commit -m "Update frontend colors"
fi
echo "pr_title=$(git log -1 --pretty='%s')" >> "$GITHUB_OUTPUT"

- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
base: main
branch: create-pull-request/update_frontend_colors
title: ${{ steps.commit.outputs.pr_title }}
body: "Automatically created by ${{ github.actor }}."
145 changes: 145 additions & 0 deletions Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation
import SwiftUI
import UIKit

public extension FrontendColors {
/// The resolved color for the light (default) theme, if the CSS value can be parsed.
var lightColor: Color? {
Self.color(from: lightValue, scheme: .light)
}

/// The resolved color for the dark theme, falling back to the light value
/// when there is no dark override.
var darkColor: Color? {
Self.color(from: darkValue ?? lightValue, scheme: .dark)
}

/// A color that adapts to the current interface style.
///
/// Values that reference custom properties defined outside of
/// `color.globals.ts` (for example the `--ha-color-*` core palette) cannot
/// be resolved and fall back to `.clear`.
var color: Color {
Self.adaptiveColor(light: lightColor, dark: darkColor)
}
}

private extension FrontendColors {
static func color(from raw: String?, scheme: ColorScheme, visited: Set<FrontendColors> = []) -> Color? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}

if raw == "transparent" {
return .clear
}
if raw.hasPrefix("#") {
return UIColor(rgbaString: raw).map { Color($0) }
}
if let color = rgbColor(from: raw) {
return color
}
if raw.lowercased().hasPrefix("var(") {
return resolveReference(raw, scheme: scheme, visited: visited)
}
return nil
}

static func resolveReference(
_ raw: String,
scheme: ColorScheme,
visited: Set<FrontendColors>
) -> Color? {
guard let open = raw.firstIndex(of: "("), raw.hasSuffix(")") else {
return nil
}
let inner = raw[raw.index(after: open) ..< raw.index(before: raw.endIndex)]
let arguments = splitTopLevel(String(inner))
guard let name = arguments.first?.trimmingCharacters(in: .whitespaces), !name.isEmpty else {
return nil
}
let fallback = arguments.count > 1
? arguments.dropFirst().joined(separator: ",").trimmingCharacters(in: .whitespaces)
: nil

if let referenced = FrontendColors(rawValue: name), !visited.contains(referenced) {
let referencedRaw = scheme == .dark
? (referenced.darkValue ?? referenced.lightValue)
: referenced.lightValue
if let resolved = color(from: referencedRaw, scheme: scheme, visited: visited.union([referenced])) {
return resolved
}
}
if let fallback {
return color(from: fallback, scheme: scheme, visited: visited)
}
return nil
}

static func rgbColor(from value: String) -> Color? {
guard let regex = rgbFunctionRegex else {
return nil
}
let range = NSRange(value.startIndex ..< value.endIndex, in: value)
guard let match = regex.firstMatch(in: value, range: range) else {
return nil
}
func component(at index: Int) -> Double? {
guard let range = Range(match.range(at: index), in: value) else {
return nil
}
return Double(value[range])
}
guard let red = component(at: 1), let green = component(at: 2), let blue = component(at: 3) else {
return nil
}
let alpha = component(at: 4) ?? 1
return Color(
.sRGB,
red: red / 255,
green: green / 255,
blue: blue / 255,
opacity: min(max(alpha, 0), 1)
)
}

static func splitTopLevel(_ string: String) -> [String] {
var result: [String] = []
var depth = 0
var current = ""
for character in string {
switch character {
case "(":
depth += 1
current.append(character)
case ")":
depth -= 1
current.append(character)
case "," where depth == 0:
result.append(current)
current = ""
default:
current.append(character)
}
}
result.append(current)
return result
}

static func adaptiveColor(light: Color?, dark: Color?) -> Color {
#if os(watchOS)
return dark ?? light ?? .clear
#else
let lightColor = light ?? dark ?? .clear
let darkColor = dark ?? light ?? .clear
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(darkColor) : UIColor(lightColor)
})
#endif
}

static let rgbFunctionRegex = try? NSRegularExpression(
pattern: #"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+)\s*)?\)$"#,
options: [.caseInsensitive]
)
}
Loading
Loading