Skip to content
Open
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
12 changes: 11 additions & 1 deletion .agents/skills/ha-ios-ui/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: ha-ios-ui
description: "SwiftUI and UIKit UI conventions for Home Assistant iOS. Use when building views, choosing SwiftUI vs UIKit, bridging with embeddedInHostingController(), or following the one-struct-per-file, inline-body, and #Preview rules."
description: "SwiftUI and UIKit UI conventions for Home Assistant iOS. Use when building views, choosing SwiftUI vs UIKit, bridging with embeddedInHostingController(), building reusable components in the HADesignSystem package, or following the one-struct-per-file, inline-body, and #Preview rules."
---

# UI Patterns
Expand All @@ -17,3 +17,13 @@ description: "SwiftUI and UIKit UI conventions for Home Assistant iOS. Use when
- **Keep everything in `body`**: build the view's content inline inside `body`. Do not abstract portions out into separate reusable subviews (helper view structs or computed `some View` properties) just to break `body` up. Extract a new struct only when it is genuinely reused elsewhere, and when you do, it gets its own file (see above).
- **Always add a `#Preview`**: every SwiftUI view must ship with a preview so it can be checked quickly in Xcode.
- **Snapshot tests for new features**: any new feature that adds UI must include snapshot tests (see the `ha-ios-testing` skill).

## Design System & Reusable Components (`HADesignSystem`)

Reusable, app-agnostic UI lives in the **`HADesignSystem`** Swift package (`Sources/HADesignSystem`), **not** in `Sources/App`. `Shared` re-exports it (`@_exported import HADesignSystem`), so anything that already does `import Shared` sees the design system with no extra import.

- **Build new reusable components in the package, not the app.** Buttons, cards, inputs, controls, indicators, sheets, tokens, colors, and styles belong in `HADesignSystem`. Only genuinely one-off, app-specific views stay in `Sources/App`.
- **Keep package code app-agnostic.** No `Current` (the World), no `L10n` product copy, no `Server`/app models, no `MaterialDesignIcons`. Inject text, colors, icons, and data through `init` parameters instead. (This is why a few still-coupled components remain in `Shared` for now.)
- **Guard iOS-only code.** The package also builds for watchOS and SPM has no per-file target membership, so wrap components using iOS-only APIs (UIKit `UIScreen`, `UIColor.label`, etc.) in `#if !os(watchOS)`.
- **Semantic colors are code-defined** in `Color+Semantic.swift` on `ShapeStyle where Self == Color` (so both `Color.haPrimary` and `.foregroundStyle(.haPrimary)` resolve). Add new semantic colors there, not to an asset catalog.
- **Register every new component in `DesignSystemComponent`** (categorized via `ComponentCategory`) so it shows up in `ComponentsLibraryView`, the in-app components explorer/glossary.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@ vendor/bundle
.bundle/
.claude/settings.local.json
/.claude/worktrees

# Local SPM package transient files
Sources/HADesignSystem/Package.resolved
109 changes: 27 additions & 82 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions Sources/HADesignSystem/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// swift-tools-version:5.9
import PackageDescription

let package = Package(
name: "HADesignSystem",
platforms: [
.iOS(.v16),
.watchOS(.v9),
],
Comment thread
bgoncal marked this conversation as resolved.
products: [
.library(name: "HADesignSystem", targets: ["HADesignSystem"]),
],
dependencies: [
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", .upToNextMajor(from: "5.3.0")),
],
targets: [
.target(
name: "HADesignSystem",
dependencies: ["SFSafeSymbols"],
path: "Sources"
),
.testTarget(
name: "HADesignSystemTests",
dependencies: ["HADesignSystem"],
path: "Tests"
),
]
)
57 changes: 57 additions & 0 deletions Sources/HADesignSystem/Sources/Colors/Color+Semantic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif

