Commit 113f65e4fb

113f65e4fbecad52806129ebd664296ccde24bcb

parent: da2b2e7988

Unsigned

cmc <hello@cleberg.net> · 2026-08-27 19:52 UTC

design: align native app with web

Layout: unified · split

gitbay/Assets.xcassets/AccentColor.colorset/Contents.json +33 −6
@@ -1,11 +1,38 @@
1{ 1{
2 "colors" : [ 2 "colors": [
3 { 3 {
4 "idiom" : "universal" 4 "color": {
5 "color-space": "srgb",
6 "components": {
7 "red": "0x00",
8 "green": "0x00",
9 "blue": "0xF0",
10 "alpha": "1.000"
11 }
12 },
13 "idiom": "universal"
14 },
15 {
16 "appearances": [
17 {
18 "appearance": "luminosity",
19 "value": "dark"
20 }
21 ],
22 "color": {
23 "color-space": "srgb",
24 "components": {
25 "red": "0x62",
26 "green": "0x72",
27 "blue": "0xFF",
28 "alpha": "1.000"
29 }
30 },
31 "idiom": "universal"
5 } 32 }
6 ], 33 ],
7 "info" : { 34 "info": {
8 "author" : "xcode", 35 "author": "xcode",
9 "version" : 1 36 "version": 1
10 } 37 }
11} 38}\ No newline at end of file
gitbay/Assets.xcassets/AppIcon.appiconset/AppIcon.png added

Binary file not shown.

