From 6e959403cb9e4d80a40b7aba514e25e02de4055c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:38:37 -0300 Subject: [PATCH] Add FrontendColors enum generated from the frontend theme --- .github/workflows/update_frontend_colors.yml | 47 ++ .../Sources/Colors/FrontendColors+Color.swift | 145 ++++ .../Sources/Colors/FrontendColors.swift | 668 ++++++++++++++++++ .../DesignSystem/FrontendColorsTests.swift | 73 ++ Tools/BuildFrontendColors.py | 225 ++++++ 5 files changed, 1158 insertions(+) create mode 100644 .github/workflows/update_frontend_colors.yml create mode 100644 Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift create mode 100644 Sources/HADesignSystem/Sources/Colors/FrontendColors.swift create mode 100644 Tests/Shared/DesignSystem/FrontendColorsTests.swift create mode 100755 Tools/BuildFrontendColors.py diff --git a/.github/workflows/update_frontend_colors.yml b/.github/workflows/update_frontend_colors.yml new file mode 100644 index 0000000000..cc7c538a96 --- /dev/null +++ b/.github/workflows/update_frontend_colors.yml @@ -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 }}." diff --git a/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift b/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift new file mode 100644 index 0000000000..820127817a --- /dev/null +++ b/Sources/HADesignSystem/Sources/Colors/FrontendColors+Color.swift @@ -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 = []) -> 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 + ) -> 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] + ) +} diff --git a/Sources/HADesignSystem/Sources/Colors/FrontendColors.swift b/Sources/HADesignSystem/Sources/Colors/FrontendColors.swift new file mode 100644 index 0000000000..347ac767ef --- /dev/null +++ b/Sources/HADesignSystem/Sources/Colors/FrontendColors.swift @@ -0,0 +1,668 @@ +// swiftformat:disable all +// Generated by Tools/BuildFrontendColors.py — DO NOT EDIT. +// +// Source: home-assistant/frontend +// https://raw.githubusercontent.com/home-assistant/frontend/dev/src/resources/theme/color/color.globals.ts +// +// Regenerate with: Tools/BuildFrontendColors.py + +import Foundation + +/// Color custom properties defined by the Home Assistant frontend theme. +/// +/// Each case corresponds to a CSS custom property from `color.globals.ts`; +/// its `rawValue` is the property name (for example `--primary-color`). Use +/// ``lightValue`` / ``darkValue`` for the raw CSS strings, or the resolution +/// helpers in `FrontendColors+Color.swift` for parsed colors. +public enum FrontendColors: String, CaseIterable { + case primaryTextColor = "--primary-text-color" + case secondaryTextColor = "--secondary-text-color" + case textPrimaryColor = "--text-primary-color" + case textLightPrimaryColor = "--text-light-primary-color" + case disabledTextColor = "--disabled-text-color" + case primaryColor = "--primary-color" + case darkPrimaryColor = "--dark-primary-color" + case darkerPrimaryColor = "--darker-primary-color" + case lightPrimaryColor = "--light-primary-color" + case accentColor = "--accent-color" + case dividerColor = "--divider-color" + case outlineColor = "--outline-color" + case outlineHoverColor = "--outline-hover-color" + case shadowColor = "--shadow-color" + case scrollbarThumbColor = "--scrollbar-thumb-color" + case errorColor = "--error-color" + case warningColor = "--warning-color" + case successColor = "--success-color" + case infoColor = "--info-color" + case cardBackgroundColor = "--card-background-color" + case primaryBackgroundColor = "--primary-background-color" + case secondaryBackgroundColor = "--secondary-background-color" + case clearBackgroundColor = "--clear-background-color" + case labelBadgeGrey = "--label-badge-grey" + case stateIconColor = "--state-icon-color" + case energyGridConsumptionColor = "--energy-grid-consumption-color" + case energyGridReturnColor = "--energy-grid-return-color" + case energySolarColor = "--energy-solar-color" + case energyNonFossilColor = "--energy-non-fossil-color" + case energyBatteryOutColor = "--energy-battery-out-color" + case energyBatteryInColor = "--energy-battery-in-color" + case energyGasColor = "--energy-gas-color" + case energyWaterColor = "--energy-water-color" + case disabledColor = "--disabled-color" + case redColor = "--red-color" + case pinkColor = "--pink-color" + case purpleColor = "--purple-color" + case deepPurpleColor = "--deep-purple-color" + case indigoColor = "--indigo-color" + case blueColor = "--blue-color" + case lightBlueColor = "--light-blue-color" + case cyanColor = "--cyan-color" + case tealColor = "--teal-color" + case greenColor = "--green-color" + case lightGreenColor = "--light-green-color" + case limeColor = "--lime-color" + case yellowColor = "--yellow-color" + case amberColor = "--amber-color" + case orangeColor = "--orange-color" + case deepOrangeColor = "--deep-orange-color" + case brownColor = "--brown-color" + case lightGreyColor = "--light-grey-color" + case greyColor = "--grey-color" + case darkGreyColor = "--dark-grey-color" + case blueGreyColor = "--blue-grey-color" + case blackColor = "--black-color" + case whiteColor = "--white-color" + case color1 = "--color-1" + case color2 = "--color-2" + case color3 = "--color-3" + case color4 = "--color-4" + case color5 = "--color-5" + case color6 = "--color-6" + case color7 = "--color-7" + case color8 = "--color-8" + case color9 = "--color-9" + case color10 = "--color-10" + case color11 = "--color-11" + case color12 = "--color-12" + case color13 = "--color-13" + case color14 = "--color-14" + case color15 = "--color-15" + case color16 = "--color-16" + case color17 = "--color-17" + case color18 = "--color-18" + case color19 = "--color-19" + case color20 = "--color-20" + case color21 = "--color-21" + case color22 = "--color-22" + case color23 = "--color-23" + case color24 = "--color-24" + case color25 = "--color-25" + case color26 = "--color-26" + case color27 = "--color-27" + case color28 = "--color-28" + case color29 = "--color-29" + case color30 = "--color-30" + case color31 = "--color-31" + case color32 = "--color-32" + case color33 = "--color-33" + case color34 = "--color-34" + case color35 = "--color-35" + case color36 = "--color-36" + case color37 = "--color-37" + case color38 = "--color-38" + case color39 = "--color-39" + case color40 = "--color-40" + case color41 = "--color-41" + case color42 = "--color-42" + case color43 = "--color-43" + case color44 = "--color-44" + case color45 = "--color-45" + case color46 = "--color-46" + case color47 = "--color-47" + case color48 = "--color-48" + case color49 = "--color-49" + case color50 = "--color-50" + case color51 = "--color-51" + case color52 = "--color-52" + case color53 = "--color-53" + case color54 = "--color-54" + case historyUnavailableColor = "--history-unavailable-color" + case inputIdleLineColor = "--input-idle-line-color" + case inputHoverLineColor = "--input-hover-line-color" + case inputDisabledLineColor = "--input-disabled-line-color" + case inputOutlinedIdleBorderColor = "--input-outlined-idle-border-color" + case inputOutlinedHoverBorderColor = "--input-outlined-hover-border-color" + case inputOutlinedDisabledBorderColor = "--input-outlined-disabled-border-color" + case inputFillColor = "--input-fill-color" + case inputDisabledFillColor = "--input-disabled-fill-color" + case inputInkColor = "--input-ink-color" + case inputLabelInkColor = "--input-label-ink-color" + case inputDisabledInkColor = "--input-disabled-ink-color" + case inputDropdownIconColor = "--input-dropdown-icon-color" + case labelBadgeRed = "--label-badge-red" + case labelBadgeBlue = "--label-badge-blue" + case labelBadgeGreen = "--label-badge-green" + case labelBadgeYellow = "--label-badge-yellow" + case stateActiveColor = "--state-active-color" + case stateInactiveColor = "--state-inactive-color" + case stateUnavailableColor = "--state-unavailable-color" + case stateAlarmControlPanelArmedAwayColor = "--state-alarm_control_panel-armed_away-color" + case stateAlarmControlPanelArmedCustomBypassColor = "--state-alarm_control_panel-armed_custom_bypass-color" + case stateAlarmControlPanelArmedHomeColor = "--state-alarm_control_panel-armed_home-color" + case stateAlarmControlPanelArmedNightColor = "--state-alarm_control_panel-armed_night-color" + case stateAlarmControlPanelArmedVacationColor = "--state-alarm_control_panel-armed_vacation-color" + case stateAlarmControlPanelArmingColor = "--state-alarm_control_panel-arming-color" + case stateAlarmControlPanelDisarmingColor = "--state-alarm_control_panel-disarming-color" + case stateAlarmControlPanelPendingColor = "--state-alarm_control_panel-pending-color" + case stateAlarmControlPanelTriggeredColor = "--state-alarm_control_panel-triggered-color" + case stateAlertOffColor = "--state-alert-off-color" + case stateAlertOnColor = "--state-alert-on-color" + case stateBinarySensorActiveColor = "--state-binary_sensor-active-color" + case stateBinarySensorBatteryOnColor = "--state-binary_sensor-battery-on-color" + case stateBinarySensorCarbonMonoxideOnColor = "--state-binary_sensor-carbon_monoxide-on-color" + case stateBinarySensorGasOnColor = "--state-binary_sensor-gas-on-color" + case stateBinarySensorHeatOnColor = "--state-binary_sensor-heat-on-color" + case stateBinarySensorLockOnColor = "--state-binary_sensor-lock-on-color" + case stateBinarySensorMoistureOnColor = "--state-binary_sensor-moisture-on-color" + case stateBinarySensorProblemOnColor = "--state-binary_sensor-problem-on-color" + case stateBinarySensorSafetyOnColor = "--state-binary_sensor-safety-on-color" + case stateBinarySensorSmokeOnColor = "--state-binary_sensor-smoke-on-color" + case stateBinarySensorSoundOnColor = "--state-binary_sensor-sound-on-color" + case stateBinarySensorTamperOnColor = "--state-binary_sensor-tamper-on-color" + case stateClimateAutoColor = "--state-climate-auto-color" + case stateClimateCoolColor = "--state-climate-cool-color" + case stateClimateDryColor = "--state-climate-dry-color" + case stateClimateFanOnlyColor = "--state-climate-fan_only-color" + case stateClimateHeatColor = "--state-climate-heat-color" + case stateClimateHeatCoolColor = "--state-climate-heat-cool-color" + case stateCoverActiveColor = "--state-cover-active-color" + case stateDeviceTrackerActiveColor = "--state-device_tracker-active-color" + case stateDeviceTrackerHomeColor = "--state-device_tracker-home-color" + case stateFanActiveColor = "--state-fan-active-color" + case stateHumidifierOnColor = "--state-humidifier-on-color" + case stateLawnMowerActiveColor = "--state-lawn_mower-active-color" + case stateLawnMowerErrorColor = "--state-lawn_mower-error-color" + case stateLightActiveColor = "--state-light-active-color" + case stateLockJammedColor = "--state-lock-jammed-color" + case stateLockLockedColor = "--state-lock-locked-color" + case stateLockLockingColor = "--state-lock-locking-color" + case stateLockUnlockedColor = "--state-lock-unlocked-color" + case stateLockUnlockingColor = "--state-lock-unlocking-color" + case stateLockOpenColor = "--state-lock-open-color" + case stateLockOpeningColor = "--state-lock-opening-color" + case stateMediaPlayerActiveColor = "--state-media_player-active-color" + case statePersonActiveColor = "--state-person-active-color" + case statePersonHomeColor = "--state-person-home-color" + case statePlantActiveColor = "--state-plant-active-color" + case stateSirenActiveColor = "--state-siren-active-color" + case stateSunAboveHorizonColor = "--state-sun-above_horizon-color" + case stateSunBelowHorizonColor = "--state-sun-below_horizon-color" + case stateSwitchActiveColor = "--state-switch-active-color" + case stateUpdateActiveColor = "--state-update-active-color" + case stateVacuumActiveColor = "--state-vacuum-active-color" + case stateVacuumErrorColor = "--state-vacuum-error-color" + case stateValveActiveColor = "--state-valve-active-color" + case stateSensorBatteryHighColor = "--state-sensor-battery-high-color" + case stateSensorBatteryLowColor = "--state-sensor-battery-low-color" + case stateSensorBatteryMediumColor = "--state-sensor-battery-medium-color" + case stateWaterHeaterEcoColor = "--state-water_heater-eco-color" + case stateWaterHeaterElectricColor = "--state-water_heater-electric-color" + case stateWaterHeaterGasColor = "--state-water_heater-gas-color" + case stateWaterHeaterHeatPumpColor = "--state-water_heater-heat_pump-color" + case stateWaterHeaterHighDemandColor = "--state-water_heater-high_demand-color" + case stateWaterHeaterPerformanceColor = "--state-water_heater-performance-color" + case stateWeatherClearNightColor = "--state-weather-clear_night-color" + case stateWeatherCloudyColor = "--state-weather-cloudy-color" + case stateWeatherExceptionalColor = "--state-weather-exceptional-color" + case stateWeatherFogColor = "--state-weather-fog-color" + case stateWeatherHailColor = "--state-weather-hail-color" + case stateWeatherLightningRainyColor = "--state-weather-lightning_rainy-color" + case stateWeatherLightningColor = "--state-weather-lightning-color" + case stateWeatherPartlycloudyColor = "--state-weather-partlycloudy-color" + case stateWeatherPouringColor = "--state-weather-pouring-color" + case stateWeatherRainyColor = "--state-weather-rainy-color" + case stateWeatherSnowyRainyColor = "--state-weather-snowy_rainy-color" + case stateWeatherSnowyColor = "--state-weather-snowy-color" + case stateWeatherSunnyColor = "--state-weather-sunny-color" + case stateWeatherWindyVariantColor = "--state-weather-windy_variant-color" + case stateWeatherWindyColor = "--state-weather-windy-color" + case historyUnknownColor = "--history-unknown-color" + case stateIconErrorColor = "--state-icon-error-color" + case sidebarTextColor = "--sidebar-text-color" + case sidebarBackgroundColor = "--sidebar-background-color" + case sidebarSelectedTextColor = "--sidebar-selected-text-color" + case sidebarSelectedIconColor = "--sidebar-selected-icon-color" + case sidebarIconColor = "--sidebar-icon-color" + case sliderColor = "--slider-color" + case sliderSecondaryColor = "--slider-secondary-color" + case sliderTrackColor = "--slider-track-color" + case labelBadgeBackgroundColor = "--label-badge-background-color" + case labelBadgeTextColor = "--label-badge-text-color" + case tableHeaderBackgroundColor = "--table-header-background-color" + case tableRowBackgroundColor = "--table-row-background-color" + case tableRowAlternativeBackgroundColor = "--table-row-alternative-background-color" + case dataTableBackgroundColor = "--data-table-background-color" + case markdownCodeBackgroundColor = "--markdown-code-background-color" + case mdcThemePrimary = "--mdc-theme-primary" + case mdcThemeSecondary = "--mdc-theme-secondary" + case mdcThemeBackground = "--mdc-theme-background" + case mdcThemeSurface = "--mdc-theme-surface" + case mdcThemeOnPrimary = "--mdc-theme-on-primary" + case mdcThemeOnSecondary = "--mdc-theme-on-secondary" + case mdcThemeOnSurface = "--mdc-theme-on-surface" + case mdcThemeTextDisabledOnLight = "--mdc-theme-text-disabled-on-light" + case mdcThemeTextPrimaryOnBackground = "--mdc-theme-text-primary-on-background" + case mdcThemeTextSecondaryOnBackground = "--mdc-theme-text-secondary-on-background" + case mdcThemeTextHintOnBackground = "--mdc-theme-text-hint-on-background" + case mdcThemeTextIconOnBackground = "--mdc-theme-text-icon-on-background" + case mdcThemeError = "--mdc-theme-error" + case appHeaderTextColor = "--app-header-text-color" + case appHeaderBackgroundColor = "--app-header-background-color" + case appThemeColor = "--app-theme-color" + case mdcCheckboxUncheckedColor = "--mdc-checkbox-unchecked-color" + case mdcCheckboxDisabledColor = "--mdc-checkbox-disabled-color" + case mdcRadioUncheckedColor = "--mdc-radio-unchecked-color" + case mdcRadioDisabledColor = "--mdc-radio-disabled-color" + case mdcTabTextLabelColorDefault = "--mdc-tab-text-label-color-default" + case mdcButtonDisabledInkColor = "--mdc-button-disabled-ink-color" + case mdcButtonOutlineColor = "--mdc-button-outline-color" + case mdcDialogScrollDividerColor = "--mdc-dialog-scroll-divider-color" + case mdcDialogHeadingInkColor = "--mdc-dialog-heading-ink-color" + case mdcDialogContentInkColor = "--mdc-dialog-content-ink-color" + case mdcTextFieldIdleLineColor = "--mdc-text-field-idle-line-color" + case mdcTextFieldHoverLineColor = "--mdc-text-field-hover-line-color" + case mdcTextFieldDisabledLineColor = "--mdc-text-field-disabled-line-color" + case mdcTextFieldOutlinedIdleBorderColor = "--mdc-text-field-outlined-idle-border-color" + case mdcTextFieldOutlinedHoverBorderColor = "--mdc-text-field-outlined-hover-border-color" + case mdcTextFieldOutlinedDisabledBorderColor = "--mdc-text-field-outlined-disabled-border-color" + case mdcTextFieldFillColor = "--mdc-text-field-fill-color" + case mdcTextFieldDisabledFillColor = "--mdc-text-field-disabled-fill-color" + case mdcTextFieldInkColor = "--mdc-text-field-ink-color" + case mdcTextFieldLabelInkColor = "--mdc-text-field-label-ink-color" + case mdcTextFieldDisabledInkColor = "--mdc-text-field-disabled-ink-color" + case mdcSelectIdleLineColor = "--mdc-select-idle-line-color" + case mdcSelectHoverLineColor = "--mdc-select-hover-line-color" + case mdcSelectOutlinedIdleBorderColor = "--mdc-select-outlined-idle-border-color" + case mdcSelectOutlinedHoverBorderColor = "--mdc-select-outlined-hover-border-color" + case mdcSelectOutlinedDisabledBorderColor = "--mdc-select-outlined-disabled-border-color" + case mdcSelectFillColor = "--mdc-select-fill-color" + case mdcSelectDisabledFillColor = "--mdc-select-disabled-fill-color" + case mdcSelectInkColor = "--mdc-select-ink-color" + case mdcSelectLabelInkColor = "--mdc-select-label-ink-color" + case mdcSelectDisabledInkColor = "--mdc-select-disabled-ink-color" + case mdcSelectDropdownIconColor = "--mdc-select-dropdown-icon-color" + case mdcSelectDisabledDropdownIconColor = "--mdc-select-disabled-dropdown-icon-color" + case haAssistChipFilledContainerColor = "--ha-assist-chip-filled-container-color" + case haAssistChipActiveContainerColor = "--ha-assist-chip-active-container-color" + case chipBackgroundColor = "--chip-background-color" + case mdcRippleColor = "--mdc-ripple-color" + case mdcLinearProgressBufferColor = "--mdc-linear-progress-buffer-color" + case codemirrorKeyword = "--codemirror-keyword" + case codemirrorOperator = "--codemirror-operator" + case codemirrorVariable = "--codemirror-variable" + case codemirrorVariable2 = "--codemirror-variable-2" + case codemirrorVariable3 = "--codemirror-variable-3" + case codemirrorBuiltin = "--codemirror-builtin" + case codemirrorAtom = "--codemirror-atom" + case codemirrorNumber = "--codemirror-number" + case codemirrorDef = "--codemirror-def" + case codemirrorString = "--codemirror-string" + case codemirrorString2 = "--codemirror-string-2" + case codemirrorComment = "--codemirror-comment" + case codemirrorTag = "--codemirror-tag" + case codemirrorMeta = "--codemirror-meta" + case codemirrorAttribute = "--codemirror-attribute" + case codemirrorProperty = "--codemirror-property" + case codemirrorQualifier = "--codemirror-qualifier" + case codemirrorType = "--codemirror-type" + case haButtonPrimaryLightColor = "--ha-button-primary-light-color" + case haButtonWarningLightColor = "--ha-button-warning-light-color" + case haButtonNeutralColor = "--ha-button-neutral-color" + case haButtonNeutralLightColor = "--ha-button-neutral-light-color" +} + +public extension FrontendColors { + /// The raw CSS value declared in the default (light) theme, if any. + var lightValue: String? { + switch self { + case .primaryTextColor: return "var(--ha-color-text-primary)" + case .secondaryTextColor: return "var(--ha-color-text-secondary)" + case .textPrimaryColor: return "#ffffff" + case .textLightPrimaryColor: return "#212121" + case .disabledTextColor: return "#bdbdbd" + case .primaryColor: return "var(--ha-color-primary-40)" + case .darkPrimaryColor: return "#0288d1" + case .darkerPrimaryColor: return "#016194" + case .lightPrimaryColor: return "#b3e5fc" + case .accentColor: return "#ff9800" + case .dividerColor: return "rgba(0, 0, 0, 0.12)" + case .outlineColor: return "rgba(0, 0, 0, 0.12)" + case .outlineHoverColor: return "rgba(0, 0, 0, 0.24)" + case .shadowColor: return "rgba(0, 0, 0, 0.16)" + case .scrollbarThumbColor: return "rgb(194, 194, 194)" + case .errorColor: return "#db4437" + case .warningColor: return "#ffa600" + case .successColor: return "#43a047" + case .infoColor: return "#039be5" + case .cardBackgroundColor: return "#ffffff" + case .primaryBackgroundColor: return "#fafafa" + case .secondaryBackgroundColor: return "#e5e5e5" + case .clearBackgroundColor: return "#ffffff" + case .labelBadgeGrey: return "#9e9e9e" + case .stateIconColor: return "#44739e" + case .energyGridConsumptionColor: return "#488fc2" + case .energyGridReturnColor: return "#8353d1" + case .energySolarColor: return "#ff9800" + case .energyNonFossilColor: return "#0f9d58" + case .energyBatteryOutColor: return "#4db6ac" + case .energyBatteryInColor: return "#f06292" + case .energyGasColor: return "#8e021b" + case .energyWaterColor: return "#00bcd4" + case .disabledColor: return "#bdbdbd" + case .redColor: return "#f44336" + case .pinkColor: return "#e91e63" + case .purpleColor: return "#926bc7" + case .deepPurpleColor: return "#6e41ab" + case .indigoColor: return "#3f51b5" + case .blueColor: return "#2196f3" + case .lightBlueColor: return "#03a9f4" + case .cyanColor: return "#00bcd4" + case .tealColor: return "#009688" + case .greenColor: return "#4caf50" + case .lightGreenColor: return "#8bc34a" + case .limeColor: return "#cddc39" + case .yellowColor: return "#ffeb3b" + case .amberColor: return "#ffc107" + case .orangeColor: return "#ff9800" + case .deepOrangeColor: return "#ff6f22" + case .brownColor: return "#795548" + case .lightGreyColor: return "#bdbdbd" + case .greyColor: return "#9e9e9e" + case .darkGreyColor: return "#606060" + case .blueGreyColor: return "#607d8b" + case .blackColor: return "#000000" + case .whiteColor: return "#ffffff" + case .color1: return "#4269d0" + case .color2: return "#f4bd4a" + case .color3: return "#ff725c" + case .color4: return "#6cc5b0" + case .color5: return "#a463f2" + case .color6: return "#ff8ab7" + case .color7: return "#9c6b4e" + case .color8: return "#97bbf5" + case .color9: return "#01ab63" + case .color10: return "#094bad" + case .color11: return "#c99000" + case .color12: return "#d84f3e" + case .color13: return "#49a28f" + case .color14: return "#048732" + case .color15: return "#d96895" + case .color16: return "#8043ce" + case .color17: return "#7599d1" + case .color18: return "#7a4c31" + case .color19: return "#6989f4" + case .color20: return "#ffd444" + case .color21: return "#ff957c" + case .color22: return "#8fe9d3" + case .color23: return "#62cc71" + case .color24: return "#ffadda" + case .color25: return "#c884ff" + case .color26: return "#badeff" + case .color27: return "#bf8b6d" + case .color28: return "#927acc" + case .color29: return "#97ee3f" + case .color30: return "#bf3947" + case .color31: return "#9f5b00" + case .color32: return "#f48758" + case .color33: return "#8caed6" + case .color34: return "#f2b94f" + case .color35: return "#eff26e" + case .color36: return "#e43872" + case .color37: return "#d9b100" + case .color38: return "#9d7a00" + case .color39: return "#698cff" + case .color40: return "#00d27e" + case .color41: return "#d06800" + case .color42: return "#009f82" + case .color43: return "#c49200" + case .color44: return "#cbe8ff" + case .color45: return "#fecddf" + case .color46: return "#c27eb6" + case .color47: return "#8cd2ce" + case .color48: return "#c4b8d9" + case .color49: return "#f883b0" + case .color50: return "#a49100" + case .color51: return "#f48800" + case .color52: return "#27d0df" + case .color53: return "#a04a9b" + case .color54: return "#4269d0" + case .historyUnavailableColor: return "transparent" + case .inputIdleLineColor: return "rgba(0, 0, 0, 0.42)" + case .inputHoverLineColor: return "rgba(0, 0, 0, 0.87)" + case .inputDisabledLineColor: return "rgba(0, 0, 0, 0.06)" + case .inputOutlinedIdleBorderColor: return "rgba(0, 0, 0, 0.38)" + case .inputOutlinedHoverBorderColor: return "rgba(0, 0, 0, 0.87)" + case .inputOutlinedDisabledBorderColor: return "rgba(0, 0, 0, 0.06)" + case .inputFillColor: return "rgb(245, 245, 245)" + case .inputDisabledFillColor: return "rgb(250, 250, 250)" + case .inputInkColor: return "rgba(0, 0, 0, 0.87)" + case .inputLabelInkColor: return "rgba(0, 0, 0, 0.6)" + case .inputDisabledInkColor: return "rgba(0, 0, 0, 0.37)" + case .inputDropdownIconColor: return "rgba(0, 0, 0, 0.54)" + case .labelBadgeRed: return "var(--error-color)" + case .labelBadgeBlue: return "var(--info-color)" + case .labelBadgeGreen: return "var(--success-color)" + case .labelBadgeYellow: return "var(--warning-color)" + case .stateActiveColor: return "var(--amber-color)" + case .stateInactiveColor: return "var(--grey-color)" + case .stateUnavailableColor: return "var(--state-icon-unavailable-color, var(--disabled-text-color))" + case .stateAlarmControlPanelArmedAwayColor: return "var(--green-color)" + case .stateAlarmControlPanelArmedCustomBypassColor: return "var(--green-color)" + case .stateAlarmControlPanelArmedHomeColor: return "var(--green-color)" + case .stateAlarmControlPanelArmedNightColor: return "var(--green-color)" + case .stateAlarmControlPanelArmedVacationColor: return "var(--green-color)" + case .stateAlarmControlPanelArmingColor: return "var(--orange-color)" + case .stateAlarmControlPanelDisarmingColor: return "var(--orange-color)" + case .stateAlarmControlPanelPendingColor: return "var(--orange-color)" + case .stateAlarmControlPanelTriggeredColor: return "var(--red-color)" + case .stateAlertOffColor: return "var(--orange-color)" + case .stateAlertOnColor: return "var(--red-color)" + case .stateBinarySensorActiveColor: return "var(--amber-color)" + case .stateBinarySensorBatteryOnColor: return "var(--red-color)" + case .stateBinarySensorCarbonMonoxideOnColor: return "var(--red-color)" + case .stateBinarySensorGasOnColor: return "var(--red-color)" + case .stateBinarySensorHeatOnColor: return "var(--red-color)" + case .stateBinarySensorLockOnColor: return "var(--red-color)" + case .stateBinarySensorMoistureOnColor: return "var(--red-color)" + case .stateBinarySensorProblemOnColor: return "var(--red-color)" + case .stateBinarySensorSafetyOnColor: return "var(--red-color)" + case .stateBinarySensorSmokeOnColor: return "var(--red-color)" + case .stateBinarySensorSoundOnColor: return "var(--red-color)" + case .stateBinarySensorTamperOnColor: return "var(--red-color)" + case .stateClimateAutoColor: return "var(--green-color)" + case .stateClimateCoolColor: return "var(--blue-color)" + case .stateClimateDryColor: return "var(--orange-color)" + case .stateClimateFanOnlyColor: return "var(--cyan-color)" + case .stateClimateHeatColor: return "var(--deep-orange-color)" + case .stateClimateHeatCoolColor: return "var(--amber-color)" + case .stateCoverActiveColor: return "var(--purple-color)" + case .stateDeviceTrackerActiveColor: return "var(--blue-color)" + case .stateDeviceTrackerHomeColor: return "var(--green-color)" + case .stateFanActiveColor: return "var(--cyan-color)" + case .stateHumidifierOnColor: return "var(--blue-color)" + case .stateLawnMowerActiveColor: return "var(--teal-color)" + case .stateLawnMowerErrorColor: return "var(--red-color)" + case .stateLightActiveColor: return "var(--amber-color)" + case .stateLockJammedColor: return "var(--red-color)" + case .stateLockLockedColor: return "var(--green-color)" + case .stateLockLockingColor: return "var(--orange-color)" + case .stateLockUnlockedColor: return "var(--red-color)" + case .stateLockUnlockingColor: return "var(--orange-color)" + case .stateLockOpenColor: return "var(--red-color)" + case .stateLockOpeningColor: return "var(--orange-color)" + case .stateMediaPlayerActiveColor: return "var(--light-blue-color)" + case .statePersonActiveColor: return "var(--blue-color)" + case .statePersonHomeColor: return "var(--green-color)" + case .statePlantActiveColor: return "var(--red-color)" + case .stateSirenActiveColor: return "var(--red-color)" + case .stateSunAboveHorizonColor: return "var(--amber-color)" + case .stateSunBelowHorizonColor: return "var(--indigo-color)" + case .stateSwitchActiveColor: return "var(--amber-color)" + case .stateUpdateActiveColor: return "var(--orange-color)" + case .stateVacuumActiveColor: return "var(--teal-color)" + case .stateVacuumErrorColor: return "var(--red-color)" + case .stateValveActiveColor: return "var(--blue-color)" + case .stateSensorBatteryHighColor: return "var(--green-color)" + case .stateSensorBatteryLowColor: return "var(--red-color)" + case .stateSensorBatteryMediumColor: return "var(--orange-color)" + case .stateWaterHeaterEcoColor: return "var(--green-color)" + case .stateWaterHeaterElectricColor: return "var(--orange-color)" + case .stateWaterHeaterGasColor: return "var(--orange-color)" + case .stateWaterHeaterHeatPumpColor: return "var(--orange-color)" + case .stateWaterHeaterHighDemandColor: return "var(--deep-orange-color)" + case .stateWaterHeaterPerformanceColor: return "var(--deep-orange-color)" + case .stateWeatherClearNightColor: return "var(--deep-purple-color)" + case .stateWeatherCloudyColor: return "var(--light-grey-color)" + case .stateWeatherExceptionalColor: return "var(--red-color)" + case .stateWeatherFogColor: return "var(--grey-color)" + case .stateWeatherHailColor: return "var(--cyan-color)" + case .stateWeatherLightningRainyColor: return "var(--lime-color)" + case .stateWeatherLightningColor: return "var(--yellow-color)" + case .stateWeatherPartlycloudyColor: return "var(--blue-grey-color)" + case .stateWeatherPouringColor: return "var(--indigo-color)" + case .stateWeatherRainyColor: return "var(--blue-color)" + case .stateWeatherSnowyRainyColor: return "var(--light-blue-color)" + case .stateWeatherSnowyColor: return "#c0e0ff" + case .stateWeatherSunnyColor: return "var(--amber-color)" + case .stateWeatherWindyVariantColor: return "var(--green-color)" + case .stateWeatherWindyColor: return "var(--green-color)" + case .historyUnknownColor: return "var(--dark-grey-color)" + case .stateIconErrorColor: return "var(--error-state-color, var(--error-color))" + case .sidebarTextColor: return "var(--primary-text-color)" + case .sidebarBackgroundColor: return "var(--card-background-color)" + case .sidebarSelectedTextColor: return "var(--primary-color)" + case .sidebarSelectedIconColor: return "var(--primary-color)" + case .sidebarIconColor: return "rgba(var(--rgb-primary-text-color), 0.6)" + case .sliderColor: return "var(--primary-color)" + case .sliderSecondaryColor: return "var(--light-primary-color)" + case .sliderTrackColor: return "var(--scrollbar-thumb-color)" + case .labelBadgeBackgroundColor: return "var(--card-background-color)" + case .labelBadgeTextColor: return "rgba(var(--rgb-primary-text-color), 0.8)" + case .tableHeaderBackgroundColor: return "var(--input-fill-color)" + case .tableRowBackgroundColor: return "var(--primary-background-color)" + case .tableRowAlternativeBackgroundColor: return "var(--secondary-background-color)" + case .dataTableBackgroundColor: return "var(--card-background-color)" + case .markdownCodeBackgroundColor: return "var(--primary-background-color)" + case .mdcThemePrimary: return "var(--primary-color)" + case .mdcThemeSecondary: return "var(--accent-color)" + case .mdcThemeBackground: return "var(--primary-background-color)" + case .mdcThemeSurface: return "var(--card-background-color)" + case .mdcThemeOnPrimary: return "var(--text-primary-color)" + case .mdcThemeOnSecondary: return "var(--text-primary-color)" + case .mdcThemeOnSurface: return "var(--primary-text-color)" + case .mdcThemeTextDisabledOnLight: return "var(--disabled-text-color)" + case .mdcThemeTextPrimaryOnBackground: return "var(--primary-text-color)" + case .mdcThemeTextSecondaryOnBackground: return "var(--secondary-text-color)" + case .mdcThemeTextHintOnBackground: return "var(--secondary-text-color)" + case .mdcThemeTextIconOnBackground: return "var(--secondary-text-color)" + case .mdcThemeError: return "var(--error-color)" + case .appHeaderTextColor: return "var(--sidebar-text-color)" + case .appHeaderBackgroundColor: return "var(--sidebar-background-color)" + case .appThemeColor: return "var(--app-header-background-color)" + case .mdcCheckboxUncheckedColor: return "rgba(var(--rgb-primary-text-color), 0.54)" + case .mdcCheckboxDisabledColor: return "var(--disabled-text-color)" + case .mdcRadioUncheckedColor: return "rgba(var(--rgb-primary-text-color), 0.54)" + case .mdcRadioDisabledColor: return "var(--disabled-text-color)" + case .mdcTabTextLabelColorDefault: return "var(--primary-text-color)" + case .mdcButtonDisabledInkColor: return "var(--disabled-text-color)" + case .mdcButtonOutlineColor: return "var(--outline-color)" + case .mdcDialogScrollDividerColor: return "var(--divider-color)" + case .mdcDialogHeadingInkColor: return "var(--primary-text-color)" + case .mdcDialogContentInkColor: return "var(--primary-text-color)" + case .mdcTextFieldIdleLineColor: return "var(--input-idle-line-color)" + case .mdcTextFieldHoverLineColor: return "var(--input-hover-line-color)" + case .mdcTextFieldDisabledLineColor: return "var(--input-disabled-line-color)" + case .mdcTextFieldOutlinedIdleBorderColor: return "var(--input-outlined-idle-border-color)" + case .mdcTextFieldOutlinedHoverBorderColor: return "var(--input-outlined-hover-border-color)" + case .mdcTextFieldOutlinedDisabledBorderColor: return "var(--input-outlined-disabled-border-color)" + case .mdcTextFieldFillColor: return "var(--input-fill-color)" + case .mdcTextFieldDisabledFillColor: return "var(--input-disabled-fill-color)" + case .mdcTextFieldInkColor: return "var(--input-ink-color)" + case .mdcTextFieldLabelInkColor: return "var(--input-label-ink-color)" + case .mdcTextFieldDisabledInkColor: return "var(--input-disabled-ink-color)" + case .mdcSelectIdleLineColor: return "var(--input-idle-line-color)" + case .mdcSelectHoverLineColor: return "var(--input-hover-line-color)" + case .mdcSelectOutlinedIdleBorderColor: return "var(--input-outlined-idle-border-color)" + case .mdcSelectOutlinedHoverBorderColor: return "var(--input-outlined-hover-border-color)" + case .mdcSelectOutlinedDisabledBorderColor: return "var(--input-outlined-disabled-border-color)" + case .mdcSelectFillColor: return "var(--input-fill-color)" + case .mdcSelectDisabledFillColor: return "var(--input-disabled-fill-color)" + case .mdcSelectInkColor: return "var(--input-ink-color)" + case .mdcSelectLabelInkColor: return "var(--input-label-ink-color)" + case .mdcSelectDisabledInkColor: return "var(--input-disabled-ink-color)" + case .mdcSelectDropdownIconColor: return "var(--input-dropdown-icon-color)" + case .mdcSelectDisabledDropdownIconColor: return "var(--input-disabled-ink-color)" + case .haAssistChipFilledContainerColor: return "rgba(var(--rgb-primary-text-color), 0.15)" + case .haAssistChipActiveContainerColor: return "rgba(var(--rgb-primary-color), 0.15)" + case .chipBackgroundColor: return "rgba(var(--rgb-primary-text-color), 0.15)" + default: return nil + } + } + + /// The raw CSS value declared by the dark theme override, if any. + var darkValue: String? { + switch self { + case .primaryTextColor: return "#e1e1e1" + case .secondaryTextColor: return "#9b9b9b" + case .disabledTextColor: return "#6f6f6f" + case .dividerColor: return "rgba(225, 225, 225, 0.12)" + case .outlineColor: return "rgba(225, 225, 225, 0.12)" + case .outlineHoverColor: return "rgba(225, 225, 225, 0.24)" + case .shadowColor: return "rgba(0, 0, 0, 0.48)" + case .scrollbarThumbColor: return "rgb(110, 110, 110)" + case .cardBackgroundColor: return "#1c1c1c" + case .primaryBackgroundColor: return "#111111" + case .secondaryBackgroundColor: return "#282828" + case .clearBackgroundColor: return "#111111" + case .energyGridReturnColor: return "#a280db" + case .disabledColor: return "#464646" + case .inputIdleLineColor: return "rgba(255, 255, 255, 0.42)" + case .inputHoverLineColor: return "rgba(255, 255, 255, 0.87)" + case .inputDisabledLineColor: return "rgba(255, 255, 255, 0.06)" + case .inputOutlinedIdleBorderColor: return "rgba(255, 255, 255, 0.38)" + case .inputOutlinedHoverBorderColor: return "rgba(255, 255, 255, 0.87)" + case .inputOutlinedDisabledBorderColor: return "rgba(255, 255, 255, 0.06)" + case .inputFillColor: return "rgba(255, 255, 255, 0.05)" + case .inputDisabledFillColor: return "rgba(255, 255, 255, 0.02)" + case .inputInkColor: return "rgba(255, 255, 255, 0.87)" + case .inputLabelInkColor: return "rgba(255, 255, 255, 0.6)" + case .inputDisabledInkColor: return "rgba(255, 255, 255, 0.37)" + case .inputDropdownIconColor: return "rgba(255, 255, 255, 0.54)" + case .mdcRippleColor: return "#aaaaaa" + case .mdcLinearProgressBufferColor: return "rgba(255, 255, 255, 0.1)" + case .codemirrorKeyword: return "#c792ea" + case .codemirrorOperator: return "#89ddff" + case .codemirrorVariable: return "#f07178" + case .codemirrorVariable2: return "#eeffff" + case .codemirrorVariable3: return "#decb6b" + case .codemirrorBuiltin: return "#ffcb6b" + case .codemirrorAtom: return "#f78c6c" + case .codemirrorNumber: return "#ff5370" + case .codemirrorDef: return "#82aaff" + case .codemirrorString: return "#c3e88d" + case .codemirrorString2: return "#f07178" + case .codemirrorComment: return "#545454" + case .codemirrorTag: return "#ff5370" + case .codemirrorMeta: return "#ffcb6b" + case .codemirrorAttribute: return "#c792ea" + case .codemirrorProperty: return "#c792ea" + case .codemirrorQualifier: return "#decb6b" + case .codemirrorType: return "#decb6b" + case .haButtonPrimaryLightColor: return "#4082a040" + case .haButtonWarningLightColor: return "#917b54c1" + case .haButtonNeutralColor: return "#d9dae0" + case .haButtonNeutralLightColor: return "#6a7081" + default: return nil + } + } +} diff --git a/Tests/Shared/DesignSystem/FrontendColorsTests.swift b/Tests/Shared/DesignSystem/FrontendColorsTests.swift new file mode 100644 index 0000000000..0e33e533aa --- /dev/null +++ b/Tests/Shared/DesignSystem/FrontendColorsTests.swift @@ -0,0 +1,73 @@ +@testable import Shared +import SwiftUI +import Testing +import UIKit + +struct FrontendColorsTests { + @Test func rawValuesMatchCSSProperties() { + #expect(FrontendColors.redColor.rawValue == "--red-color") + #expect(FrontendColors(rawValue: "--red-color") == .redColor) + #expect(FrontendColors.allCases.count > 200) + } + + @Test func lightAndDarkRawValues() { + #expect(FrontendColors.redColor.lightValue == "#f44336") + #expect(FrontendColors.primaryBackgroundColor.lightValue == "#fafafa") + #expect(FrontendColors.primaryBackgroundColor.darkValue == "#111111") + // A light-only variable has no dark override. + #expect(FrontendColors.redColor.darkValue == nil) + } + + @Test func resolvesHexLiteral() throws { + let color = try #require(rgba(FrontendColors.redColor.lightColor)) + expectClose(color, red: 244 / 255, green: 67 / 255, blue: 54 / 255, alpha: 1) + } + + @Test func resolvesVariableReference() throws { + // --state-light-active-color: var(--amber-color); --amber-color: #ffc107 + let color = try #require(rgba(FrontendColors.stateLightActiveColor.lightColor)) + expectClose(color, red: 255 / 255, green: 193 / 255, blue: 7 / 255, alpha: 1) + } + + @Test func resolvesVariableFallback() throws { + // --state-unavailable-color references an undefined property, so the + // fallback var(--disabled-text-color) (#bdbdbd) should win. + let color = try #require(rgba(FrontendColors.stateUnavailableColor.lightColor)) + expectClose(color, red: 189 / 255, green: 189 / 255, blue: 189 / 255, alpha: 1) + } + + @Test func resolvesRGBAFunction() throws { + // --divider-color (dark): rgba(225, 225, 225, 0.12) + let color = try #require(rgba(FrontendColors.dividerColor.darkColor)) + expectClose(color, red: 225 / 255, green: 225 / 255, blue: 225 / 255, alpha: 0.12) + } + + @Test func externalReferencesAreUnresolved() { + // --primary-color: var(--ha-color-primary-40) lives in core.globals. + #expect(FrontendColors.primaryColor.lightColor == nil) + } + + private func rgba(_ color: Color?) -> (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)? { + guard let color else { return nil } + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + guard UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil } + return (r, g, b, a) + } + + private func expectClose( + _ color: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat), + red: CGFloat, + green: CGFloat, + blue: CGFloat, + alpha: CGFloat, + tolerance: CGFloat = 0.01 + ) { + #expect(abs(color.r - red) < tolerance) + #expect(abs(color.g - green) < tolerance) + #expect(abs(color.b - blue) < tolerance) + #expect(abs(color.a - alpha) < tolerance) + } +} diff --git a/Tools/BuildFrontendColors.py b/Tools/BuildFrontendColors.py new file mode 100755 index 0000000000..4111ceee35 --- /dev/null +++ b/Tools/BuildFrontendColors.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Generate FrontendColors.swift from the Home Assistant frontend color theme. + +The frontend defines its interface colors as CSS custom properties in +``src/resources/theme/color/color.globals.ts``. This script fetches that file +(from the ``dev`` branch by default), extracts the color-valued declarations +from the light (``colorStyles``) and dark (``darkColorStyles``) blocks, and +writes a Swift enum mirroring them so the native app can reference the same +palette. + +Only the data lives in the generated file. The logic that turns a raw CSS value +into a resolved color is hand written in ``FrontendColors+Color.swift``. + +Usage: + Tools/BuildFrontendColors.py # fetch dev + regenerate + Tools/BuildFrontendColors.py --source PATH # parse a local .ts file + Tools/BuildFrontendColors.py --check # fail if output is stale +""" + +from __future__ import annotations + +import argparse +import re +import sys +import urllib.request +from pathlib import Path + +SOURCE_URL = ( + "https://raw.githubusercontent.com/home-assistant/frontend/dev/" + "src/resources/theme/color/color.globals.ts" +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_PATH = REPO_ROOT / "Sources/HADesignSystem/Sources/Colors/FrontendColors.swift" + +# Markers delimiting the two exported `css` template literals in the source. +LIGHT_MARKER = "export const colorStyles =" +DARK_MARKER = "export const darkColorStyles =" +END_MARKER = "export const DefaultPrimaryColor" + +# Matches a single `--name: value;` CSS declaration. +DECLARATION_RE = re.compile(r"--([A-Za-z0-9_-]+)\s*:\s*([^;{}]+);") +BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) + +HEX_RE = re.compile(r"#[0-9A-Fa-f]{3,8}$") +FUNCTION_RE = re.compile(r"(?:rgba?|var)\(.*\)$", re.IGNORECASE) + +# Swift reserved and contextual keywords. A generated case name that lands on +# one of these is escaped with backticks so the output always compiles, even if +# the upstream CSS introduces a variable that maps to a keyword. Case names are +# lowercase-initial, so lowercase forms are what actually need matching. +SWIFT_KEYWORDS = { + # Keywords used in declarations + "associatedtype", "borrowing", "class", "consuming", "deinit", "enum", + "extension", "fileprivate", "func", "import", "init", "inout", "internal", + "let", "macro", "nonisolated", "open", "operator", "precedencegroup", + "private", "protocol", "public", "rethrows", "static", "struct", + "subscript", "typealias", "var", + # Keywords used in statements + "break", "case", "catch", "continue", "default", "defer", "do", "else", + "fallthrough", "for", "guard", "if", "in", "repeat", "return", "switch", + "throw", "where", "while", + # Keywords used in expressions and types + "any", "as", "async", "await", "each", "false", "is", "nil", "self", + "some", "super", "throws", "true", "try", + # Contextual keywords and patterns + "actor", "isolated", "_", +} + + +def load_source(source: str) -> str: + if re.match(r"^https?://", source): + try: + with urllib.request.urlopen(source, timeout=30) as response: # noqa: S310 (trusted host) + return response.read().decode("utf-8") + except OSError as error: + raise SystemExit(f"error: failed to fetch {source}: {error}") from error + return Path(source).read_text(encoding="utf-8") + + +def slice_block(text: str, start_marker: str, end_marker: str | None) -> str: + start = text.find(start_marker) + if start == -1: + raise ValueError(f"Could not find marker: {start_marker!r}") + end = text.find(end_marker, start) if end_marker else -1 + return text[start:] if end == -1 else text[start:end] + + +def is_color(value: str) -> bool: + if value == "transparent": + return True + if HEX_RE.match(value): + return True + return bool(FUNCTION_RE.match(value)) + + +def parse_block(block: str) -> "dict[str, str]": + """Return an ordered mapping of `--name` -> value for color declarations.""" + block = BLOCK_COMMENT_RE.sub("", block) + colors: dict[str, str] = {} + for name, raw_value in DECLARATION_RE.findall(block): + value = re.sub(r"\s+", " ", raw_value).strip() + if is_color(value): + colors[f"--{name}"] = value + return colors + + +def swift_case(css_name: str) -> str: + parts = [part for part in re.split(r"[-_]+", css_name.lstrip("-")) if part] + head = parts[0].lower() + tail = "".join(part[:1].upper() + part[1:].lower() for part in parts[1:]) + name = head + tail + return f"`{name}`" if name in SWIFT_KEYWORDS else name + + +def build_cases(light: "dict[str, str]", dark: "dict[str, str]") -> "list[tuple[str, str]]": + """Return ordered (swiftCase, cssName) pairs, light order then dark-only.""" + ordered_css = list(light.keys()) + [key for key in dark if key not in light] + cases: list[tuple[str, str]] = [] + seen: dict[str, str] = {} + for css_name in ordered_css: + case = swift_case(css_name) + if case in seen: + raise ValueError( + f"Swift case collision: {css_name!r} and {seen[case]!r} " + f"both map to {case!r}" + ) + seen[case] = css_name + cases.append((case, css_name)) + return cases + + +def render(light: "dict[str, str]", dark: "dict[str, str]", source: str) -> str: + cases = build_cases(light, dark) + + lines: list[str] = [ + "// swiftformat:disable all", + "// Generated by Tools/BuildFrontendColors.py — DO NOT EDIT.", + "//", + "// Source: home-assistant/frontend", + f"// {source}", + "//", + "// Regenerate with: Tools/BuildFrontendColors.py", + "", + "import Foundation", + "", + "/// Color custom properties defined by the Home Assistant frontend theme.", + "///", + "/// Each case corresponds to a CSS custom property from `color.globals.ts`;", + "/// its `rawValue` is the property name (for example `--primary-color`). Use", + "/// ``lightValue`` / ``darkValue`` for the raw CSS strings, or the resolution", + "/// helpers in `FrontendColors+Color.swift` for parsed colors.", + "public enum FrontendColors: String, CaseIterable {", + ] + for case, css_name in cases: + lines.append(f' case {case} = "{css_name}"') + lines.append("}") + lines.append("") + lines.append("public extension FrontendColors {") + lines.append(" /// The raw CSS value declared in the default (light) theme, if any.") + lines.append(" var lightValue: String? {") + lines.append(" switch self {") + for case, css_name in cases: + if css_name in light: + lines.append(f' case .{case}: return "{light[css_name]}"') + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("") + lines.append(" /// The raw CSS value declared by the dark theme override, if any.") + lines.append(" var darkValue: String? {") + lines.append(" switch self {") + for case, css_name in cases: + if css_name in dark: + lines.append(f' case .{case}: return "{dark[css_name]}"') + lines.append(" default: return nil") + lines.append(" }") + lines.append(" }") + lines.append("}") + lines.append("") + return "\n".join(lines) + + +def main(argv: "list[str]") -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", default=SOURCE_URL, help="URL or path to color.globals.ts") + parser.add_argument("--output", default=str(OUTPUT_PATH), help="Path to the generated Swift file") + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if the generated file is out of date instead of writing it", + ) + args = parser.parse_args(argv) + + text = load_source(args.source) + light = parse_block(slice_block(text, LIGHT_MARKER, DARK_MARKER)) + dark = parse_block(slice_block(text, DARK_MARKER, END_MARKER)) + if not light: + print("error: no color declarations found; source format may have changed", file=sys.stderr) + return 1 + + source_label = SOURCE_URL if args.source == SOURCE_URL else args.source + rendered = render(light, dark, source_label) + + output = Path(args.output) + existing = output.read_text(encoding="utf-8") if output.exists() else None + + if args.check: + if existing == rendered: + print(f"{output} is up to date ({len(light)} light, {len(dark)} dark)") + return 0 + print(f"error: {output} is out of date; run Tools/BuildFrontendColors.py", file=sys.stderr) + return 1 + + if existing == rendered: + print(f"{output} already up to date ({len(light)} light, {len(dark)} dark)") + return 0 + + output.write_text(rendered, encoding="utf-8") + print(f"Wrote {output} ({len(light)} light, {len(dark)} dark)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:]))