public extension ShapeStyle where Self == Color {
static var haPrimary: Color { srgb(0x00, 0x9A, 0xC7, opacity: 1) }

static var track: Color { displayP3(0, 0, 0, opacity: 0.12) }

static var onSurface: Color {
adaptive(
light: srgb(0x1A, 0x1C, 0x1E, opacity: 0.16),
dark: srgb(0xE2, 0xE2, 0xE5, opacity: 0.16)
)
}

static var tileBorder: Color {
adaptive(
light: displayP3(0xE0, 0xE0, 0xE0, opacity: 1),
dark: displayP3(0x34, 0x37, 0x37, opacity: 1)
)
}

static var haColorBorderPrimaryQuiet: Color {
adaptive(
light: srgb(0xB9, 0xE6, 0xFC, opacity: 1),
dark: srgb(0x00, 0x9A, 0xC7, opacity: 1)
)
}
}

private func srgb(_ red: Double, _ green: Double, _ blue: Double, opacity: Double) -> Color {
Color(.sRGB, red: red / 255.0, green: green / 255.0, blue: blue / 255.0, opacity: opacity)
}

private func displayP3(_ red: Double, _ green: Double, _ blue: Double, opacity: Double) -> Color {
Color(.displayP3, red: red / 255.0, green: green / 255.0, blue: blue / 255.0, opacity: opacity)
}

private func adaptive(light: Color, dark: Color) -> Color {
#if os(watchOS)
return dark
#elseif canImport(UIKit)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? UIColor(dark) : UIColor(light)
})
#else
return light
#endif
}