gitbay/Assets.xcassets/AppIcon.appiconset/Contents.json +9 −30
@@ -1,35 +1,14 @@
1{ 1{
2 "images" : [ 2 "images": [
3 { 3 {
4 "idiom" : "universal", 4 "filename": "AppIcon.png",
5 "platform" : "ios", 5 "idiom": "universal",
6 "size" : "1024x1024" 6 "platform": "ios",
7 }, 7 "size": "1024x1024"
8 {
9 "appearances" : [
10 {
11 "appearance" : "luminosity",
12 "value" : "dark"
13 }
14 ],
15 "idiom" : "universal",
16 "platform" : "ios",
17 "size" : "1024x1024"
18 },
19 {
20 "appearances" : [
21 {
22 "appearance" : "luminosity",
23 "value" : "tinted"
24 }
25 ],
26 "idiom" : "universal",
27 "platform" : "ios",
28 "size" : "1024x1024"
29 } 8 }
30 ], 9 ],
31 "info" : { 10 "info": {
32 "author" : "xcode", 11 "author": "xcode",
33 "version" : 1 12 "version": 1
34 } 13 }
35} 14}\ No newline at end of file
gitbay/Dashboard/DashboardModels.swift +24 −4
@@ -1,18 +1,38 @@
1import Foundation 1import Foundation
2 2
3/// The `dashboard` command: the whole account aggregate in one read. 3/// The `dashboard` command: the same account aggregate as the web
4/// `internal/control/dashboard.go` — arrays are always present. 4/// dashboard, in one read. Newer arrays default empty so an updated app
5/// can still talk to a server from before the dashboard parity contract.
5nonisolated struct DashboardData: Decodable, Sendable, Hashable { 6nonisolated struct DashboardData: Decodable, Sendable, Hashable {
7 let reviewQueue: [DashboardItem]
8 let assignedIssues: [DashboardItem]
9 let openMRs: [DashboardItem]
10 let openIssues: [DashboardItem]
6 /// Pinned repos share `repo list`'s row shape. 11 /// Pinned repos share `repo list`'s row shape.
7 let pinned: [RepoSummary] 12 let pinned: [RepoSummary]
8 let openMRs: [DashboardItem] 13 let recentActivity: [FeedEvent]
9 let assignedIssues: [DashboardItem] 14 /// Retained by the CLI for compatibility; builds are not a web
15 /// dashboard section.
10 let builds: [DashboardBuild] 16 let builds: [DashboardBuild]
11 17
12 enum CodingKeys: String, CodingKey { 18 enum CodingKeys: String, CodingKey {
13 case pinned, builds 19 case pinned, builds
20 case reviewQueue = "review_queue"
14 case openMRs = "open_mrs" 21 case openMRs = "open_mrs"
15 case assignedIssues = "assigned_issues" 22 case assignedIssues = "assigned_issues"
23 case openIssues = "open_issues"
24 case recentActivity = "recent_activity"
25 }
26
27 init(from decoder: any Decoder) throws {
28 let values = try decoder.container(keyedBy: CodingKeys.self)
29 reviewQueue = try values.decodeIfPresent([DashboardItem].self, forKey: .reviewQueue) ?? []
30 assignedIssues = try values.decodeIfPresent([DashboardItem].self, forKey: .assignedIssues) ?? []
31 openMRs = try values.decodeIfPresent([DashboardItem].self, forKey: .openMRs) ?? []
32 openIssues = try values.decodeIfPresent([DashboardItem].self, forKey: .openIssues) ?? []
33 pinned = try values.decodeIfPresent([RepoSummary].self, forKey: .pinned) ?? []
34 recentActivity = try values.decodeIfPresent([FeedEvent].self, forKey: .recentActivity) ?? []
35 builds = try values.decodeIfPresent([DashboardBuild].self, forKey: .builds) ?? []
16 } 36 }
17} 37}
18 38
gitbay/Dashboard/DashboardViewModel.swift +2 −2
@@ -1,8 +1,8 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// "What needs me": pinned repos, open MRs, assigned issues, recent 4/// The web dashboard contract — attention queues, open work, pins, and
5/// builds — one `dashboard` read. This replaced a per-repo fan-out that 5/// recent activity — in one `dashboard` read. This replaced a per-repo fan-out that
6/// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the 6/// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the
7/// aggregate. 7/// aggregate.
8@Observable 8@Observable
gitbay/Discovery/FeedEvent.swift +24 −9
@@ -20,33 +20,48 @@ nonisolated struct FeedEvent: Decodable, Sendable, Hashable, Identifiable {
20 let sha: String? 20 let sha: String?
21 let tag: String? 21 let tag: String?
22 let title: String? 22 let title: String?
23 let job: String?
23 } 24 }
24 25
25 /// "opened !4", "build #56 succeeded" — the verb phrase after the actor. 26 /// The same verb and reference phrasing as the web dashboard.
26 var phrase: String { 27 var phrase: String {
27 let n = data?.number.map(String.init) ?? "" 28 let n = data?.number.map(String.init) ?? ""
28 switch kind { 29 switch kind {
29 case "mr.created": return "opened !\(n)" 30 case "mr.created": return "opened merge request !\(n)"
30 case "mr.merged": return "merged !\(n)" 31 case "mr.merged": return "merged !\(n)"
31 case "mr.closed": return "closed !\(n)" 32 case "mr.commented": return "commented on !\(n)"
32 case "issue.created": return "opened #\(n)" 33 case "mr.closed": return "closed merge request !\(n)"
33 case "issue.closed": return "closed #\(n)" 34 case "issue.created": return "opened issue #\(n)"
34 case "issue.open": return "reopened #\(n)" 35 case "issue.closed": return "closed issue #\(n)"
36 case "issue.reopened": return "reopened issue #\(n)"
35 case "issue.commented": return "commented on #\(n)" 37 case "issue.commented": return "commented on #\(n)"
36 case "build.success": return "build #\(n) succeeded" 38 case "build.success": return "build success \(data?.job ?? "")"
37 case "build.failure": return "build #\(n) failed" 39 case "build.failure": return "build failure \(data?.job ?? "")"
38 case "release.created": return "released \(data?.tag ?? "")" 40 case "release.created": return "released \(data?.tag ?? "")"
39 case "push": return "pushed" 41 case "push": return "pushed"
42 case let value where value.hasPrefix("issue."):
43 return "issue \(value.dropFirst("issue.".count)) #\(n)"
44 case let value where value.hasPrefix("mr."):
45 return "merge request \(value.dropFirst("mr.".count)) !\(n)"
46 case let value where value.hasPrefix("build."):
47 return "build \(value.dropFirst("build.".count)) \(data?.job ?? "")"
48 case let value where value.hasPrefix("repo."):
49 return "repository \(value.dropFirst("repo.".count))"
40 default: return kind 50 default: return kind
41 } 51 }
42 } 52 }
43 53
54 var displayActor: String {
55 guard let actor, !actor.isEmpty else { return "gitbay" }
56 return actor
57 }
58
44 var icon: (name: String, isPositive: Bool?) { 59 var icon: (name: String, isPositive: Bool?) {
45 switch kind { 60 switch kind {
46 case "mr.created": ("arrow.triangle.merge", nil) 61 case "mr.created": ("arrow.triangle.merge", nil)
47 case "mr.merged": ("arrow.triangle.merge", true) 62 case "mr.merged": ("arrow.triangle.merge", true)
48 case "mr.closed": ("xmark.circle", false) 63 case "mr.closed": ("xmark.circle", false)
49 case "issue.created", "issue.open": ("smallcircle.filled.circle", nil) 64 case "issue.created", "issue.open", "issue.reopened": ("smallcircle.filled.circle", nil)
50 case "issue.closed": ("checkmark.circle", true) 65 case "issue.closed": ("checkmark.circle", true)
51 case "issue.commented": ("bubble.left", nil) 66 case "issue.commented": ("bubble.left", nil)
52 case "build.success": ("checkmark.circle.fill", true) 67 case "build.success": ("checkmark.circle.fill", true)
gitbay/Fonts/AtkinsonMono-Regular.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonMono-Semibold.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Bold.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Italic.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Regular.ttf added

Binary file not shown.

gitbay/Fonts/AtkinsonNext-Semibold.ttf added

Binary file not shown.

gitbay/Fonts/OFL-LICENSE.txt added +6
@@ -0,0 +1,6 @@
1Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono
2Copyright 2020-2025 Braille Institute of America, Inc.
3Licensed under the SIL Open Font License, Version 1.1.
4https://openfontlicense.org
5Bundled unmodified apart from static instancing of the variable weight
6axis, matching the faces gitbay's web UI serves.
gitbay/Theme/Theme.swift added +119
@@ -0,0 +1,119 @@
1import SwiftUI
2import UIKit
3import CoreText
4
5/// The web UI's design tokens (`internal/web/static/style.css` in
6/// krz/gitbay), so the two surfaces read as one product. Every pair is
7/// the stylesheet's light/dark value for the same variable.
8extension Color {
9
10 nonisolated private static func paired(_ light: UInt32, _ dark: UInt32) -> Color {
11 Color(UIColor { traits in
12 UIColor(hex: traits.userInterfaceStyle == .dark ? dark : light)
13 })
14 }
15
16 /// --accent: links and focus.
17 nonisolated static let gbAccent = paired(0x0000F0, 0x6272FF)
18 /// --ok: open things, passing builds, verified signatures.
19 nonisolated static let gbOK = paired(0x0A7D3C, 0x3FCE7A)
20 /// --bad: closed without merging, failures, bad signatures.
21 nonisolated static let gbBad = paired(0xC62828, 0xFF6B6B)
22 /// --done: merged.
23 nonisolated static let gbDone = paired(0x6B21A8, 0xC084FC)
24 /// --warn: text-sized warnings.
25 nonisolated static let gbWarn = paired(0xC2410C, 0xFF6B3D)
26 /// --mark: the brand orange, bars and underlines.
27 nonisolated static let gbMark = paired(0xE4572E, 0xFF6B3D)
28 /// --diff-add / --diff-del: row grounds in diffs.
29 nonisolated static let gbDiffAdd = paired(0xE4F6EA, 0x0D2A18)
30 nonisolated static let gbDiffDel = paired(0xFDEAEA, 0x2C1113)
31 /// --code-bg: code blocks.
32 nonisolated static let gbCodeBackground = paired(0xF5F5F5, 0x050505)
33 /// --fill-subtle: hunk headers and quiet fills.
34 nonisolated static let gbFillSubtle = paired(0xECECEC, 0x161616)
35}
36
37extension UIColor {
38 nonisolated convenience init(hex: UInt32) {
39 self.init(
40 red: CGFloat((hex >> 16) & 0xFF) / 255,
41 green: CGFloat((hex >> 8) & 0xFF) / 255,
42 blue: CGFloat(hex & 0xFF) / 255,
43 alpha: 1
44 )
45 }
46}
47
48/// The web's chip shape: --r-pill is 2px, not a capsule.
49nonisolated let gbChipShape = RoundedRectangle(cornerRadius: 2)
50
51/// Atkinson Hyperlegible, the faces the web serves, scaled with Dynamic
52/// Type. Falls back to the system face wherever the fonts fail to
53/// register.
54extension Font {
55
56 nonisolated static func gbSans(_ style: TextStyle) -> Font {
57 .custom("Atkinson Hyperlegible Next", size: gbSize(style), relativeTo: style)
58 }
59
60 nonisolated static func gbMono(_ style: TextStyle) -> Font {
61 .custom("Atkinson Hyperlegible Mono", size: gbSize(style), relativeTo: style)
62 }
63
64 /// Base sizes tracking the system text styles.
65 nonisolated private static func gbSize(_ style: TextStyle) -> CGFloat {
66 switch style {
67 case .largeTitle: 34
68 case .title: 28
69 case .title2: 22
70 case .title3: 20
71 case .headline: 17
72 case .body: 17
73 case .callout: 16
74 case .subheadline: 15
75 case .footnote: 13
76 case .caption: 12
77 case .caption2: 11
78 @unknown default: 17
79 }
80 }
81}
82
83/// Registers the bundled faces once at launch. Registration failure is
84/// survivable: Font.custom falls back to the system face.
85enum GitbayFonts {
86 static func register() {
87 let names = [
88 "AtkinsonNext-Regular", "AtkinsonNext-Bold", "AtkinsonNext-Semibold",
89 "AtkinsonNext-Italic", "AtkinsonMono-Regular", "AtkinsonMono-Semibold",
90 ]
91 let urls = names.compactMap {
92 Bundle.main.url(forResource: $0, withExtension: "ttf")
93 }
94 guard !urls.isEmpty else { return }
95 CTFontManagerRegisterFontURLs(urls as CFArray, .process, true, nil)
96 }
97}
98
99/// The web's `.chip` recipe: state color, a 7% tint ground, a 35% tint
100/// border, 2px radius.
101struct GBChip: View {
102 let text: String
103 let color: Color
104
105 init(_ text: String, _ color: Color) {
106 self.text = text
107 self.color = color
108 }
109
110 var body: some View {
111 Text(text)
112 .font(.gbSans(.caption2).weight(.medium))
113 .foregroundStyle(color)
114 .padding(.horizontal, 7)
115 .padding(.vertical, 1)
116 .background(color.opacity(0.07), in: gbChipShape)
117 .overlay(gbChipShape.stroke(color.opacity(0.35), lineWidth: 1))
118 }
119}
gitbay/Views/Account/AccountView.swift +18 −18
@@ -25,13 +25,13 @@ struct AccountView: View {
25 NavigationLink(value: OrgRoute.org(membership.org)) { 25 NavigationLink(value: OrgRoute.org(membership.org)) {
26 HStack { 26 HStack {
27 Label(membership.org, systemImage: "building.2") 27 Label(membership.org, systemImage: "building.2")
28 .font(.subheadline) 28 .font(.gbSans(.subheadline))
29 Spacer() 29 Spacer()
30 Text(membership.role) 30 Text(membership.role)
31 .font(.caption) 31 .font(.gbSans(.caption))
32 .padding(.horizontal, 6) 32 .padding(.horizontal, 6)
33 .padding(.vertical, 2) 33 .padding(.vertical, 2)
34 .background(.quaternary, in: Capsule()) 34 .background(.quaternary, in: gbChipShape)
35 } 35 }
36 } 36 }
37 } 37 }
@@ -45,15 +45,15 @@ struct AccountView: View {
45 if let error = model.actionError { 45 if let error = model.actionError {
46 Section { 46 Section {
47 Label(error, systemImage: "hand.raised") 47 Label(error, systemImage: "hand.raised")
48 .foregroundStyle(.orange) 48 .foregroundStyle(Color.gbWarn)
49 .font(.subheadline) 49 .font(.gbSans(.subheadline))
50 } 50 }
51 } 51 }
52 if let notice = model.notice { 52 if let notice = model.notice {
53 Section { 53 Section {
54 Label(notice, systemImage: "envelope") 54 Label(notice, systemImage: "envelope")
55 .foregroundStyle(.secondary) 55 .foregroundStyle(.secondary)
56 .font(.subheadline) 56 .font(.gbSans(.subheadline))
57 } 57 }
58 } 58 }
59 emailSection 59 emailSection
@@ -131,7 +131,7 @@ struct AccountView: View {
131 ForEach(keys) { key in 131 ForEach(keys) { key in
132 VStack(alignment: .leading, spacing: 2) { 132 VStack(alignment: .leading, spacing: 2) {
133 Text(key.fingerprint) 133 Text(key.fingerprint)
134 .font(.caption.monospaced()) 134 .font(.gbMono(.caption))
135 .lineLimit(1) 135 .lineLimit(1)
136 .truncationMode(.middle) 136 .truncationMode(.middle)
137 HStack(spacing: 6) { 137 HStack(spacing: 6) {
@@ -139,9 +139,9 @@ struct AccountView: View {
139 Text(key.scope) 139 Text(key.scope)
140 .padding(.horizontal, 5) 140 .padding(.horizontal, 5)
141 .padding(.vertical, 1) 141 .padding(.vertical, 1)
142 .background(.quaternary, in: Capsule()) 142 .background(.quaternary, in: gbChipShape)
143 } 143 }
144 .font(.caption2) 144 .font(.gbSans(.caption2))
145 .foregroundStyle(.secondary) 145 .foregroundStyle(.secondary)
146 } 146 }
147 .swipeActions { 147 .swipeActions {
@@ -154,7 +154,7 @@ struct AccountView: View {
154 addingSSH = true 154 addingSSH = true
155 } label: { 155 } label: {
156 Label("Add SSH Key", systemImage: "plus") 156 Label("Add SSH Key", systemImage: "plus")
157 .font(.subheadline) 157 .font(.gbSans(.subheadline))
158 } 158 }
159 .accessibilityIdentifier("add-ssh-key") 159 .accessibilityIdentifier("add-ssh-key")
160 } header: { 160 } header: {
@@ -169,12 +169,12 @@ struct AccountView: View {
169 ForEach(keys) { key in 169 ForEach(keys) { key in
170 VStack(alignment: .leading, spacing: 2) { 170 VStack(alignment: .leading, spacing: 2) {
171 Text(key.fingerprint) 171 Text(key.fingerprint)
172 .font(.caption.monospaced()) 172 .font(.gbMono(.caption))
173 .lineLimit(1) 173 .lineLimit(1)
174 .truncationMode(.middle) 174 .truncationMode(.middle)
175 if !key.emailList.isEmpty { 175 if !key.emailList.isEmpty {
176 Text(key.emailList.joined(separator: ", ")) 176 Text(key.emailList.joined(separator: ", "))
177 .font(.caption2) 177 .font(.gbSans(.caption2))
178 .foregroundStyle(.secondary) 178 .foregroundStyle(.secondary)
179 } 179 }
180 } 180 }
@@ -188,7 +188,7 @@ struct AccountView: View {
188 addingPGP = true 188 addingPGP = true
189 } label: { 189 } label: {
190 Label("Add PGP Key", systemImage: "plus") 190 Label("Add PGP Key", systemImage: "plus")
191 .font(.subheadline) 191 .font(.gbSans(.subheadline))
192 } 192 }
193 .accessibilityIdentifier("add-pgp-key") 193 .accessibilityIdentifier("add-pgp-key")
194 } header: { 194 } header: {
@@ -211,7 +211,7 @@ struct AccountView: View {
211 emailAddress = "" 211 emailAddress = ""
212 Task { await model.addEmail(address) } 212 Task { await model.addEmail(address) }
213 } 213 }
214 .font(.caption) 214 .font(.gbSans(.caption))
215 .disabled(!emailAddress.contains("@") || model.working) 215 .disabled(!emailAddress.contains("@") || model.working)
216 } 216 }
217 HStack { 217 HStack {
@@ -224,7 +224,7 @@ struct AccountView: View {
224 verifyCode = "" 224 verifyCode = ""
225 Task { await model.verifyEmail(code: code) } 225 Task { await model.verifyEmail(code: code) }
226 } 226 }
227 .font(.caption) 227 .font(.gbSans(.caption))
228 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working) 228 .disabled(verifyCode.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
229 .accessibilityIdentifier("email-verify") 229 .accessibilityIdentifier("email-verify")
230 } 230 }
@@ -256,7 +256,7 @@ private struct KeyPasteSheet: View {
256 Section { 256 Section {
257 TextEditor(text: $text) 257 TextEditor(text: $text)
258 .frame(minHeight: 120) 258 .frame(minHeight: 120)
259 .font(.caption.monospaced()) 259 .font(.gbMono(.caption))
260 .autocorrectionDisabled() 260 .autocorrectionDisabled()
261 .textInputAutocapitalization(.never) 261 .textInputAutocapitalization(.never)
262 .accessibilityIdentifier("key-paste-text") 262 .accessibilityIdentifier("key-paste-text")
@@ -274,8 +274,8 @@ private struct KeyPasteSheet: View {
274 if let errorMessage { 274 if let errorMessage {
275 Section { 275 Section {
276 Label(errorMessage, systemImage: "exclamationmark.triangle") 276 Label(errorMessage, systemImage: "exclamationmark.triangle")
277 .foregroundStyle(.red) 277 .foregroundStyle(Color.gbBad)
278 .font(.subheadline) 278 .font(.gbSans(.subheadline))
279 } 279 }
280 } 280 }
281 } 281 }
gitbay/Views/Builds/BuildListView.swift +11 −11
@@ -15,8 +15,8 @@ struct BuildListView: View {
15 if let error = model.actionError { 15 if let error = model.actionError {
16 Section { 16 Section {
17 Label(error, systemImage: "hand.raised") 17 Label(error, systemImage: "hand.raised")
18 .foregroundStyle(.orange) 18 .foregroundStyle(Color.gbWarn)
19 .font(.subheadline) 19 .font(.gbSans(.subheadline))
20 } 20 }
21 } 21 }
22 ForEach(model.state.value ?? []) { build in 22 ForEach(model.state.value ?? []) { build in
@@ -65,23 +65,23 @@ private struct BuildRow: View {
65 .foregroundStyle(color) 65 .foregroundStyle(color)
66 VStack(alignment: .leading, spacing: 2) { 66 VStack(alignment: .leading, spacing: 2) {
67 Text("#\(build.number) \(build.job)") 67 Text("#\(build.number) \(build.job)")
68 .font(.subheadline.weight(.medium)) 68 .font(.gbSans(.subheadline).weight(.medium))
69 HStack(spacing: 6) { 69 HStack(spacing: 6) {
70 Text(build.ref) 70 Text(build.ref)
71 Text(build.shortSHA) 71 Text(build.shortSHA)
72 .font(.caption.monospaced()) 72 .font(.gbMono(.caption))
73 } 73 }
74 .font(.caption) 74 .font(.gbSans(.caption))
75 .foregroundStyle(.secondary) 75 .foregroundStyle(.secondary)
76 } 76 }
77 Spacer() 77 Spacer()
78 VStack(alignment: .trailing, spacing: 2) { 78 VStack(alignment: .trailing, spacing: 2) {
79 Text(build.status) 79 Text(build.status)
80 .font(.caption.weight(.medium)) 80 .font(.gbSans(.caption).weight(.medium))
81 .foregroundStyle(color) 81 .foregroundStyle(color)
82 Text(build.createdAt, format: .relative(presentation: .named)) 82 Text(build.createdAt, format: .relative(presentation: .named))
83 .font(.caption) 83 .font(.gbSans(.caption))
84 .foregroundStyle(.tertiary) 84 .foregroundStyle(.secondary)
85 } 85 }
86 } 86 }
87 .padding(.vertical, 2) 87 .padding(.vertical, 2)
@@ -99,9 +99,9 @@ private struct BuildRow: View {
99 99
100 private var color: Color { 100 private var color: Color {
101 switch build.status { 101 switch build.status {
102 case "success": .green 102 case "success": .gbOK
103 case "failure", "error": .red 103 case "failure", "error": .gbBad
104 case "running", "queued", "pending": .orange 104 case "running", "queued", "pending": .gbWarn
105 default: .secondary 105 default: .secondary
106 } 106 }
107 } 107 }
gitbay/Views/Builds/BuildLogView.swift +1 −1
@@ -25,7 +25,7 @@ struct BuildLogView: View {
25 } else { 25 } else {
26 ScrollView([.horizontal, .vertical]) { 26 ScrollView([.horizontal, .vertical]) {
27 Text(log) 27 Text(log)
28 .font(.caption2.monospaced()) 28 .font(.gbMono(.caption2))
29 .frame(maxWidth: .infinity, alignment: .leading) 29 .frame(maxWidth: .infinity, alignment: .leading)
30 .padding(12) 30 .padding(12)
31 .textSelection(.enabled) 31 .textSelection(.enabled)
gitbay/Views/Dashboard/DashboardView.swift +83 −93
@@ -1,6 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3/// What needs me, in one read. 3/// The native representation of `gitbay dashboard`, ordered and phrased
4/// like the logged-in web dashboard.
4struct DashboardView: View { 5struct DashboardView: View {
5 6
6 @State private var model: DashboardViewModel 7 @State private var model: DashboardViewModel
@@ -12,20 +13,28 @@ struct DashboardView: View {
12 var body: some View { 13 var body: some View {
13 List { 14 List {
14 if let data = model.state.value { 15 if let data = model.state.value {
15 if !data.pinned.isEmpty {
16 pinnedSection(data.pinned)
17 }
18 itemSection( 16 itemSection(
19 "Needs review", items: data.openMRs, 17 "Waiting on your review", items: data.reviewQueue,
20 empty: "No open merge requests.", 18 empty: "Nothing waiting on you", marker: "!"
21 marker: "!" 19 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
20
21 itemSection(
22 "Assigned to you", items: data.assignedIssues,
23 empty: "Nothing assigned to you", marker: "#"
24 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
25
26 itemSection(
27 "Open merge requests", items: data.openMRs,
28 empty: "No open merge requests", marker: "!"
22 ) { MRRoute.mr(repo: $0.repo, number: $0.number) } 29 ) { MRRoute.mr(repo: $0.repo, number: $0.number) }
30
23 itemSection( 31 itemSection(
24 "Assigned to me", items: data.assignedIssues, 32 "Open issues", items: data.openIssues,
25 empty: "No assigned issues.", 33 empty: "No open issues", marker: "#"
26 marker: "#"
27 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) } 34 ) { IssueRoute.issue(repo: $0.repo, number: $0.number) }
28 buildsSection(data.builds) 35
36 pinnedSection(data.pinned)
37 activitySection(data.recentActivity)
29 } 38 }
30 } 39 }
31 .overlay { LoadStateOverlay(state: model.state) } 40 .overlay { LoadStateOverlay(state: model.state) }
@@ -43,33 +52,6 @@ struct DashboardView: View {
43 .refreshable { await model.load() } 52 .refreshable { await model.load() }
44 } 53 }
45 54
46 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
47 Section("Pinned") {
48 ForEach(pinned) { repo in
49 NavigationLink(value: RepoRoute.repo(repo.path)) {
50 VStack(alignment: .leading, spacing: 2) {
51 HStack(spacing: 6) {
52 Text(repo.path)
53 .font(.subheadline.weight(.medium))
54 .lineLimit(1)
55 if repo.isPrivate {
56 Image(systemName: "lock.fill")
57 .font(.caption2)
58 .foregroundStyle(.secondary)
59 }
60 }
61 if let description = repo.description, !description.isEmpty {
62 Text(description)
63 .font(.caption)
64 .foregroundStyle(.secondary)
65 .lineLimit(1)
66 }
67 }
68 }
69 }
70 }
71 }
72
73 private func itemSection( 55 private func itemSection(
74 _ title: String, 56 _ title: String,
75 items: [DashboardItem], 57 items: [DashboardItem],
@@ -77,86 +59,94 @@ struct DashboardView: View {
77 marker: String, 59 marker: String,
78 route: @escaping (DashboardItem) -> some Hashable 60 route: @escaping (DashboardItem) -> some Hashable
79 ) -> some View { 61 ) -> some View {
80 Section(title) { 62 Section {
81 if items.isEmpty { 63 if items.isEmpty {
82 Text(empty) 64 Text(empty)
83 .font(.subheadline) 65 .font(.gbSans(.subheadline))
84 .foregroundStyle(.secondary) 66 .foregroundStyle(.secondary)
85 } else { 67 } else {
86 ForEach(items) { item in 68 ForEach(items) { item in
87 NavigationLink(value: route(item)) { 69 NavigationLink(value: route(item)) {
88 VStack(alignment: .leading, spacing: 2) { 70 dashboardItem(item, marker: marker)
89 Text(item.repo)
90 .font(.caption)
91 .foregroundStyle(.secondary)
92 HStack(alignment: .firstTextBaseline, spacing: 6) {
93 Text(marker + String(item.number))
94 .font(.caption.monospaced())
95 .foregroundStyle(.secondary)
96 Text(item.title)
97 .font(.subheadline.weight(.medium))
98 .lineLimit(2)
99 }
100 HStack(spacing: 6) {
101 Text("by \(item.author)")
102 Text(item.updatedAt, format: .relative(presentation: .named))
103 .foregroundStyle(.tertiary)
104 }
105 .font(.caption)
106 .foregroundStyle(.secondary)
107 }
108 } 71 }
109 } 72 }
110 } 73 }
74 } header: {
75 sectionHeader(title, count: items.count)
111 } 76 }
112 } 77 }
113 78
114 private func buildsSection(_ builds: [DashboardBuild]) -> some View { 79 private func dashboardItem(_ item: DashboardItem, marker: String) -> some View {
115 Section("Recent builds") { 80 VStack(alignment: .leading, spacing: 3) {
116 if builds.isEmpty { 81 Text(item.title)
117 Text("No recent builds.") 82 .font(.gbSans(.subheadline).weight(.semibold))
118 .font(.subheadline) 83 .lineLimit(2)
84 HStack(spacing: 4) {
85 Text(item.repo + marker + String(item.number))
86 .font(.gbSans(.caption))
87 Text("·")
88 Text(item.author)
89 .foregroundStyle(Color.gbAccent)
90 Text("·")
91 Text(item.updatedAt, format: .relative(presentation: .named))
92 if item.state == "source_gone" {
93 Text("·")
94 GBChip("source gone", .secondary)
95 }
96 }
97 .font(.gbSans(.caption))
98 .foregroundStyle(.secondary)
99 .lineLimit(1)
100 }
101 .padding(.vertical, 2)
102 }
103
104 private func pinnedSection(_ pinned: [RepoSummary]) -> some View {
105 Section {
106 if pinned.isEmpty {
107 Text("Pin a repository to keep it here")
108 .font(.gbSans(.subheadline))
119 .foregroundStyle(.secondary) 109 .foregroundStyle(.secondary)
120 } else { 110 } else {
121 ForEach(builds) { build in 111 ForEach(pinned) { repo in
122 NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) { 112 NavigationLink(value: RepoRoute.repo(repo.path)) {
123 HStack(spacing: 8) { 113 HStack(spacing: 6) {
124 Image(systemName: buildIcon(build.status)) 114 Text(repo.path)
125 .foregroundStyle(buildColor(build.status)) 115 .font(.gbSans(.subheadline).weight(.medium))
126 VStack(alignment: .leading, spacing: 2) { 116 .foregroundStyle(Color.gbAccent)
127 Text(build.repo) 117 if repo.isPrivate {
128 .font(.caption) 118 GBChip("Private", .secondary)
129 .foregroundStyle(.secondary)
130 Text("#\(build.number) \(build.job)")
131 .font(.subheadline)
132 } 119 }
133 Spacer()
134 Text(build.createdAt, format: .relative(presentation: .named))
135 .font(.caption)
136 .foregroundStyle(.tertiary)
137 } 120 }
138 } 121 }
139 } 122 }
140 } 123 }
124 } header: {
125 Text("Pinned")
141 } 126 }
142 } 127 }
143 128
144 private func buildIcon(_ status: String) -> String { 129 private func activitySection(_ events: [FeedEvent]) -> some View {
145 switch status { 130 Section {
146 case "success": "checkmark.circle.fill" 131 if events.isEmpty {
147 case "failure", "error": "xmark.circle.fill" 132 Text("No activity yet")
148 case "running": "circle.dotted" 133 .font(.gbSans(.subheadline))
149 case "queued", "pending": "clock" 134 .foregroundStyle(.secondary)
150 default: "questionmark.circle" 135 } else {
136 ForEach(events) { event in
137 FeedLink(event: event)
138 }
139 }
140 } header: {
141 Text("Recent activity")
151 } 142 }
152 } 143 }
153 144
154 private func buildColor(_ status: String) -> Color { 145 private func sectionHeader(_ title: String, count: Int) -> some View {
155 switch status { 146 HStack(alignment: .firstTextBaseline, spacing: 4) {
156 case "success": .green 147 Text(title)
157 case "failure", "error": .red 148 Text(String(count))
158 case "running", "queued", "pending": .orange 149 .foregroundStyle(.secondary)
159 default: .secondary
160 } 150 }
161 } 151 }
162} 152}
gitbay/Views/Discovery/FeedView.swift +18 −13
@@ -16,7 +16,7 @@ struct FeedView: View {
16 var body: some View { 16 var body: some View {
17 List { 17 List {
18 ForEach(list.state.value ?? []) { event in 18 ForEach(list.state.value ?? []) { event in
19 link(for: event) 19 FeedLink(event: event)
20 } 20 }
21 PageFooter(list: list) 21 PageFooter(list: list)
22 } 22 }
@@ -31,8 +31,15 @@ struct FeedView: View {
31 .refreshable { await list.reload() } 31 .refreshable { await list.reload() }
32 } 32 }
33 33
34}
35
36/// One feed event linked to the same native destination everywhere it is
37/// represented, including the dashboard's recent-activity section.
38struct FeedLink: View {
39 let event: FeedEvent
40
34 @ViewBuilder 41 @ViewBuilder
35 private func link(for event: FeedEvent) -> some View { 42 var body: some View {
36 switch event.destination { 43 switch event.destination {
37 case .repo(let repo): 44 case .repo(let repo):
38 NavigationLink(value: RepoRoute.repo(repo)) { FeedRow(event: event) } 45 NavigationLink(value: RepoRoute.repo(repo)) { FeedRow(event: event) }
@@ -50,7 +57,7 @@ struct FeedView: View {
50 } 57 }
51} 58}
52 59
53private struct FeedRow: View { 60struct FeedRow: View {
54 let event: FeedEvent 61 let event: FeedEvent
55 62
56 var body: some View { 63 var body: some View {
@@ -60,30 +67,28 @@ private struct FeedRow: View {
60 .frame(width: 22) 67 .frame(width: 22)
61 VStack(alignment: .leading, spacing: 2) { 68 VStack(alignment: .leading, spacing: 2) {
62 Text(event.repo) 69 Text(event.repo)
63 .font(.caption) 70 .font(.gbSans(.caption))
64 .foregroundStyle(.secondary) 71 .foregroundStyle(.secondary)
65 HStack(spacing: 4) { 72 HStack(spacing: 4) {
66 if let actor = event.actor, !actor.isEmpty { 73 Text(event.displayActor)
67 Text(actor) 74 .font(.gbSans(.subheadline).weight(.semibold))
68 .font(.subheadline.weight(.semibold))
69 }
70 Text(event.phrase) 75 Text(event.phrase)
71 .font(.subheadline) 76 .font(.gbSans(.subheadline))
72 .lineLimit(1) 77 .lineLimit(1)
73 } 78 }
74 } 79 }
75 Spacer() 80 Spacer()
76 Text(event.createdAt, format: .relative(presentation: .named)) 81 Text(event.createdAt, format: .relative(presentation: .named))
77 .font(.caption) 82 .font(.gbSans(.caption))
78 .foregroundStyle(.tertiary) 83 .foregroundStyle(.secondary)
79 } 84 }
80 .padding(.vertical, 2) 85 .padding(.vertical, 2)
81 } 86 }
82 87
83 private var color: Color { 88 private var color: Color {
84 switch event.icon.isPositive { 89 switch event.icon.isPositive {
85 case true: .green 90 case true: .gbOK
86 case false: .red 91 case false: .gbBad
87 default: .secondary 92 default: .secondary
88 } 93 }
89 } 94 }
gitbay/Views/Discovery/GrepView.swift +3 −3
@@ -20,18 +20,18 @@ struct GrepView: View {
20 )) { 20 )) {
21 HStack(alignment: .firstTextBaseline, spacing: 8) { 21 HStack(alignment: .firstTextBaseline, spacing: 8) {
22 Text(String(match.line)) 22 Text(String(match.line))
23 .font(.caption.monospaced()) 23 .font(.gbMono(.caption))
24 .foregroundStyle(.tertiary) 24 .foregroundStyle(.tertiary)
25 .frame(minWidth: 32, alignment: .trailing) 25 .frame(minWidth: 32, alignment: .trailing)
26 Text(match.text.trimmingCharacters(in: .whitespaces)) 26 Text(match.text.trimmingCharacters(in: .whitespaces))
27 .font(.caption.monospaced()) 27 .font(.gbMono(.caption))
28 .lineLimit(2) 28 .lineLimit(2)
29 } 29 }
30 } 30 }
31 } 31 }
32 } header: { 32 } header: {
33 Text(group.file) 33 Text(group.file)
34 .font(.caption.monospaced()) 34 .font(.gbMono(.caption))
35 .textCase(nil) 35 .textCase(nil)
36 } 36 }
37 } 37 }
gitbay/Views/Discovery/ProfileView.swift +8 −8
@@ -17,24 +17,24 @@ struct ProfileView: View {
17 HStack(spacing: 8) { 17 HStack(spacing: 8) {
18 Image(systemName: profile.kind == "org" 18 Image(systemName: profile.kind == "org"
19 ? "building.2" : "person.crop.circle") 19 ? "building.2" : "person.crop.circle")
20 .font(.title2) 20 .font(.gbSans(.title2))
21 .foregroundStyle(.secondary) 21 .foregroundStyle(.secondary)
22 Text(profile.name) 22 Text(profile.name)
23 .font(.title3.weight(.semibold)) 23 .font(.gbSans(.title3).weight(.semibold))
24 Text(profile.kind) 24 Text(profile.kind)
25 .font(.caption2) 25 .font(.gbSans(.caption2))
26 .padding(.horizontal, 6) 26 .padding(.horizontal, 6)
27 .padding(.vertical, 1) 27 .padding(.vertical, 1)
28 .background(.quaternary, in: Capsule()) 28 .background(.quaternary, in: gbChipShape)
29 } 29 }
30 if let description = profile.description, !description.isEmpty { 30 if let description = profile.description, !description.isEmpty {
31 Text(description) 31 Text(description)
32 .font(.subheadline) 32 .font(.gbSans(.subheadline))
33 .foregroundStyle(.secondary) 33 .foregroundStyle(.secondary)
34 } 34 }
35 if let website = profile.website, let url = URL(string: website) { 35 if let website = profile.website, let url = URL(string: website) {
36 Link(website, destination: url) 36 Link(website, destination: url)
37 .font(.caption) 37 .font(.gbSans(.caption))
38 .lineLimit(1) 38 .lineLimit(1)
39 } 39 }
40 } 40 }
@@ -55,10 +55,10 @@ struct ProfileView: View {
55 NavigationLink(value: RepoRoute.repo(repo.path)) { 55 NavigationLink(value: RepoRoute.repo(repo.path)) {
56 VStack(alignment: .leading, spacing: 2) { 56 VStack(alignment: .leading, spacing: 2) {
57 Text(repo.name) 57 Text(repo.name)
58 .font(.subheadline.weight(.medium)) 58 .font(.gbSans(.subheadline).weight(.medium))
59 if let description = repo.description, !description.isEmpty { 59 if let description = repo.description, !description.isEmpty {
60 Text(description) 60 Text(description)
61 .font(.caption) 61 .font(.gbSans(.caption))
62 .foregroundStyle(.secondary) 62 .foregroundStyle(.secondary)
63 .lineLimit(1) 63 .lineLimit(1)
64 } 64 }
gitbay/Views/Issues/IssueListView.swift +9 −9
@@ -75,33 +75,33 @@ private struct IssueRow: View {
75 VStack(alignment: .leading, spacing: 4) { 75 VStack(alignment: .leading, spacing: 4) {
76 HStack(alignment: .firstTextBaseline, spacing: 6) { 76 HStack(alignment: .firstTextBaseline, spacing: 6) {
77 Text("#\(issue.number)") 77 Text("#\(issue.number)")
78 .font(.caption.monospaced()) 78 .font(.gbMono(.caption))
79 .foregroundStyle(.secondary) 79 .foregroundStyle(.secondary)
80 Text(issue.title) 80 Text(issue.title)
81 .font(.subheadline.weight(.medium)) 81 .font(.gbSans(.subheadline).weight(.medium))
82 .lineLimit(2) 82 .lineLimit(2)
83 } 83 }
84 HStack(spacing: 6) { 84 HStack(spacing: 6) {
85 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill") 85 Image(systemName: issue.isOpen ? "circle" : "checkmark.circle.fill")
86 .font(.caption2) 86 .font(.gbSans(.caption2))
87 .foregroundStyle(issue.isOpen ? .green : .purple) 87 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
88 ForEach(issue.labels ?? [], id: \.self) { label in 88 ForEach(issue.labels ?? [], id: \.self) { label in
89 Text(label) 89 Text(label)
90 .font(.caption2) 90 .font(.gbSans(.caption2))
91 .padding(.horizontal, 5) 91 .padding(.horizontal, 5)
92 .padding(.vertical, 1) 92 .padding(.vertical, 1)
93 .background(.quaternary, in: Capsule()) 93 .background(.quaternary, in: gbChipShape)
94 } 94 }
95 Spacer() 95 Spacer()
96 if let assignees = issue.assignees, !assignees.isEmpty { 96 if let assignees = issue.assignees, !assignees.isEmpty {
97 Text(assignees.joined(separator: ", ")) 97 Text(assignees.joined(separator: ", "))
98 .font(.caption) 98 .font(.gbSans(.caption))
99 .foregroundStyle(.secondary) 99 .foregroundStyle(.secondary)
100 .lineLimit(1) 100 .lineLimit(1)
101 } 101 }
102 Text(issue.createdAt, format: .relative(presentation: .named)) 102 Text(issue.createdAt, format: .relative(presentation: .named))
103 .font(.caption) 103 .font(.gbSans(.caption))
104 .foregroundStyle(.tertiary) 104 .foregroundStyle(.secondary)
105 } 105 }
106 } 106 }
107 .padding(.vertical, 2) 107 .padding(.vertical, 2)
gitbay/Views/Issues/IssueView.swift +13 −13
@@ -24,8 +24,8 @@ struct IssueView: View {
24 if let error = model.actionError { 24 if let error = model.actionError {
25 Section { 25 Section {
26 Label(error, systemImage: "hand.raised") 26 Label(error, systemImage: "hand.raised")
27 .foregroundStyle(.orange) 27 .foregroundStyle(Color.gbWarn)
28 .font(.subheadline) 28 .font(.gbSans(.subheadline))
29 } 29 }
30 } 30 }
31 31
@@ -68,11 +68,11 @@ struct IssueView: View {
68 Section { 68 Section {
69 VStack(alignment: .leading, spacing: 6) { 69 VStack(alignment: .leading, spacing: 6) {
70 Text(issue.title) 70 Text(issue.title)
71 .font(.headline) 71 .font(.gbSans(.headline))
72 HStack(spacing: 6) { 72 HStack(spacing: 6) {
73 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill") 73 Label(issue.state, systemImage: issue.isOpen ? "circle" : "checkmark.circle.fill")
74 .font(.caption.weight(.medium)) 74 .font(.gbSans(.caption).weight(.medium))
75 .foregroundStyle(issue.isOpen ? .green : .purple) 75 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
76 Text("by \(issue.author)") 76 Text("by \(issue.author)")
77 Text(issue.createdAt, format: .relative(presentation: .named)) 77 Text(issue.createdAt, format: .relative(presentation: .named))
78 .foregroundStyle(.tertiary) 78 .foregroundStyle(.tertiary)
@@ -80,7 +80,7 @@ struct IssueView: View {
80 Label(milestone, systemImage: "flag") 80 Label(milestone, systemImage: "flag")
81 } 81 }
82 } 82 }
83 .font(.caption) 83 .font(.gbSans(.caption))
84 .foregroundStyle(.secondary) 84 .foregroundStyle(.secondary)
85 } 85 }
86 .padding(.vertical, 2) 86 .padding(.vertical, 2)
@@ -138,10 +138,10 @@ struct IssueView: View {
138 } label: { 138 } label: {
139 HStack { 139 HStack {
140 Label(issue.milestone ?? "None", systemImage: "flag") 140 Label(issue.milestone ?? "None", systemImage: "flag")
141 .font(.subheadline) 141 .font(.gbSans(.subheadline))
142 Spacer() 142 Spacer()
143 Image(systemName: "chevron.up.chevron.down") 143 Image(systemName: "chevron.up.chevron.down")
144 .font(.caption2) 144 .font(.gbSans(.caption2))
145 .foregroundStyle(.secondary) 145 .foregroundStyle(.secondary)
146 } 146 }
147 } 147 }
@@ -167,10 +167,10 @@ struct IssueView: View {
167 } 167 }
168 .disabled(model.working) 168 .disabled(model.working)
169 } 169 }
170 .font(.caption) 170 .font(.gbSans(.caption))
171 .padding(.horizontal, 8) 171 .padding(.horizontal, 8)
172 .padding(.vertical, 3) 172 .padding(.vertical, 3)
173 .background(.quaternary, in: Capsule()) 173 .background(.quaternary, in: gbChipShape)
174 } 174 }
175 } 175 }
176 } 176 }
@@ -183,13 +183,13 @@ struct IssueView: View {
183 VStack(alignment: .leading, spacing: 4) { 183 VStack(alignment: .leading, spacing: 4) {
184 HStack { 184 HStack {
185 Text(comment.author) 185 Text(comment.author)
186 .font(.caption.weight(.semibold)) 186 .font(.gbSans(.caption).weight(.semibold))
187 Text(comment.createdAt, format: .relative(presentation: .named)) 187 Text(comment.createdAt, format: .relative(presentation: .named))
188 .font(.caption) 188 .font(.gbSans(.caption))
189 .foregroundStyle(.tertiary) 189 .foregroundStyle(.tertiary)
190 } 190 }
191 MarkdownView(markdown: comment.body) 191 MarkdownView(markdown: comment.body)
192 .font(.subheadline) 192 .font(.gbSans(.subheadline))
193 } 193 }
194 .padding(.vertical, 2) 194 .padding(.vertical, 2)
195 } 195 }
gitbay/Views/MRs/DiffView.swift +16 −14
@@ -55,7 +55,7 @@ private struct FileDiffSection: View {
55 if !collapsed { 55 if !collapsed {
56 if file.isBinary { 56 if file.isBinary {
57 Text("Binary file") 57 Text("Binary file")
58 .font(.caption) 58 .font(.gbSans(.caption))
59 .foregroundStyle(.secondary) 59 .foregroundStyle(.secondary)
60 } else { 60 } else {
61 ForEach(file.hunks) { hunk in 61 ForEach(file.hunks) { hunk in
@@ -69,20 +69,20 @@ private struct FileDiffSection: View {
69 } label: { 69 } label: {
70 HStack(spacing: 6) { 70 HStack(spacing: 6) {
71 Image(systemName: collapsed ? "chevron.right" : "chevron.down") 71 Image(systemName: collapsed ? "chevron.right" : "chevron.down")
72 .font(.caption2) 72 .font(.gbSans(.caption2))
73 Text(file.displayPath) 73 Text(file.displayPath)
74 .font(.caption.monospaced().weight(.semibold)) 74 .font(.gbMono(.caption).weight(.semibold))
75 .lineLimit(1) 75 .lineLimit(1)
76 .truncationMode(.head) 76 .truncationMode(.head)
77 if file.isNew { 77 if file.isNew {
78 Text("new").font(.caption2).foregroundStyle(.green) 78 Text("new").font(.gbSans(.caption2)).foregroundStyle(Color.gbOK)
79 } 79 }
80 if file.isDeleted { 80 if file.isDeleted {
81 Text("deleted").font(.caption2).foregroundStyle(.red) 81 Text("deleted").font(.gbSans(.caption2)).foregroundStyle(Color.gbBad)
82 } 82 }
83 Spacer() 83 Spacer()
84 Text("+\(file.additions)").foregroundStyle(.green).font(.caption2) 84 Text("+\(file.additions)").foregroundStyle(Color.gbOK).font(.gbSans(.caption2))
85 Text("−\(file.deletions)").foregroundStyle(.red).font(.caption2) 85 Text("−\(file.deletions)").foregroundStyle(Color.gbBad).font(.gbSans(.caption2))
86 } 86 }
87 } 87 }
88 .buttonStyle(.plain) 88 .buttonStyle(.plain)
@@ -99,9 +99,11 @@ private struct HunkView: View {
99 ScrollView(.horizontal) { 99 ScrollView(.horizontal) {
100 VStack(alignment: .leading, spacing: 0) { 100 VStack(alignment: .leading, spacing: 0) {
101 Text(hunk.header) 101 Text(hunk.header)
102 .font(.caption2.monospaced()) 102 .font(.gbMono(.caption2))
103 .foregroundStyle(.blue) 103 .foregroundStyle(.secondary)
104 .padding(.vertical, 2) 104 .padding(.vertical, 2)
105 .frame(maxWidth: .infinity, alignment: .leading)
106 .background(Color.gbFillSubtle)
105 ForEach(hunk.lines) { line in 107 ForEach(hunk.lines) { line in
106 LineView(line: line) 108 LineView(line: line)
107 } 109 }
@@ -129,7 +131,7 @@ private struct LineView: View {
129 Text(line.text.isEmpty ? " " : line.text) 131 Text(line.text.isEmpty ? " " : line.text)
130 .foregroundStyle(textColor) 132 .foregroundStyle(textColor)
131 } 133 }
132 .font(.caption2.monospaced()) 134 .font(.gbMono(.caption2))
133 .background(background) 135 .background(background)
134 } 136 }
135 137
@@ -143,8 +145,8 @@ private struct LineView: View {
143 145
144 private var markerColor: Color { 146 private var markerColor: Color {
145 switch line.kind { 147 switch line.kind {
146 case .addition: .green 148 case .addition: .gbOK
147 case .deletion: .red 149 case .deletion: .gbBad
148 case .context: .clear 150 case .context: .clear
149 } 151 }
150 } 152 }
@@ -155,8 +157,8 @@ private struct LineView: View {
155 157
156 private var background: Color { 158 private var background: Color {
157 switch line.kind { 159 switch line.kind {
158 case .addition: .green.opacity(0.12) 160 case .addition: .gbDiffAdd
159 case .deletion: .red.opacity(0.12) 161 case .deletion: .gbDiffDel
160 case .context: .clear 162 case .context: .clear
161 } 163 }
162 } 164 }
gitbay/Views/MRs/MRListView.swift +13 −16
@@ -94,8 +94,8 @@ private struct MRCreateSheet: View {
94 if let error = model.errorMessage { 94 if let error = model.errorMessage {
95 Section { 95 Section {
96 Label(error, systemImage: "exclamationmark.triangle") 96 Label(error, systemImage: "exclamationmark.triangle")
97 .foregroundStyle(.red) 97 .foregroundStyle(Color.gbBad)
98 .font(.subheadline) 98 .font(.gbSans(.subheadline))
99 } 99 }
100 } 100 }
101 } 101 }
@@ -146,10 +146,10 @@ struct MRRow: View {
146 VStack(alignment: .leading, spacing: 4) { 146 VStack(alignment: .leading, spacing: 4) {
147 HStack(alignment: .firstTextBaseline, spacing: 6) { 147 HStack(alignment: .firstTextBaseline, spacing: 6) {
148 Text("!\(mr.number)") 148 Text("!\(mr.number)")
149 .font(.caption.monospaced()) 149 .font(.gbMono(.caption))
150 .foregroundStyle(.secondary) 150 .foregroundStyle(.secondary)
151 Text(mr.title) 151 Text(mr.title)
152 .font(.subheadline.weight(.medium)) 152 .font(.gbSans(.subheadline).weight(.medium))
153 .lineLimit(2) 153 .lineLimit(2)
154 } 154 }
155 HStack(spacing: 6) { 155 HStack(spacing: 6) {
@@ -157,13 +157,13 @@ struct MRRow: View {
157 Text(mr.source.isEmpty ? "(source gone)" : mr.source) 157 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
158 .lineLimit(1) 158 .lineLimit(1)
159 Image(systemName: "arrow.right") 159 Image(systemName: "arrow.right")
160 .font(.caption2) 160 .font(.gbSans(.caption2))
161 Text(mr.targetRef) 161 Text(mr.targetRef)
162 Spacer() 162 Spacer()
163 Text(mr.createdAt, format: .relative(presentation: .named)) 163 Text(mr.createdAt, format: .relative(presentation: .named))
164 .foregroundStyle(.tertiary) 164 .foregroundStyle(.secondary)
165 } 165 }
166 .font(.caption) 166 .font(.gbSans(.caption))
167 .foregroundStyle(.secondary) 167 .foregroundStyle(.secondary)
168 } 168 }
169 .padding(.vertical, 2) 169 .padding(.vertical, 2)
@@ -174,19 +174,16 @@ struct MRStateBadge: View {
174 let state: String 174 let state: String
175 175
176 var body: some View { 176 var body: some View {
177 Text(state.replacingOccurrences(of: "_", with: " ")) 177 GBChip(state.replacingOccurrences(of: "_", with: " "), color)
178 .font(.caption2.weight(.medium))
179 .padding(.horizontal, 6)
180 .padding(.vertical, 1)
181 .background(color.opacity(0.15), in: Capsule())
182 .foregroundStyle(color)
183 } 178 }
184 179
180 /// The web's chip mapping: open=ok, merged=done, closed=bad,
181 /// source_gone=neutral.
185 private var color: Color { 182 private var color: Color {
186 switch state { 183 switch state {
187 case "open": .green 184 case "open": .gbOK
188 case "merged": .purple 185 case "merged": .gbDone
189 case "closed": .red 186 case "closed": .gbBad
190 default: .secondary 187 default: .secondary
191 } 188 }
192 } 189 }
gitbay/Views/MRs/MRView.swift +33 −33
@@ -24,8 +24,8 @@ struct MRView: View {
24 if let error = model.actionError { 24 if let error = model.actionError {
25 Section { 25 Section {
26 Label(error, systemImage: "hand.raised") 26 Label(error, systemImage: "hand.raised")
27 .foregroundStyle(.orange) 27 .foregroundStyle(Color.gbWarn)
28 .font(.subheadline) 28 .font(.gbSans(.subheadline))
29 } 29 }
30 } 30 }
31 31
@@ -96,23 +96,23 @@ struct MRView: View {
96 Section { 96 Section {
97 VStack(alignment: .leading, spacing: 6) { 97 VStack(alignment: .leading, spacing: 6) {
98 Text(mr.title) 98 Text(mr.title)
99 .font(.headline) 99 .font(.gbSans(.headline))
100 HStack(spacing: 6) { 100 HStack(spacing: 6) {
101 MRStateBadge(state: mr.state) 101 MRStateBadge(state: mr.state)
102 Text(mr.source.isEmpty ? "(source gone)" : mr.source) 102 Text(mr.source.isEmpty ? "(source gone)" : mr.source)
103 .lineLimit(1) 103 .lineLimit(1)
104 Image(systemName: "arrow.right") 104 Image(systemName: "arrow.right")
105 .font(.caption2) 105 .font(.gbSans(.caption2))
106 Text(mr.targetRef) 106 Text(mr.targetRef)
107 } 107 }
108 .font(.caption) 108 .font(.gbSans(.caption))
109 .foregroundStyle(.secondary) 109 .foregroundStyle(.secondary)
110 HStack(spacing: 6) { 110 HStack(spacing: 6) {
111 Text("by \(mr.author)") 111 Text("by \(mr.author)")
112 Text(mr.createdAt, format: .relative(presentation: .named)) 112 Text(mr.createdAt, format: .relative(presentation: .named))
113 .foregroundStyle(.tertiary) 113 .foregroundStyle(.tertiary)
114 } 114 }
115 .font(.caption) 115 .font(.gbSans(.caption))
116 .foregroundStyle(.secondary) 116 .foregroundStyle(.secondary)
117 } 117 }
118 .padding(.vertical, 2) 118 .padding(.vertical, 2)
@@ -127,12 +127,12 @@ struct MRView: View {
127 Spacer() 127 Spacer()
128 if let diff = model.diff { 128 if let diff = model.diff {
129 Text("+\(diff.additions)") 129 Text("+\(diff.additions)")
130 .foregroundStyle(.green) 130 .foregroundStyle(Color.gbOK)
131 Text("−\(diff.deletions)") 131 Text("−\(diff.deletions)")
132 .foregroundStyle(.red) 132 .foregroundStyle(Color.gbBad)
133 } 133 }
134 } 134 }
135 .font(.subheadline) 135 .font(.gbSans(.subheadline))
136 } 136 }
137 } 137 }
138 } 138 }
@@ -142,10 +142,10 @@ struct MRView: View {
142 ForEach(commits) { commit in 142 ForEach(commits) { commit in
143 HStack(spacing: 8) { 143 HStack(spacing: 8) {
144 Text(commit.shortSHA) 144 Text(commit.shortSHA)
145 .font(.caption.monospaced()) 145 .font(.gbMono(.caption))
146 .foregroundStyle(.secondary) 146 .foregroundStyle(.secondary)
147 Text(commit.subject) 147 Text(commit.subject)
148 .font(.subheadline) 148 .font(.gbSans(.subheadline))
149 .lineLimit(1) 149 .lineLimit(1)
150 } 150 }
151 } 151 }
@@ -159,10 +159,10 @@ struct MRView: View {
159 Image(systemName: checkIcon(check.state)) 159 Image(systemName: checkIcon(check.state))
160 .foregroundStyle(checkColor(check.state)) 160 .foregroundStyle(checkColor(check.state))
161 Text(check.context) 161 Text(check.context)
162 .font(.subheadline) 162 .font(.gbSans(.subheadline))
163 Spacer() 163 Spacer()
164 Text(check.state) 164 Text(check.state)
165 .font(.caption) 165 .font(.gbSans(.caption))
166 .foregroundStyle(.secondary) 166 .foregroundStyle(.secondary)
167 } 167 }
168 } 168 }
@@ -175,16 +175,16 @@ struct MRView: View {
175 HStack { 175 HStack {
176 Image(systemName: review.verdict == "approve" 176 Image(systemName: review.verdict == "approve"
177 ? "checkmark.circle.fill" : "exclamationmark.circle.fill") 177 ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
178 .foregroundStyle(review.verdict == "approve" ? .green : .orange) 178 .foregroundStyle(review.verdict == "approve" ? Color.gbOK : Color.gbWarn)
179 Text(review.reviewer) 179 Text(review.reviewer)
180 .font(.subheadline) 180 .font(.gbSans(.subheadline))
181 Spacer() 181 Spacer()
182 if review.stale { 182 if review.stale {
183 Text("stale") 183 Text("stale")
184 .font(.caption2) 184 .font(.gbSans(.caption2))
185 .padding(.horizontal, 5) 185 .padding(.horizontal, 5)
186 .padding(.vertical, 1) 186 .padding(.vertical, 1)
187 .background(.quaternary, in: Capsule()) 187 .background(.quaternary, in: gbChipShape)
188 } 188 }
189 } 189 }
190 } 190 }
@@ -205,13 +205,13 @@ struct MRView: View {
205 VStack(alignment: .leading, spacing: 4) { 205 VStack(alignment: .leading, spacing: 4) {
206 HStack { 206 HStack {
207 Text(comment.author) 207 Text(comment.author)
208 .font(.caption.weight(.semibold)) 208 .font(.gbSans(.caption).weight(.semibold))
209 Text(comment.createdAt, format: .relative(presentation: .named)) 209 Text(comment.createdAt, format: .relative(presentation: .named))
210 .font(.caption) 210 .font(.gbSans(.caption))
211 .foregroundStyle(.tertiary) 211 .foregroundStyle(.tertiary)
212 } 212 }
213 MarkdownView(markdown: comment.body) 213 MarkdownView(markdown: comment.body)
214 .font(.subheadline) 214 .font(.gbSans(.subheadline))
215 } 215 }
216 .padding(.vertical, 2) 216 .padding(.vertical, 2)
217 } 217 }
@@ -290,9 +290,9 @@ struct MRView: View {
290 290
291 private func checkColor(_ state: String) -> Color { 291 private func checkColor(_ state: String) -> Color {
292 switch state { 292 switch state {
293 case "success": .green 293 case "success": .gbOK
294 case "failure", "error": .red 294 case "failure", "error": .gbBad
295 case "pending", "running": .orange 295 case "pending", "running": .gbWarn
296 default: .secondary 296 default: .secondary
297 } 297 }
298 } 298 }
@@ -310,23 +310,23 @@ private struct ThreadView: View {
310 HStack(spacing: 6) { 310 HStack(spacing: 6) {
311 Image(systemName: thread.isResolved 311 Image(systemName: thread.isResolved
312 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right") 312 ? "checkmark.bubble" : "bubble.left.and.exclamationmark.bubble.right")
313 .font(.caption) 313 .font(.gbSans(.caption))
314 .foregroundStyle(thread.isResolved ? .green : .orange) 314 .foregroundStyle(thread.isResolved ? Color.gbOK : Color.gbWarn)
315 Text("\(thread.path):\(thread.line)") 315 Text("\(thread.path):\(thread.line)")
316 .font(.caption.monospaced()) 316 .font(.gbMono(.caption))
317 .lineLimit(1) 317 .lineLimit(1)
318 if thread.stale { 318 if thread.stale {
319 Text("stale") 319 Text("stale")
320 .font(.caption2) 320 .font(.gbSans(.caption2))
321 .padding(.horizontal, 5) 321 .padding(.horizontal, 5)
322 .padding(.vertical, 1) 322 .padding(.vertical, 1)
323 .background(.quaternary, in: Capsule()) 323 .background(.quaternary, in: gbChipShape)
324 } 324 }
325 Spacer() 325 Spacer()
326 Button(thread.isResolved ? "Reopen" : "Resolve") { 326 Button(thread.isResolved ? "Reopen" : "Resolve") {
327 Task { await model.setResolved(thread, !thread.isResolved) } 327 Task { await model.setResolved(thread, !thread.isResolved) }
328 } 328 }
329 .font(.caption) 329 .font(.gbSans(.caption))
330 .buttonStyle(.bordered) 330 .buttonStyle(.bordered)
331 .disabled(model.working) 331 .disabled(model.working)
332 } 332 }
@@ -334,19 +334,19 @@ private struct ThreadView: View {
334 VStack(alignment: .leading, spacing: 2) { 334 VStack(alignment: .leading, spacing: 2) {
335 HStack { 335 HStack {
336 Text(comment.author) 336 Text(comment.author)
337 .font(.caption.weight(.semibold)) 337 .font(.gbSans(.caption).weight(.semibold))
338 Text(comment.createdAt, format: .relative(presentation: .named)) 338 Text(comment.createdAt, format: .relative(presentation: .named))
339 .font(.caption2) 339 .font(.gbSans(.caption2))
340 .foregroundStyle(.tertiary) 340 .foregroundStyle(.tertiary)
341 } 341 }
342 Text(comment.body) 342 Text(comment.body)
343 .font(.subheadline) 343 .font(.gbSans(.subheadline))
344 } 344 }
345 } 345 }
346 if !thread.isResolved { 346 if !thread.isResolved {
347 HStack { 347 HStack {
348 TextField("Reply", text: $replyText, axis: .vertical) 348 TextField("Reply", text: $replyText, axis: .vertical)
349 .font(.subheadline) 349 .font(.gbSans(.subheadline))
350 .lineLimit(1...4) 350 .lineLimit(1...4)
351 Button { 351 Button {
352 let text = replyText 352 let text = replyText
gitbay/Views/Orgs/OrgView.swift +6 −6
@@ -21,8 +21,8 @@ struct OrgView: View {
21 if let error = model.actionError { 21 if let error = model.actionError {
22 Section { 22 Section {
23 Label(error, systemImage: "hand.raised") 23 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange) 24 .foregroundStyle(Color.gbWarn)
25 .font(.subheadline) 25 .font(.gbSans(.subheadline))
26 } 26 }
27 } 27 }
28 membersSection(loaded.members) 28 membersSection(loaded.members)
@@ -72,7 +72,7 @@ struct OrgView: View {
72 HStack { 72 HStack {
73 NavigationLink(value: RepoRoute.profile(member.user)) { 73 NavigationLink(value: RepoRoute.profile(member.user)) {
74 Text(member.user) 74 Text(member.user)
75 .font(.subheadline) 75 .font(.gbSans(.subheadline))
76 } 76 }
77 Spacer() 77 Spacer()
78 Menu { 78 Menu {
@@ -80,10 +80,10 @@ struct OrgView: View {
80 Button("Member") { Task { await model.setRole(member, role: "member") } } 80 Button("Member") { Task { await model.setRole(member, role: "member") } }
81 } label: { 81 } label: {
82 Text(member.role) 82 Text(member.role)
83 .font(.caption) 83 .font(.gbSans(.caption))
84 .padding(.horizontal, 6) 84 .padding(.horizontal, 6)
85 .padding(.vertical, 2) 85 .padding(.vertical, 2)
86 .background(.quaternary, in: Capsule()) 86 .background(.quaternary, in: gbChipShape)
87 } 87 }
88 .disabled(model.working) 88 .disabled(model.working)
89 } 89 }
@@ -121,7 +121,7 @@ struct OrgView: View {
121 ForEach(teams, id: \.self) { team in 121 ForEach(teams, id: \.self) { team in
122 NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) { 122 NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) {
123 Label(team, systemImage: "person.3") 123 Label(team, systemImage: "person.3")
124 .font(.subheadline) 124 .font(.gbSans(.subheadline))
125 } 125 }
126 .swipeActions { 126 .swipeActions {
127 Button("Delete", role: .destructive) { 127 Button("Delete", role: .destructive) {
gitbay/Views/Orgs/TeamView.swift +6 −6
@@ -18,15 +18,15 @@ struct TeamView: View {
18 if let error = model.actionError { 18 if let error = model.actionError {
19 Section { 19 Section {
20 Label(error, systemImage: "hand.raised") 20 Label(error, systemImage: "hand.raised")
21 .foregroundStyle(.orange) 21 .foregroundStyle(Color.gbWarn)
22 .font(.subheadline) 22 .font(.gbSans(.subheadline))
23 } 23 }
24 } 24 }
25 25
26 Section("Members") { 26 Section("Members") {
27 ForEach(loaded.members ?? [], id: \.self) { user in 27 ForEach(loaded.members ?? [], id: \.self) { user in
28 Text(user) 28 Text(user)
29 .font(.subheadline) 29 .font(.gbSans(.subheadline))
30 .swipeActions { 30 .swipeActions {
31 Button("Remove", role: .destructive) { 31 Button("Remove", role: .destructive) {
32 Task { await model.removeMember(user) } 32 Task { await model.removeMember(user) }
@@ -53,14 +53,14 @@ struct TeamView: View {
53 ForEach(loaded.grants ?? []) { grant in 53 ForEach(loaded.grants ?? []) { grant in
54 HStack { 54 HStack {
55 Text(grant.repo) 55 Text(grant.repo)
56 .font(.subheadline) 56 .font(.gbSans(.subheadline))
57 .lineLimit(1) 57 .lineLimit(1)
58 Spacer() 58 Spacer()
59 Text(grant.role) 59 Text(grant.role)
60 .font(.caption) 60 .font(.gbSans(.caption))
61 .padding(.horizontal, 6) 61 .padding(.horizontal, 6)
62 .padding(.vertical, 2) 62 .padding(.vertical, 2)
63 .background(.quaternary, in: Capsule()) 63 .background(.quaternary, in: gbChipShape)
64 } 64 }
65 .swipeActions { 65 .swipeActions {
66 Button("Revoke", role: .destructive) { 66 Button("Revoke", role: .destructive) {
gitbay/Views/Releases/ReleaseListView.swift +6 −6
@@ -18,20 +18,20 @@ struct ReleaseListView: View {
18 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) { 18 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
19 VStack(alignment: .leading, spacing: 3) { 19 VStack(alignment: .leading, spacing: 3) {
20 Text(release.title.isEmpty ? release.tag : release.title) 20 Text(release.title.isEmpty ? release.tag : release.title)
21 .font(.subheadline.weight(.medium)) 21 .font(.gbSans(.subheadline).weight(.medium))
22 .lineLimit(2) 22 .lineLimit(2)
23 HStack(spacing: 6) { 23 HStack(spacing: 6) {
24 Text(release.tag) 24 Text(release.tag)
25 .font(.caption.monospaced()) 25 .font(.gbMono(.caption))
26 .foregroundStyle(.secondary) 26 .foregroundStyle(.secondary)
27 if let author = release.author { 27 if let author = release.author {
28 Text("by \(author)") 28 Text("by \(author)")
29 .font(.caption) 29 .font(.gbSans(.caption))
30 .foregroundStyle(.secondary) 30 .foregroundStyle(.secondary)
31 } 31 }
32 Spacer() 32 Spacer()
33 Text(release.createdAt, format: .relative(presentation: .named)) 33 Text(release.createdAt, format: .relative(presentation: .named))
34 .font(.caption) 34 .font(.gbSans(.caption))
35 .foregroundStyle(.tertiary) 35 .foregroundStyle(.tertiary)
36 } 36 }
37 } 37 }
@@ -101,8 +101,8 @@ private struct ReleaseCreateSheet: View {
101 if let error = model.createError { 101 if let error = model.createError {
102 Section { 102 Section {
103 Label(error, systemImage: "exclamationmark.triangle") 103 Label(error, systemImage: "exclamationmark.triangle")
104 .foregroundStyle(.red) 104 .foregroundStyle(Color.gbBad)
105 .font(.subheadline) 105 .font(.gbSans(.subheadline))
106 } 106 }
107 } 107 }
108 } 108 }
gitbay/Views/Releases/ReleaseView.swift +8 −8
@@ -19,24 +19,24 @@ struct ReleaseView: View {
19 if let error = model.actionError { 19 if let error = model.actionError {
20 Section { 20 Section {
21 Label(error, systemImage: "hand.raised") 21 Label(error, systemImage: "hand.raised")
22 .foregroundStyle(.orange) 22 .foregroundStyle(Color.gbWarn)
23 .font(.subheadline) 23 .font(.gbSans(.subheadline))
24 } 24 }
25 } 25 }
26 Section { 26 Section {
27 VStack(alignment: .leading, spacing: 6) { 27 VStack(alignment: .leading, spacing: 6) {
28 Text(release.title.isEmpty ? release.tag : release.title) 28 Text(release.title.isEmpty ? release.tag : release.title)
29 .font(.headline) 29 .font(.gbSans(.headline))
30 HStack(spacing: 6) { 30 HStack(spacing: 6) {
31 Text(release.tag) 31 Text(release.tag)
32 .font(.caption.monospaced()) 32 .font(.gbMono(.caption))
33 if let author = release.author { 33 if let author = release.author {
34 Text("by \(author)") 34 Text("by \(author)")
35 } 35 }
36 Text(release.createdAt, format: .relative(presentation: .named)) 36 Text(release.createdAt, format: .relative(presentation: .named))
37 .foregroundStyle(.tertiary) 37 .foregroundStyle(.tertiary)
38 } 38 }
39 .font(.caption) 39 .font(.gbSans(.caption))
40 .foregroundStyle(.secondary) 40 .foregroundStyle(.secondary)
41 } 41 }
42 .padding(.vertical, 2) 42 .padding(.vertical, 2)
@@ -56,16 +56,16 @@ struct ReleaseView: View {
56 HStack { 56 HStack {
57 VStack(alignment: .leading, spacing: 2) { 57 VStack(alignment: .leading, spacing: 2) {
58 Text(asset.name) 58 Text(asset.name)
59 .font(.caption.monospaced()) 59 .font(.gbMono(.caption))
60 .foregroundStyle(.primary) 60 .foregroundStyle(.primary)
61 .lineLimit(1) 61 .lineLimit(1)
62 Text(String(asset.sha256.prefix(16))) 62 Text(String(asset.sha256.prefix(16)))
63 .font(.caption2.monospaced()) 63 .font(.gbMono(.caption2))
64 .foregroundStyle(.tertiary) 64 .foregroundStyle(.tertiary)
65 } 65 }
66 Spacer() 66 Spacer()
67 Text(asset.size.formatted(.byteCount(style: .file))) 67 Text(asset.size.formatted(.byteCount(style: .file)))
68 .font(.caption) 68 .font(.gbSans(.caption))
69 .foregroundStyle(.secondary) 69 .foregroundStyle(.secondary)
70 Image(systemName: "arrow.down.circle") 70 Image(systemName: "arrow.down.circle")
71 .foregroundStyle(.secondary) 71 .foregroundStyle(.secondary)
gitbay/Views/Repos/FileView.swift +5 −5
@@ -40,7 +40,7 @@ struct FileView: View {
40 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).", 40 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
41 systemImage: "scissors" 41 systemImage: "scissors"
42 ) 42 )
43 .font(.caption) 43 .font(.gbSans(.caption))
44 .frame(maxWidth: .infinity, alignment: .leading) 44 .frame(maxWidth: .infinity, alignment: .leading)
45 .padding(8) 45 .padding(8)
46 .background(.yellow.opacity(0.15)) 46 .background(.yellow.opacity(0.15))
@@ -64,13 +64,13 @@ private struct CodeScrollView: View {
64 .padding(12) 64 .padding(12)
65 .frame(maxWidth: .infinity, alignment: .leading) 65 .frame(maxWidth: .infinity, alignment: .leading)
66 } 66 }
67 .font(.caption.monospaced()) 67 .font(.gbMono(.caption))
68 } 68 }
69 69
70 private var code: some View { 70 private var code: some View {
71 let font = UIFont.monospacedSystemFont( 71 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
72 ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .regular 72 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
73 ) 73 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
74 let highlighter = SyntaxHighlighter(colorScheme: colorScheme) 74 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
75 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) { 75 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) {
76 return Text(AttributedString(highlighted)) 76 return Text(AttributedString(highlighted))
gitbay/Views/Repos/LogView.swift +11 −11
@@ -28,21 +28,21 @@ struct CommitRow: View {
28 var body: some View { 28 var body: some View {
29 VStack(alignment: .leading, spacing: 4) { 29 VStack(alignment: .leading, spacing: 4) {
30 Text(commit.subject) 30 Text(commit.subject)
31 .font(.subheadline.weight(.medium)) 31 .font(.gbSans(.subheadline).weight(.medium))
32 .lineLimit(2) 32 .lineLimit(2)
33 HStack(spacing: 6) { 33 HStack(spacing: 6) {
34 Text(commit.shortSHA) 34 Text(commit.shortSHA)
35 .font(.caption.monospaced()) 35 .font(.gbMono(.caption))
36 .foregroundStyle(.secondary) 36 .foregroundStyle(.secondary)
37 SignatureBadge(signature: commit.signature) 37 SignatureBadge(signature: commit.signature)
38 Spacer() 38 Spacer()
39 Text(commit.authorName) 39 Text(commit.authorName)
40 .font(.caption) 40 .font(.gbSans(.caption))
41 .foregroundStyle(.secondary) 41 .foregroundStyle(.secondary)
42 .lineLimit(1) 42 .lineLimit(1)
43 Text(commit.date, format: .relative(presentation: .named)) 43 Text(commit.date, format: .relative(presentation: .named))
44 .font(.caption) 44 .font(.gbSans(.caption))
45 .foregroundStyle(.tertiary) 45 .foregroundStyle(.secondary)
46 } 46 }
47 } 47 }
48 .padding(.vertical, 2) 48 .padding(.vertical, 2)
@@ -61,7 +61,7 @@ struct SignatureBadge: View {
61 } icon: { 61 } icon: {
62 Image(systemName: icon) 62 Image(systemName: icon)
63 } 63 }
64 .font(.caption2) 64 .font(.gbSans(.caption2))
65 .foregroundStyle(color) 65 .foregroundStyle(color)
66 .labelStyle(.titleAndIcon) 66 .labelStyle(.titleAndIcon)
67 } 67 }
@@ -74,17 +74,17 @@ struct SignatureBadge: View {
74 case .unsigned: 74 case .unsigned:
75 nil 75 nil
76 case .verified: 76 case .verified:
77 ("checkmark.seal.fill", .green, signature.signer ?? "verified") 77 ("checkmark.seal.fill", .gbOK, signature.signer ?? "verified")
78 case .signedUnknownKey: 78 case .signedUnknownKey:
79 ("questionmark.diamond", .secondary, "unknown key") 79 ("questionmark.diamond", .secondary, "unknown key")
80 case .signedEmailMismatch: 80 case .signedEmailMismatch:
81 ("exclamationmark.triangle", .orange, "email mismatch") 81 ("exclamationmark.triangle", .gbWarn, "email mismatch")
82 case .signedKeyExpired: 82 case .signedKeyExpired:
83 ("clock.badge.exclamationmark", .orange, "key expired") 83 ("clock.badge.exclamationmark", .gbWarn, "key expired")
84 case .signedKeyRevoked: 84 case .signedKeyRevoked:
85 ("xmark.seal", .red, "key revoked") 85 ("xmark.seal", .gbBad, "key revoked")
86 case .badSignature: 86 case .badSignature:
87 ("xmark.seal.fill", .red, "bad signature") 87 ("xmark.seal.fill", .gbBad, "bad signature")
88 case .unrecognized(let state): 88 case .unrecognized(let state):
89 ("questionmark.circle", .secondary, state) 89 ("questionmark.circle", .secondary, state)
90 } 90 }
gitbay/Views/Repos/MarkdownView.swift +2 −2
@@ -119,10 +119,10 @@ struct MarkdownView: View {
119 case .code(let code): 119 case .code(let code):
120 ScrollView(.horizontal) { 120 ScrollView(.horizontal) {
121 Text(code) 121 Text(code)
122 .font(.caption.monospaced()) 122 .font(.gbMono(.caption))
123 .padding(10) 123 .padding(10)
124 } 124 }
125 .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) 125 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
126 case .quote(let text): 126 case .quote(let text):
127 HStack(spacing: 10) { 127 HStack(spacing: 10) {
128 RoundedRectangle(cornerRadius: 2) 128 RoundedRectangle(cornerRadius: 2)
gitbay/Views/Repos/RepoListView.swift +7 −7
@@ -81,8 +81,8 @@ private struct RepoCreateSheet: View {
81 if let error = model.errorMessage { 81 if let error = model.errorMessage {
82 Section { 82 Section {
83 Label(error, systemImage: "exclamationmark.triangle") 83 Label(error, systemImage: "exclamationmark.triangle")
84 .foregroundStyle(.red) 84 .foregroundStyle(Color.gbBad)
85 .font(.subheadline) 85 .font(.gbSans(.subheadline))
86 } 86 }
87 } 87 }
88 } 88 }
@@ -128,24 +128,24 @@ private struct RepoRow: View {
128 VStack(alignment: .leading, spacing: 3) { 128 VStack(alignment: .leading, spacing: 3) {
129 HStack(spacing: 6) { 129 HStack(spacing: 6) {
130 Text(repo.path) 130 Text(repo.path)
131 .font(.body.weight(.medium)) 131 .font(.gbSans(.body).weight(.medium))
132 .lineLimit(1) 132 .lineLimit(1)
133 if repo.isPrivate { 133 if repo.isPrivate {
134 Image(systemName: "lock.fill") 134 Image(systemName: "lock.fill")
135 .font(.caption2) 135 .font(.gbSans(.caption2))
136 .foregroundStyle(.secondary) 136 .foregroundStyle(.secondary)
137 } 137 }
138 if repo.isArchived { 138 if repo.isArchived {
139 Text("archived") 139 Text("archived")
140 .font(.caption2) 140 .font(.gbSans(.caption2))
141 .padding(.horizontal, 5) 141 .padding(.horizontal, 5)
142 .padding(.vertical, 1) 142 .padding(.vertical, 1)
143 .background(.quaternary, in: Capsule()) 143 .background(.quaternary, in: gbChipShape)
144 } 144 }
145 } 145 }
146 if let description = repo.description, !description.isEmpty { 146 if let description = repo.description, !description.isEmpty {
147 Text(description) 147 Text(description)
148 .font(.subheadline) 148 .font(.gbSans(.subheadline))
149 .foregroundStyle(.secondary) 149 .foregroundStyle(.secondary)
150 .lineLimit(2) 150 .lineLimit(2)
151 } 151 }
gitbay/Views/Repos/RepoSettingsView.swift +8 −8
@@ -21,8 +21,8 @@ struct RepoSettingsView: View {
21 if let error = model.actionError { 21 if let error = model.actionError {
22 Section { 22 Section {
23 Label(error, systemImage: "hand.raised") 23 Label(error, systemImage: "hand.raised")
24 .foregroundStyle(.orange) 24 .foregroundStyle(Color.gbWarn)
25 .font(.subheadline) 25 .font(.gbSans(.subheadline))
26 } 26 }
27 } 27 }
28 aboutSection(loaded) 28 aboutSection(loaded)
@@ -59,7 +59,7 @@ struct RepoSettingsView: View {
59 Button("Save") { 59 Button("Save") {
60 Task { await model.setDescription(descriptionText) } 60 Task { await model.setDescription(descriptionText) }
61 } 61 }
62 .font(.caption) 62 .font(.gbSans(.caption))
63 .disabled(model.working) 63 .disabled(model.working)
64 .accessibilityIdentifier("settings-description-save") 64 .accessibilityIdentifier("settings-description-save")
65 } 65 }
@@ -74,7 +74,7 @@ struct RepoSettingsView: View {
74 Button("Save") { 74 Button("Save") {
75 Task { await model.setWebsite(websiteText) } 75 Task { await model.setWebsite(websiteText) }
76 } 76 }
77 .font(.caption) 77 .font(.gbSans(.caption))
78 .disabled(model.working) 78 .disabled(model.working)
79 } 79 }
80 } 80 }
@@ -97,10 +97,10 @@ struct RepoSettingsView: View {
97 } 97 }
98 .disabled(model.working) 98 .disabled(model.working)
99 } 99 }
100 .font(.caption) 100 .font(.gbSans(.caption))
101 .padding(.horizontal, 8) 101 .padding(.horizontal, 8)
102 .padding(.vertical, 3) 102 .padding(.vertical, 3)
103 .background(.quaternary, in: Capsule()) 103 .background(.quaternary, in: gbChipShape)
104 } 104 }
105 } 105 }
106 } 106 }
@@ -143,12 +143,12 @@ struct RepoSettingsView: View {
143 ForEach(loaded.settings.branches, id: \.self) { branch in 143 ForEach(loaded.settings.branches, id: \.self) { branch in
144 HStack { 144 HStack {
145 Label(branch, systemImage: "lock.shield") 145 Label(branch, systemImage: "lock.shield")
146 .font(.subheadline) 146 .font(.gbSans(.subheadline))
147 Spacer() 147 Spacer()
148 Button("Unprotect") { 148 Button("Unprotect") {
149 Task { await model.unprotectBranch(branch) } 149 Task { await model.unprotectBranch(branch) }
150 } 150 }
151 .font(.caption) 151 .font(.gbSans(.caption))
152 .disabled(model.working) 152 .disabled(model.working)
153 } 153 }
154 } 154 }
gitbay/Views/Repos/RepoView.swift +9 −9
@@ -18,8 +18,8 @@ struct RepoView: View {
18 if let error = model.actionError { 18 if let error = model.actionError {
19 Section { 19 Section {
20 Label(error, systemImage: "hand.raised") 20 Label(error, systemImage: "hand.raised")
21 .foregroundStyle(.orange) 21 .foregroundStyle(Color.gbWarn)
22 .font(.subheadline) 22 .font(.gbSans(.subheadline))
23 } 23 }
24 } 24 }
25 header(detail) 25 header(detail)
@@ -123,28 +123,28 @@ struct RepoView: View {
123 VStack(alignment: .leading, spacing: 8) { 123 VStack(alignment: .leading, spacing: 8) {
124 HStack(spacing: 6) { 124 HStack(spacing: 6) {
125 Text(detail.path) 125 Text(detail.path)
126 .font(.headline) 126 .font(.gbSans(.headline))
127 if detail.visibility == "private" { 127 if detail.visibility == "private" {
128 Image(systemName: "lock.fill") 128 Image(systemName: "lock.fill")
129 .font(.caption) 129 .font(.gbSans(.caption))
130 .foregroundStyle(.secondary) 130 .foregroundStyle(.secondary)
131 } 131 }
132 if detail.isArchived { 132 if detail.isArchived {
133 Text("archived") 133 Text("archived")
134 .font(.caption2) 134 .font(.gbSans(.caption2))
135 .padding(.horizontal, 5) 135 .padding(.horizontal, 5)
136 .padding(.vertical, 1) 136 .padding(.vertical, 1)
137 .background(.quaternary, in: Capsule()) 137 .background(.quaternary, in: gbChipShape)
138 } 138 }
139 } 139 }
140 if let description = detail.description, !description.isEmpty { 140 if let description = detail.description, !description.isEmpty {
141 Text(description) 141 Text(description)
142 .font(.subheadline) 142 .font(.gbSans(.subheadline))
143 .foregroundStyle(.secondary) 143 .foregroundStyle(.secondary)
144 } 144 }
145 if let website = detail.website, let url = URL(string: website) { 145 if let website = detail.website, let url = URL(string: website) {
146 Link(website, destination: url) 146 Link(website, destination: url)
147 .font(.caption) 147 .font(.gbSans(.caption))
148 .lineLimit(1) 148 .lineLimit(1)
149 } 149 }
150 HStack(spacing: 12) { 150 HStack(spacing: 12) {
@@ -154,7 +154,7 @@ struct RepoView: View {
154 .lineLimit(1) 154 .lineLimit(1)
155 } 155 }
156 } 156 }
157 .font(.caption) 157 .font(.gbSans(.caption))
158 .foregroundStyle(.secondary) 158 .foregroundStyle(.secondary)
159 } 159 }
160 .padding(.vertical, 4) 160 .padding(.vertical, 4)
gitbay/Views/Repos/TreeView.swift +1 −1
@@ -57,7 +57,7 @@ private struct EntryRow: View {
57 Spacer() 57 Spacer()
58 if let size = entry.size, !entry.isDirectory { 58 if let size = entry.size, !entry.isDirectory {
59 Text(size.formatted(.byteCount(style: .file))) 59 Text(size.formatted(.byteCount(style: .file)))
60 .font(.caption) 60 .font(.gbSans(.caption))
61 .foregroundStyle(.tertiary) 61 .foregroundStyle(.tertiary)
62 .monospacedDigit() 62 .monospacedDigit()
63 } 63 }
gitbay/Views/Shared/ComposeSheet.swift +3 −3
@@ -27,15 +27,15 @@ struct ComposeSheet: View {
27 Section("Body") { 27 Section("Body") {
28 TextEditor(text: $bodyText) 28 TextEditor(text: $bodyText)
29 .frame(minHeight: 160) 29 .frame(minHeight: 160)
30 .font(.body) 30 .font(.gbSans(.body))
31 .autocorrectionDisabled() 31 .autocorrectionDisabled()
32 .accessibilityIdentifier("compose-body") 32 .accessibilityIdentifier("compose-body")
33 } 33 }
34 if let errorMessage { 34 if let errorMessage {
35 Section { 35 Section {
36 Label(errorMessage, systemImage: "exclamationmark.triangle") 36 Label(errorMessage, systemImage: "exclamationmark.triangle")
37 .foregroundStyle(.red) 37 .foregroundStyle(Color.gbBad)
38 .font(.subheadline) 38 .font(.gbSans(.subheadline))
39 } 39 }
40 } 40 }
41 } 41 }
gitbay/Views/SignInView.swift +2 −2
@@ -44,7 +44,7 @@ struct SignInView: View {
44 VStack(alignment: .leading, spacing: 8) { 44 VStack(alignment: .leading, spacing: 8) {
45 Text("Mint one over SSH on a machine that has your key:") 45 Text("Mint one over SSH on a machine that has your key:")
46 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d") 46 Text(verbatim: "gitbay auth token create --name iphone --scope full --ttl 90d")
47 .font(.caption.monospaced()) 47 .font(.gbMono(.caption))
48 .textSelection(.enabled) 48 .textSelection(.enabled)
49 Text("`--scope read` also works, but a read-only token cannot comment or merge.") 49 Text("`--scope read` also works, but a read-only token cannot comment or merge.")
50 } 50 }
@@ -53,7 +53,7 @@ struct SignInView: View {
53 if let errorMessage { 53 if let errorMessage {
54 Section { 54 Section {
55 Label(errorMessage, systemImage: "exclamationmark.triangle") 55 Label(errorMessage, systemImage: "exclamationmark.triangle")
56 .foregroundStyle(.red) 56 .foregroundStyle(Color.gbBad)
57 } 57 }
58 } 58 }
59 59
gitbay/gitbayApp.swift +11
@@ -5,10 +5,21 @@ struct gitbayApp: App {
5 5
6 @State private var session = SessionStore() 6 @State private var session = SessionStore()
7 7
8 init() {
9 GitbayFonts.register()
10 }
11
8 var body: some Scene { 12 var body: some Scene {
9 WindowGroup { 13 WindowGroup {
10 ContentView() 14 ContentView()
11 .environment(session) 15 .environment(session)
16 .font(.gbSans(.body))
17 .tint(.gbAccent)
18 // The screenshot checkpoint forces appearance per run;
19 // simulator clones ignore the base device's setting.
20 .preferredColorScheme(
21 ProcessInfo.processInfo.arguments.contains("-gb-dark") ? .dark : nil
22 )
12 } 23 }
13 } 24 }
14} 25}
gitbayTests/DashboardViewModelTests.swift +12
@@ -14,11 +14,17 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
14 14
15private let dashboardJSON = """ 15private let dashboardJSON = """
16 {"protocol_version":1,"data":{\ 16 {"protocol_version":1,"data":{\
17 "review_queue":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
18 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
17 "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\ 19 "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\
18 "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\ 20 "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\
19 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\ 21 "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\
20 "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\ 22 "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\
21 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\ 23 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\
24 "open_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\
25 "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\
26 "recent_activity":[{"id":7,"repo":"krz/gitbay-ios","actor":"cmc","kind":"mr.merged",\
27 "data":{"number":2},"created_at":"2026-08-27T10:30:00.000Z"}],\
22 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ 28 "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\
23 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\ 29 "sha":"65ba14e0000000000000","ref":"refs/heads/main",\
24 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\ 30 "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\
@@ -42,8 +48,11 @@ struct DashboardViewModelTests {
42 48
43 let data = try #require(model.state.value) 49 let data = try #require(model.state.value)
44 #expect(data.pinned.map(\.path) == ["krz/gitbay"]) 50 #expect(data.pinned.map(\.path) == ["krz/gitbay"])
51 #expect(data.reviewQueue.map(\.id) == ["krz/gitbay-ios#3"])
45 #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"]) 52 #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"])
46 #expect(data.assignedIssues.first?.number == 11) 53 #expect(data.assignedIssues.first?.number == 11)
54 #expect(data.openIssues.first?.number == 11)
55 #expect(data.recentActivity.first?.phrase == "merged !2")
47 #expect(data.builds.first?.status == "success") 56 #expect(data.builds.first?.status == "success")
48 // The whole screen cost exactly one request. 57 // The whole screen cost exactly one request.
49 #expect(stub.seen.count == 1) 58 #expect(stub.seen.count == 1)
@@ -60,6 +69,9 @@ struct DashboardViewModelTests {
60 let data = try #require(model.state.value) 69 let data = try #require(model.state.value)
61 #expect(data.pinned.isEmpty) 70 #expect(data.pinned.isEmpty)
62 #expect(data.openMRs.isEmpty) 71 #expect(data.openMRs.isEmpty)
72 #expect(data.reviewQueue.isEmpty)
73 #expect(data.openIssues.isEmpty)
74 #expect(data.recentActivity.isEmpty)
63 } 75 }
64 76
65 @Test func aFailureIsTheScreensState() async throws { 77 @Test func aFailureIsTheScreensState() async throws {
gitbayTests/DiscoveryTests.swift +3 −2
@@ -30,9 +30,10 @@ struct FeedEventTests {
30 30
31 let build = try decode(""" 31 let build = try decode("""
32 {"id":1,"repo":"krz/gitbay","actor":"","kind":"build.success",\ 32 {"id":1,"repo":"krz/gitbay","actor":"","kind":"build.success",\
33 "data":{"number":56},"created_at":"2026-08-27T15:31:10Z"} 33 "data":{"number":56,"job":"ci"},"created_at":"2026-08-27T15:31:10Z"}
34 """) 34 """)
35 #expect(build.phrase == "build #56 succeeded") 35 #expect(build.phrase == "build success ci")
36 #expect(build.displayActor == "gitbay")
36 #expect(build.destination == .build(repo: "krz/gitbay", number: 56)) 37 #expect(build.destination == .build(repo: "krz/gitbay", number: 56))
37 38
38 let release = try decode(""" 39 let release = try decode("""
gitbayUITests/LiveSmokeUITests.swift +60
@@ -23,6 +23,11 @@ final class LiveSmokeUITests: XCTestCase {
23 ) 23 )
24 continueAfterFailure = false 24 continueAfterFailure = false
25 app = XCUIApplication() 25 app = XCUIApplication()
26 // xcodebuild runs tests on simulator clones, so the base
27 // device's appearance never applies; force it per run.
28 if ProcessInfo.processInfo.environment["GITBAY_UITEST_DARK"] == "1" {
29 app.launchArguments.append("-gb-dark")
30 }
26 app.launch() 31 app.launch()
27 } 32 }
28 33
@@ -473,3 +478,58 @@ extension LiveSmokeUITests {
473 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted") 478 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted")
474 } 479 }
475} 480}
481
482extension LiveSmokeUITests {
483
484 /// The pre-merge screenshot checkpoint: walks the dense screens and
485 /// attaches captures. Run per device/appearance; export the
486 /// attachments from the xcresult. Signs in first when the device has
487 /// no session and TEST_RUNNER_GITBAY_UITEST_TOKEN is provided.
488 func testScreenshotCheckpoint() throws {
489 signInIfNeeded()
490
491 snap("dashboard")
492
493 openRepo("krz/gitbay")
494 snap("repo")
495
496 app.staticTexts["Merge Requests"].firstMatch.tap()
497 let all = app.segmentedControls.buttons["All"].firstMatch
498 XCTAssertTrue(all.waitForExistence(timeout: 10), "state picker missing")
499 all.tap()
500 // A real MR row carries its !number; the picker row does not.
501 let mrNumber = app.staticTexts
502 .containing(NSPredicate(format: "label BEGINSWITH '!'")).firstMatch
503 XCTAssertTrue(mrNumber.waitForExistence(timeout: 15), "no MR rows after All")
504 snap("mr-list")
505
506 mrNumber.tap()
507 XCTAssertTrue(app.staticTexts["Diff"].firstMatch.waitForExistence(timeout: 15),
508 "MR detail did not open")
509 snap("mr-detail")
510
511 app.staticTexts["Diff"].firstMatch.tap()
512 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 15))
513 snap("diff")
514 }
515
516 private func signInIfNeeded() {
517 let tokenField = app.secureTextFields.firstMatch
518 guard tokenField.waitForExistence(timeout: 5) else { return }
519 guard let token = ProcessInfo.processInfo.environment["GITBAY_UITEST_TOKEN"] else {
520 XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided")
521 return
522 }
523 tokenField.tap()
524 tokenField.typeText(token + "\n")
525 XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch
526 .waitForExistence(timeout: 20), "sign-in did not land")
527 }
528
529 private func snap(_ name: String) {
530 let attachment = XCTAttachment(screenshot: app.screenshot())
531 attachment.name = name
532 attachment.lifetime = .keepAlways
533 add(attachment)
534 }
535}