#if canImport(UIKit)
public extension UIColor {
static let haPrimary = UIColor(red: 0x00 / 255.0, green: 0x9A / 255.0, blue: 0xC7 / 255.0, alpha: 1)
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#if !os(watchOS)
import SFSafeSymbols
import SwiftUI

public enum AppleLikeBottomSheetViewState {
Expand Down Expand Up @@ -202,3 +204,4 @@ public struct AppleLikeBottomSheet<Content: View>: View {
trailing: DesignSystem.Spaces.two
), state: .constant(.initial), willDismiss: {})
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if !os(watchOS)
import SwiftUI

public struct CardView<Content: View>: View {
Expand Down Expand Up @@ -37,3 +38,4 @@ public struct CardView<Content: View>: View {
}
.background(Color.yellow)
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#if !os(watchOS)
import SFSafeSymbols
import SwiftUI

public struct CloseButton: View {
Expand All @@ -7,25 +9,25 @@ public struct CloseButton: View {
case large

var size: CGFloat {
if Current.isCatalyst {
switch self {
case .small:
return 24
case .medium:
return 28
case .large:
return 32
}
} else {
switch self {
case .small:
return 20
case .medium:
return 24
case .large:
return 28
}
#if targetEnvironment(macCatalyst)
switch self {
case .small:
return 24
case .medium:
return 28
case .large:
return 32
}
#else
switch self {
case .small:
return 20
case .medium:
return 24
case .large:
return 28
}
#endif
}
}

Expand Down Expand Up @@ -86,3 +88,4 @@ public struct CloseButton: View {
Spacer()
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if !os(watchOS)
import SwiftUI

public struct PillView: View {
Expand Down Expand Up @@ -39,3 +40,4 @@ public struct PillView: View {
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if !os(watchOS)
import SFSafeSymbols
import SwiftUI

Expand All @@ -22,3 +23,4 @@ public struct SheetCloseButton: View {
#Preview {
SheetCloseButton(action: {})
}
#endif
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#if !os(watchOS)
import Foundation
import UIKit

Expand All @@ -16,3 +17,4 @@ public extension UIScreen {
return cornerRadius
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#if !os(watchOS)
import SwiftUI

struct BottomSheetGalleryDemo: View {
@State private var state: AppleLikeBottomSheetViewState?
@State private var isPresented = false

var body: some View {
Button("Present bottom sheet") {
isPresented = true
}
.buttonStyle(.secondaryButton)
.fullScreenCover(isPresented: $isPresented) {
AppleLikeBottomSheet(
title: "Example sheet",
content: {
Text("This AppleLikeBottomSheet is rendered from the components library.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
},
state: $state,
customDismiss: { isPresented = false }
)
}
}
}

#Preview {
BottomSheetGalleryDemo()
}
#endif
23 changes: 23 additions & 0 deletions Sources/HADesignSystem/Sources/Gallery/ComponentCategory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#if !os(watchOS)
import Foundation

public enum ComponentCategory: String, CaseIterable, Identifiable {
case buttons
case controls
case inputs
case containers
case indicators

public var id: String { rawValue }

public var title: String {
switch self {
case .buttons: "Buttons"
case .controls: "Controls"
case .inputs: "Inputs"
case .containers: "Containers"
case .indicators: "Indicators"
}
}
}
#endif
33 changes: 33 additions & 0 deletions Sources/HADesignSystem/Sources/Gallery/ComponentsLibraryView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#if !os(watchOS)
import SwiftUI

public struct ComponentsLibraryView: View {
public init() {}

public var body: some View {
List {
ForEach(ComponentCategory.allCases) { category in
Section(category.title) {
ForEach(DesignSystemComponent.allCases.filter { $0.category == category }) { component in
VStack(alignment: .leading, spacing: DesignSystem.Spaces.one) {
Text(component.title)
.font(DesignSystem.Font.subheadline)
.foregroundStyle(.secondary)
component.preview
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, DesignSystem.Spaces.half)
}
}
}
}
.navigationTitle("Components")
}
}

#Preview {
NavigationStack {
ComponentsLibraryView()
}
}
#endif
86 changes: 86 additions & 0 deletions Sources/HADesignSystem/Sources/Gallery/DesignSystemComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#if !os(watchOS)
import SwiftUI

public enum DesignSystemComponent: String, CaseIterable, Identifiable {
case primaryButton
case secondaryButton
case outlinedButton
case neutralButton
case negativeButton
case secondaryNegativeButton
case criticalButton
case linkButton
case textButton
case closeButton
case sheetCloseButton
case textField
case card
case bottomSheet
case progressView
case pill

public var id: String { rawValue }

public var title: String {
switch self {
case .primaryButton: "Primary Button"
case .secondaryButton: "Secondary Button"
case .outlinedButton: "Outlined Button"
case .neutralButton: "Neutral Button"
case .negativeButton: "Negative Button"
case .secondaryNegativeButton: "Secondary Negative Button"
case .criticalButton: "Critical Button"
case .linkButton: "Link Button"
case .textButton: "Text Button"
case .closeButton: "Close Button"
case .sheetCloseButton: "Sheet Close Button"
case .textField: "Text Field"
case .card: "Card"
case .bottomSheet: "Bottom Sheet"
case .progressView: "Progress View"
case .pill: "Pill"
}
}

public var category: ComponentCategory {
switch self {
case .primaryButton, .secondaryButton, .outlinedButton, .neutralButton, .negativeButton,
.secondaryNegativeButton, .criticalButton, .linkButton, .textButton:
.buttons
case .closeButton, .sheetCloseButton:
.controls
case .textField:
.inputs
case .card, .bottomSheet:
.containers
case .progressView, .pill:
.indicators
}
}

@ViewBuilder public var preview: some View {
switch self {
case .primaryButton: Button("Primary") {}.buttonStyle(.primaryButton)
case .secondaryButton: Button("Secondary") {}.buttonStyle(.secondaryButton)
case .outlinedButton: Button("Outlined") {}.buttonStyle(.outlinedButton)
case .neutralButton: Button("Neutral") {}.buttonStyle(.neutralButton)
case .negativeButton: Button("Negative") {}.buttonStyle(.negativeButton)
case .secondaryNegativeButton: Button("Secondary Negative") {}.buttonStyle(.secondaryNegativeButton)
case .criticalButton: Button("Critical") {}.buttonStyle(.criticalButton)
case .linkButton: Button("Link") {}.buttonStyle(.linkButton)
case .textButton: Button("Text") {}.buttonStyle(.textButton)
case .closeButton: CloseButton {}
case .sheetCloseButton: SheetCloseButton {}
case .textField: HATextField(placeholder: "Placeholder", text: .constant("Example"))
case .card: CardView { Text("Card content").frame(maxWidth: .infinity, alignment: .leading) }
case .bottomSheet: BottomSheetGalleryDemo()
case .progressView: HAProgressView(style: .medium)
case .pill:
HStack(spacing: DesignSystem.Spaces.one) {
PillView(text: "Selected", selected: true)
PillView(text: "Normal", selected: false)
}
}
}
}
#endif
Loading
Loading