krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

86bcd9452d3107f68aca085e03e69b02667c0dbb

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-13T03:06:16Z

feat: polish read-only project views and home integration

Refs: https://todo.sr.ht/~ccleberg/hutch/36
Refs: https://todo.sr.ht/~ccleberg/hutch/37
Refs: https://todo.sr.ht/~ccleberg/hutch/38
 Hutch.xcodeproj/project.pbxproj              |  16 +-
 Hutch/Extensions/SRHTWebURL.swift            |  16 ++
 Hutch/Models/Project.swift                   | 106 ++++++++++++++
 Hutch/Networking/ProjectService.swift        | 118 +++++++++++++++
 Hutch/Views/Home/HomeView.swift              |  68 ++++++---
 Hutch/Views/Home/HomeViewModel.swift         |  11 +-
 Hutch/Views/Projects/ProjectDetailView.swift | 152 ++++++++++++-------
 Hutch/Views/Projects/ProjectPinStore.swift   |  32 +++-
 Hutch/Views/Projects/ProjectsListView.swift  |  70 ++++++---
 Hutch/Views/Tickets/TicketListView.swift     | 209 +++++++++++++++++----------
 HutchTests/ProjectPinStoreTests.swift        |  13 ++
 HutchTests/ProjectTests.swift                |  21 +++
 12 files changed, 645 insertions(+), 187 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 57441b8..07c076b 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -515,7 +515,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 41;
+				CURRENT_PROJECT_VERSION = 43;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.18.0;
+				MARKETING_VERSION = 2.19.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 41;
+				CURRENT_PROJECT_VERSION = 43;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.18.0;
+				MARKETING_VERSION = 2.19.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 41;
+				CURRENT_PROJECT_VERSION = 43;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.18.0;
+				MARKETING_VERSION = 2.19.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 41;
+				CURRENT_PROJECT_VERSION = 43;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.18.0;
+				MARKETING_VERSION = 2.19.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index 9bc1552..cd8e8be 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -63,6 +63,22 @@ enum SRHTWebURL {
         )
     }
 
+    static func projectSource(_ source: Project.SourceRepo) -> URL? {
+        userScopedURL(
+            host: "\(source.repoType.service.rawValue).sr.ht",
+            ownerUsername: source.ownerUsername,
+            pathComponents: [source.name]
+        )
+    }
+
+    static func mailingList(ownerUsername: String, listName: String) -> URL? {
+        userScopedURL(
+            host: "lists.sr.ht",
+            ownerUsername: ownerUsername,
+            pathComponents: [listName]
+        )
+    }
+
     static func ticket(ownerUsername: String, trackerName: String, ticketId: Int) -> URL? {
         userScopedURL(
             host: "todo.sr.ht",
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 54c0318..678d52e 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -115,11 +115,104 @@ struct Project: Identifiable, Hashable, Sendable {
         return nil
     }
 
+    var displayName: String {
+        Self.normalizedText(name) ?? "Untitled Project"
+    }
+
+    var displayDescription: String? {
+        Self.normalizedText(description)
+    }
+
+    var displayTags: [String] {
+        var seen = Set<String>()
+        return tags.compactMap { tag in
+            guard let normalized = Self.normalizedText(tag) else { return nil }
+            let key = normalized.lowercased()
+            guard seen.insert(key).inserted else { return nil }
+            return normalized
+        }
+    }
+
+    var websiteURL: URL? {
+        guard let website = Self.normalizedText(website) else { return nil }
+        return URL(string: website)
+    }
+
+    var hasLinkedResources: Bool {
+        !sources.isEmpty || !trackers.isEmpty || !mailingLists.isEmpty
+    }
+
+    var metadataLine: String {
+        var parts = [visibility.displayName, updated.relativeDescription]
+        if let summary = resourceSummary {
+            parts.append(summary)
+        }
+        return parts.joined(separator: " • ")
+    }
+
     private static func resourceCountText(count: Int, singular: String) -> String? {
         guard count > 0 else { return nil }
         let label = count == 1 ? singular : "\(singular)s"
         return "\(count) \(label)"
     }
+
+    fileprivate static func normalizedText(_ value: String?) -> String? {
+        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
+              !trimmed.isEmpty else {
+            return nil
+        }
+        return trimmed
+    }
+}
+
+extension Project.MailingList {
+    var displayName: String {
+        Project.normalizedText(name) ?? "Untitled Mailing List"
+    }
+
+    var displayDescription: String? {
+        Project.normalizedText(description)
+    }
+
+    var ownerDisplayName: String {
+        Project.normalizedText(owner.canonicalName) ?? "~unknown"
+    }
+}
+
+extension Project.SourceRepo {
+    var displayName: String {
+        Project.normalizedText(name) ?? "Untitled Repository"
+    }
+
+    var displayDescription: String? {
+        Project.normalizedText(description)
+    }
+
+    var ownerDisplayName: String {
+        Project.normalizedText(owner.canonicalName) ?? "~unknown"
+    }
+
+    var webURL: URL? {
+        SRHTWebURL.projectSource(self)
+    }
+}
+
+extension Project.Tracker {
+    var displayName: String {
+        Project.normalizedText(name) ?? "Untitled Tracker"
+    }
+
+    var displayDescription: String? {
+        Project.normalizedText(description)
+    }
+
+    var ownerDisplayName: String {
+        Project.normalizedText(owner.canonicalName) ?? "~unknown"
+    }
+
+    var webURL: URL? {
+        SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: name)
+    }
 }
 
 extension String {
@@ -127,3 +220,16 @@ extension String {
         hasPrefix("~") ? String(dropFirst()) : self
     }
 }
+
+extension Visibility {
+    var displayName: String {
+        switch self {
+        case .public:
+            "Public"
+        case .unlisted:
+            "Unlisted"
+        case .private:
+            "Private"
+        }
+    }
+}
diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift
index 5a0b466..93ce71f 100644
--- a/Hutch/Networking/ProjectService.swift
+++ b/Hutch/Networking/ProjectService.swift
@@ -11,6 +11,17 @@ private struct ProjectPageUser: Decodable, Sendable {
 private struct ProjectPage: Decodable, Sendable {
     let results: [ProjectSummaryPayload]
     let cursor: String?
+
+    private enum CodingKeys: String, CodingKey {
+        case results
+        case cursor
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
+        cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+    }
 }
 
 private struct ProjectSummaryPayload: Decodable, Sendable {
@@ -21,6 +32,27 @@ private struct ProjectSummaryPayload: Decodable, Sendable {
     let visibility: Visibility
     let tags: [String]
     let updated: Date
+
+    private enum CodingKeys: String, CodingKey {
+        case rid
+        case name
+        case description
+        case website
+        case visibility
+        case tags
+        case updated
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        rid = try container.decode(String.self, forKey: .rid)
+        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        description = try container.decodeIfPresent(String.self, forKey: .description)
+        website = try container.decodeIfPresent(String.self, forKey: .website)
+        visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+        updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+    }
 }
 
 private struct ProjectDetailResponse: Decodable, Sendable {
@@ -38,11 +70,40 @@ private struct ProjectDetailPayload: Decodable, Sendable {
     let mailingLists: ProjectMailingListPage
     let sources: ProjectSourcePage
     let trackers: ProjectTrackerPage
+
+    private enum CodingKeys: String, CodingKey {
+        case rid
+        case name
+        case description
+        case website
+        case visibility
+        case tags
+        case updated
+        case mailingLists
+        case sources
+        case trackers
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        rid = try container.decode(String.self, forKey: .rid)
+        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        description = try container.decodeIfPresent(String.self, forKey: .description)
+        website = try container.decodeIfPresent(String.self, forKey: .website)
+        visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+        tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
+        updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
+        mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
+        sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
+        trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
+    }
 }
 
 private struct ProjectMailingListPage: Decodable, Sendable {
     let results: [ProjectMailingListPayload]
     let cursor: String?
+
+    static let empty = ProjectMailingListPage(results: [], cursor: nil)
 }
 
 private struct ProjectMailingListPayload: Decodable, Sendable {
@@ -51,11 +112,30 @@ private struct ProjectMailingListPayload: Decodable, Sendable {
     let description: String?
     let visibility: Visibility
     let owner: Entity
+
+    private enum CodingKeys: String, CodingKey {
+        case rid
+        case name
+        case description
+        case visibility
+        case owner
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        rid = try container.decode(String.self, forKey: .rid)
+        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        description = try container.decodeIfPresent(String.self, forKey: .description)
+        visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+        owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+    }
 }
 
 private struct ProjectSourcePage: Decodable, Sendable {
     let results: [ProjectSourcePayload]
     let cursor: String?
+
+    static let empty = ProjectSourcePage(results: [], cursor: nil)
 }
 
 private struct ProjectSourcePayload: Decodable, Sendable {
@@ -65,11 +145,32 @@ private struct ProjectSourcePayload: Decodable, Sendable {
     let visibility: Visibility
     let owner: Entity
     let repoType: Project.SourceRepo.RepoType
+
+    private enum CodingKeys: String, CodingKey {
+        case rid
+        case name
+        case description
+        case visibility
+        case owner
+        case repoType
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        rid = try container.decode(String.self, forKey: .rid)
+        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        description = try container.decodeIfPresent(String.self, forKey: .description)
+        visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+        owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+        repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
+    }
 }
 
 private struct ProjectTrackerPage: Decodable, Sendable {
     let results: [ProjectTrackerPayload]
     let cursor: String?
+
+    static let empty = ProjectTrackerPage(results: [], cursor: nil)
 }
 
 private struct ProjectTrackerPayload: Decodable, Sendable {
@@ -78,6 +179,23 @@ private struct ProjectTrackerPayload: Decodable, Sendable {
     let description: String?
     let visibility: Visibility
     let owner: Entity
+
+    private enum CodingKeys: String, CodingKey {
+        case rid
+        case name
+        case description
+        case visibility
+        case owner
+    }
+
+    init(from decoder: any Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        rid = try container.decode(String.self, forKey: .rid)
+        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
+        description = try container.decodeIfPresent(String.self, forKey: .description)
+        visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .public
+        owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
+    }
 }
 
 struct ProjectService: Sendable {
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index 42a6737..426d351 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -179,7 +179,7 @@ struct HomeView: View {
 
     @ViewBuilder
     private func projectsSection(_ viewModel: HomeViewModel) -> some View {
-        if !viewModel.pinnedProjects.isEmpty {
+        if viewModel.hasPinnedProjects {
             HomeSectionView("Pinned Projects", isExpanded: $projectsExpanded) {
                 NavigationLink {
                     ProjectsListView()
@@ -189,11 +189,27 @@ struct HomeView: View {
                 }
                 .buttonStyle(.plain)
             } content: {
-                ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
-                    NavigationLink {
-                        ProjectDetailView(project: project)
-                    } label: {
-                        HomeProjectRow(project: project)
+                if viewModel.isLoadingProjects && viewModel.pinnedProjects.isEmpty {
+                    HomeSectionLoadingRow(label: "Loading pinned projects")
+                } else if let error = viewModel.projectsError, viewModel.pinnedProjects.isEmpty {
+                    HomeSectionMessageRow(
+                        text: "Couldn’t load pinned projects.",
+                        systemImage: "exclamationmark.triangle",
+                        emphasized: true,
+                        accessibilityHint: error
+                    )
+                } else if viewModel.pinnedProjects.isEmpty {
+                    HomeSectionMessageRow(
+                        text: "Pinned projects will appear here when they’re available.",
+                        systemImage: "pin"
+                    )
+                } else {
+                    ForEach(viewModel.pinnedProjects.prefix(projectPreviewLimit)) { project in
+                        NavigationLink {
+                            ProjectDetailView(project: project)
+                        } label: {
+                            HomeProjectRow(project: project)
+                        }
                     }
                 }
             }
@@ -388,26 +404,34 @@ private struct HomeProjectRow: View {
     let project: Project
 
     var body: some View {
-        VStack(alignment: .leading, spacing: 4) {
-            Text(project.name)
-                .font(.subheadline.weight(.medium))
-                .lineLimit(1)
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(alignment: .top, spacing: 10) {
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(project.displayName)
+                        .font(.subheadline.weight(.medium))
+                        .foregroundStyle(.primary)
+                        .lineLimit(1)
 
-            if let description = project.description, !description.isEmpty {
-                Text(description)
-                    .font(.caption)
-                    .foregroundStyle(.secondary)
-                    .lineLimit(1)
-            }
+                    if let description = project.displayDescription {
+                        Text(description)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                            .lineLimit(2)
+                    }
+                }
 
-            if let summary = project.resourceSummary {
-                Text(summary)
-                    .font(.caption)
-                    .foregroundStyle(.tertiary)
-                    .lineLimit(1)
+                Spacer(minLength: 8)
+
+                VisibilityBadge(visibility: project.visibility)
             }
+
+            Text(project.metadataLine)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
         }
-        .padding(.vertical, 2)
+        .contentShape(Rectangle())
+        .padding(.vertical, 4)
     }
 }
 
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index e9f0f50..682c94d 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -185,6 +185,7 @@ final class HomeViewModel {
     private(set) var isLoadingProjects = false
     private(set) var isLoadingAssignedTickets = false
     private(set) var isLoadingRecentBuilds = false
+    private(set) var projectsError: String?
     private(set) var assignedTicketsError: String?
     private(set) var recentBuildsError: String?
 
@@ -332,6 +333,7 @@ final class HomeViewModel {
         isLoadingAssignedTickets = true
         isLoadingRecentBuilds = true
         isLoadingSystemStatus = true
+        projectsError = nil
         assignedTicketsError = nil
         recentBuildsError = nil
         isShowingStaleSystemStatus = false
@@ -347,8 +349,9 @@ final class HomeViewModel {
         switch projectsResult {
         case .success(let projects):
             self.projects = projects
-        case .failure:
-            self.projects = []
+            self.projectsError = nil
+        case .failure(let error):
+            self.projectsError = error.userFacingMessage
         }
         isLoadingProjects = false
 
@@ -407,6 +410,10 @@ final class HomeViewModel {
         return pinnedIDs.compactMap { projectsByID[$0] }
     }
 
+    var hasPinnedProjects: Bool {
+        !ProjectPinStore.loadPinnedProjectIDs(for: currentUserKey).isEmpty
+    }
+
     var failedBuildCount: Int {
         recentBuilds.filter {
             switch $0.job.status {
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index a04c526..11758ae 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -5,6 +5,7 @@ struct ProjectDetailView: View {
 
     @Environment(AppState.self) private var appState
     @Environment(\.dismiss) private var dismiss
+    @Environment(\.openURL) private var openURL
     @State private var detailProject: Project?
     @State private var isLoading = false
     @State private var error: String?
@@ -27,13 +28,14 @@ struct ProjectDetailView: View {
     var body: some View {
         List {
             headerSection
-            linksSection
             repositoriesSection
             trackersSection
             mailingListsSection
+            linksSection
+            emptyResourcesSection
         }
         .themedList()
-        .navigationTitle(displayedProject.name)
+        .navigationTitle(displayedProject.displayName)
         .navigationBarTitleDisplayMode(.inline)
         .overlay {
             if isLoading, detailProject == nil, !project.isFullyLoaded {
@@ -71,19 +73,19 @@ struct ProjectDetailView: View {
     private var headerSection: some View {
         Section {
             VStack(alignment: .leading, spacing: 10) {
-                Text(displayedProject.name)
+                Text(displayedProject.displayName)
                     .font(.headline)
 
-                if let description = displayedProject.description, !description.isEmpty {
+                if let description = displayedProject.displayDescription {
                     Text(description)
                         .font(.subheadline)
                         .foregroundStyle(.secondary)
                 }
 
-                if !displayedProject.tags.isEmpty {
+                if !displayedProject.displayTags.isEmpty {
                     ScrollView(.horizontal, showsIndicators: false) {
                         HStack(spacing: 8) {
-                            ForEach(displayedProject.tags, id: \.self) { tag in
+                            ForEach(displayedProject.displayTags, id: \.self) { tag in
                                 Text(tag)
                                     .font(.caption.weight(.medium))
                                     .padding(.horizontal, 10)
@@ -94,7 +96,11 @@ struct ProjectDetailView: View {
                     }
                 }
 
+                LabeledContent("Project", value: displayedProject.visibility.displayName)
                 LabeledContent("Updated", value: displayedProject.updated.relativeDescription)
+                if let summary = displayedProject.resourceSummary {
+                    LabeledContent("Linked", value: summary)
+                }
             }
             .padding(.vertical, 4)
         }
@@ -106,10 +112,20 @@ struct ProjectDetailView: View {
         if !links.isEmpty {
             Section("Links") {
                 ForEach(links) { link in
-                    Link(destination: link.url) {
-                        Label(link.title, systemImage: link.systemImage)
-                            .font(.subheadline)
+                    Button {
+                        openURL(link.url)
+                    } label: {
+                        HStack(spacing: 12) {
+                            Label(link.title, systemImage: link.systemImage)
+                                .font(.subheadline)
+                                .foregroundStyle(.primary)
+                            Spacer()
+                            Image(systemName: "arrow.up.right")
+                                .font(.caption.weight(.semibold))
+                                .foregroundStyle(.tertiary)
+                        }
                     }
+                    .buttonStyle(.plain)
                 }
             }
         }
@@ -122,14 +138,19 @@ struct ProjectDetailView: View {
                 ForEach(displayedProject.sources) { source in
                     Button {
                         Task {
-                            try? await appState.openProjectSource(source)
-                            dismiss()
+                            do {
+                                try await appState.openProjectSource(source)
+                                dismiss()
+                            } catch {
+                                self.error = "Couldn’t open repository. \(error.userFacingMessage)"
+                            }
                         }
                     } label: {
                         ProjectResourceRow(
-                            title: source.name,
-                            subtitle: source.owner.canonicalName,
-                            detail: source.description
+                            title: source.displayName,
+                            subtitle: source.ownerDisplayName,
+                            detail: source.displayDescription,
+                            systemImage: "book.closed"
                         )
                     }
                     .buttonStyle(.plain)
@@ -145,14 +166,19 @@ struct ProjectDetailView: View {
                 ForEach(displayedProject.trackers) { tracker in
                     Button {
                         Task {
-                            try? await appState.openProjectTracker(tracker)
-                            dismiss()
+                            do {
+                                try await appState.openProjectTracker(tracker)
+                                dismiss()
+                            } catch {
+                                self.error = "Couldn’t open tracker. \(error.userFacingMessage)"
+                            }
                         }
                     } label: {
                         ProjectResourceRow(
-                            title: tracker.name,
-                            subtitle: tracker.owner.canonicalName,
-                            detail: tracker.description
+                            title: tracker.displayName,
+                            subtitle: tracker.ownerDisplayName,
+                            detail: tracker.displayDescription,
+                            systemImage: "checklist"
                         )
                     }
                     .buttonStyle(.plain)
@@ -171,9 +197,10 @@ struct ProjectDetailView: View {
                         dismiss()
                     } label: {
                         ProjectResourceRow(
-                            title: mailingList.name,
-                            subtitle: mailingList.owner.canonicalName,
-                            detail: mailingList.description
+                            title: mailingList.displayName,
+                            subtitle: mailingList.ownerDisplayName,
+                            detail: mailingList.displayDescription,
+                            systemImage: "list.bullet"
                         )
                     }
                     .buttonStyle(.plain)
@@ -182,6 +209,19 @@ struct ProjectDetailView: View {
         }
     }
 
+    @ViewBuilder
+    private var emptyResourcesSection: some View {
+        if !displayedProject.hasLinkedResources, displayedProject.websiteURL == nil {
+            Section {
+                ContentUnavailableView(
+                    "No Linked Resources",
+                    systemImage: "square.stack.3d.up.slash",
+                    description: Text("This project doesn’t currently expose repositories, trackers, mailing lists, or external links.")
+                )
+            }
+        }
+    }
+
     private func loadProjectIfNeeded(forceRefresh: Bool = false) async {
         guard forceRefresh || !project.isFullyLoaded else {
             detailProject = project
@@ -197,7 +237,7 @@ struct ProjectDetailView: View {
             let service = ProjectService(client: appState.client)
             detailProject = try await service.fetchProjectDetail(rid: project.id)
         } catch {
-            self.error = "Failed to load project"
+            self.error = "Couldn’t load project. \(error.userFacingMessage)"
         }
     }
 
@@ -210,31 +250,26 @@ struct ProjectDetailView: View {
     private func projectLinks(for project: Project) -> [ProjectLink] {
         var links: [ProjectLink] = []
 
-        if let website = project.website?.trimmingCharacters(in: .whitespacesAndNewlines),
-           let url = URL(string: website),
-           !website.isEmpty {
-            links.append(ProjectLink(id: "website", title: website, systemImage: "globe", url: url))
+        if let url = project.websiteURL {
+            links.append(ProjectLink(id: "website", title: project.website ?? url.absoluteString, systemImage: "globe", url: url))
         }
 
         if let source = project.sources.first,
-           let url = sourceURL(for: source) {
-            links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.name)", systemImage: "book.closed", url: url))
+           let url = source.webURL {
+            links.append(ProjectLink(id: "primary-repo", title: "\(source.ownerUsername)/\(source.displayName)", systemImage: "book.closed", url: url))
         }
 
         if let tracker = project.trackers.first,
-           let url = SRHTWebURL.tracker(ownerUsername: tracker.ownerUsername, trackerName: tracker.name) {
-            links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.name)", systemImage: "checklist", url: url))
+           let url = tracker.webURL {
+            links.append(ProjectLink(id: "primary-tracker", title: "\(tracker.ownerUsername)/\(tracker.displayName)", systemImage: "checklist", url: url))
         }
 
-        return links
-    }
+        if let mailingList = project.mailingLists.first,
+           let url = SRHTWebURL.mailingList(ownerUsername: mailingList.ownerUsername, listName: mailingList.name) {
+            links.append(ProjectLink(id: "primary-list", title: "\(mailingList.ownerUsername)/\(mailingList.displayName)", systemImage: "list.bullet", url: url))
+        }
 
-    private func sourceURL(for source: Project.SourceRepo) -> URL? {
-        var components = URLComponents()
-        components.scheme = "https"
-        components.host = "\(source.repoType.service.rawValue).sr.ht"
-        components.percentEncodedPath = "/~\(source.ownerUsername)/\(source.name)"
-        return components.url
+        return links
     }
 }
 
@@ -249,24 +284,39 @@ private struct ProjectResourceRow: View {
     let title: String
     let subtitle: String
     let detail: String?
+    let systemImage: String
 
     var body: some View {
-        VStack(alignment: .leading, spacing: 4) {
-            Text(title)
-                .font(.subheadline.weight(.medium))
-
-            Text(subtitle)
-                .font(.caption)
+        HStack(alignment: .top, spacing: 12) {
+            Image(systemName: systemImage)
+                .frame(width: 18, alignment: .leading)
                 .foregroundStyle(.secondary)
-                .lineLimit(1)
 
-            if let detail, !detail.isEmpty {
-                Text(detail)
+            VStack(alignment: .leading, spacing: 4) {
+                Text(title)
+                    .font(.subheadline.weight(.medium))
+                    .foregroundStyle(.primary)
+
+                Text(subtitle)
                     .font(.caption)
-                    .foregroundStyle(.tertiary)
-                    .lineLimit(2)
+                    .foregroundStyle(.secondary)
+                    .lineLimit(1)
+
+                if let detail, !detail.isEmpty {
+                    Text(detail)
+                        .font(.caption)
+                        .foregroundStyle(.tertiary)
+                        .lineLimit(2)
+                }
             }
+
+            Spacer(minLength: 8)
+
+            Image(systemName: "chevron.right")
+                .font(.caption.weight(.semibold))
+                .foregroundStyle(.tertiary)
         }
-        .padding(.vertical, 2)
+        .contentShape(Rectangle())
+        .padding(.vertical, 4)
     }
 }
diff --git a/Hutch/Views/Projects/ProjectPinStore.swift b/Hutch/Views/Projects/ProjectPinStore.swift
index cd3d961..77a4172 100644
--- a/Hutch/Views/Projects/ProjectPinStore.swift
+++ b/Hutch/Views/Projects/ProjectPinStore.swift
@@ -6,7 +6,7 @@ enum ProjectPinStore {
         defaults: UserDefaults = .standard
     ) -> [String] {
         let pinnedProjects = loadAll(defaults: defaults)
-        return pinnedProjects[userKey] ?? []
+        return normalizedProjectIDs(pinnedProjects[userKey] ?? [])
     }
 
     static func isPinned(
@@ -23,12 +23,14 @@ enum ProjectPinStore {
         defaults: UserDefaults = .standard
     ) {
         var pinnedProjects = loadAll(defaults: defaults)
-        var projectIDs = pinnedProjects[userKey] ?? []
+        var projectIDs = normalizedProjectIDs(pinnedProjects[userKey] ?? [])
 
         if let index = projectIDs.firstIndex(of: projectID) {
             projectIDs.remove(at: index)
         } else {
-            projectIDs.append(projectID)
+            if let normalizedProjectID = normalizedProjectID(projectID) {
+                projectIDs.append(normalizedProjectID)
+            }
         }
 
         pinnedProjects[userKey] = projectIDs
@@ -40,11 +42,33 @@ enum ProjectPinStore {
             return [:]
         }
 
-        return (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+        let decoded = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:]
+        return decoded.reduce(into: [String: [String]]()) { result, entry in
+            let normalizedUserKey = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !normalizedUserKey.isEmpty else { return }
+            let normalizedProjectIDs = normalizedProjectIDs(entry.value)
+            if !normalizedProjectIDs.isEmpty {
+                result[normalizedUserKey] = normalizedProjectIDs
+            }
+        }
     }
 
     private static func save(_ pinnedProjects: [String: [String]], defaults: UserDefaults) {
         guard let data = try? JSONEncoder().encode(pinnedProjects) else { return }
         defaults.set(data, forKey: AppStorageKeys.pinnedHomeProjects)
     }
+
+    private static func normalizedProjectIDs(_ projectIDs: [String]) -> [String] {
+        var seen = Set<String>()
+        return projectIDs.compactMap { projectID in
+            guard let normalizedProjectID = normalizedProjectID(projectID) else { return nil }
+            guard seen.insert(normalizedProjectID).inserted else { return nil }
+            return normalizedProjectID
+        }
+    }
+
+    private static func normalizedProjectID(_ projectID: String) -> String? {
+        let trimmed = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
+        return trimmed.isEmpty ? nil : trimmed
+    }
 }
diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift
index 0ffe183..5786462 100644
--- a/Hutch/Views/Projects/ProjectsListView.swift
+++ b/Hutch/Views/Projects/ProjectsListView.swift
@@ -34,7 +34,11 @@ final class ProjectsListViewModel {
         do {
             projects = try await service.fetchProjects()
         } catch {
-            self.error = "Failed to load projects"
+            if projects.isEmpty {
+                self.error = error.userFacingMessage
+            } else {
+                self.error = "Couldn’t refresh projects. \(error.userFacingMessage)"
+            }
         }
     }
 }
@@ -72,6 +76,8 @@ struct ProjectsListView: View {
                 } label: {
                     ProjectListRow(project: project)
                 }
+                .buttonStyle(.plain)
+                .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
             }
         }
         .themedList()
@@ -96,7 +102,7 @@ struct ProjectsListView: View {
                 ContentUnavailableView(
                     "No Projects",
                     systemImage: "square.stack.3d.up",
-                    description: Text("Your SourceHut projects will appear here.")
+                    description: Text("Projects from your SourceHut account will appear here when available.")
                 )
             }
         }
@@ -114,33 +120,49 @@ private struct ProjectListRow: View {
     let project: Project
 
     var body: some View {
-        VStack(alignment: .leading, spacing: 4) {
-            HStack(alignment: .firstTextBaseline, spacing: 8) {
-                Text(project.name)
-                    .font(.subheadline.weight(.medium))
-                    .lineLimit(1)
-
-                Spacer()
+        VStack(alignment: .leading, spacing: 6) {
+            HStack(alignment: .top, spacing: 10) {
+                VStack(alignment: .leading, spacing: 4) {
+                    Text(project.displayName)
+                        .font(.subheadline.weight(.medium))
+                        .foregroundStyle(.primary)
+                        .lineLimit(1)
+
+                    if let description = project.displayDescription {
+                        Text(description)
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                            .lineLimit(2)
+                    }
+                }
 
-                Text(project.updated.relativeDescription)
-                    .font(.caption)
-                    .foregroundStyle(.tertiary)
-            }
+                Spacer(minLength: 8)
 
-            if let description = project.description, !description.isEmpty {
-                Text(description)
-                    .font(.caption)
-                    .foregroundStyle(.secondary)
-                    .lineLimit(2)
+                VisibilityBadge(visibility: project.visibility)
             }
 
-            if let summary = project.resourceSummary {
-                Text(summary)
-                    .font(.caption)
-                    .foregroundStyle(.tertiary)
-                    .lineLimit(1)
+            Text(project.metadataLine)
+                .font(.caption)
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
+
+            if !project.displayTags.isEmpty {
+                ScrollView(.horizontal, showsIndicators: false) {
+                    HStack(spacing: 6) {
+                        ForEach(project.displayTags.prefix(4), id: \.self) { tag in
+                            Text(tag)
+                                .font(.caption2.weight(.medium))
+                                .foregroundStyle(.secondary)
+                                .padding(.horizontal, 8)
+                                .padding(.vertical, 3)
+                                .background(.quaternary, in: Capsule())
+                        }
+                    }
+                }
+                .scrollDisabled(true)
             }
         }
-        .padding(.vertical, 2)
+        .contentShape(Rectangle())
+        .padding(.vertical, 4)
     }
 }
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index fffaba4..3e0c656 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -194,75 +194,67 @@ struct TicketListView: View {
 
         List {
             Section {
-                VStack(alignment: .leading, spacing: 12) {
-                    Picker("Filter", selection: $vm.filter) {
-                        ForEach(TicketFilter.allCases, id: \.self) { filter in
-                            Text(filter.rawValue).tag(filter)
-                        }
-                    }
-                    .pickerStyle(.segmented)
-
-                    TicketQuickFilterBar(
-                        selectedLabels: viewModel.selectedLabels,
-                        savedFilters: viewModel.savedFilters,
-                        activeSavedFilterID: viewModel.activeSavedFilterID,
-                        canSaveCurrentFilter: viewModel.hasCustomFilterSelection
-                    ) {
-                        showLabelFilterSheet = true
-                    } onSaveFilter: {
-                        showSaveFilterSheet = true
-                    } onResetFilters: {
-                        vm.resetFilters()
-                    } onApplySavedFilter: { savedFilter in
-                        vm.applySavedFilter(savedFilter)
-                    } onDeleteSavedFilter: { savedFilter in
-                        vm.deleteSavedFilter(savedFilter)
-                    }
+                TicketListFilterHeader(
+                    selectedLabels: viewModel.selectedLabels,
+                    savedFilters: viewModel.savedFilters,
+                    activeSavedFilterID: viewModel.activeSavedFilterID,
+                    canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
+                    filter: $vm.filter
+                ) {
+                    showLabelFilterSheet = true
+                } onSaveFilter: {
+                    showSaveFilterSheet = true
+                } onResetFilters: {
+                    vm.resetFilters()
+                } onApplySavedFilter: { savedFilter in
+                    vm.applySavedFilter(savedFilter)
+                } onDeleteSavedFilter: { savedFilter in
+                    vm.deleteSavedFilter(savedFilter)
                 }
-                .padding(.vertical, 4)
-                .listRowBackground(Color.clear)
                 .listRowInsets(EdgeInsets())
-            }
+                .listRowBackground(Color.clear)
+                .listRowSeparator(.hidden)
 
-            // Tickets
-            ForEach(viewModel.filteredTickets) { ticket in
-                NavigationLink {
-                    TicketDetailView(
-                        ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
-                        trackerName: tracker.name,
-                        trackerId: tracker.id,
-                        trackerRid: tracker.rid,
-                        ticketId: ticket.id
-                    )
-                } label: {
-                    TicketRowView(ticket: ticket)
-                }
-                .swipeActions(edge: .leading, allowsFullSwipe: true) {
-                    if swipeActionsEnabled {
-                        ticketAssignSwipeAction(ticket, viewModel: viewModel)
+                ForEach(viewModel.filteredTickets) { ticket in
+                    NavigationLink {
+                        TicketDetailView(
+                            ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+                            trackerName: tracker.name,
+                            trackerId: tracker.id,
+                            trackerRid: tracker.rid,
+                            ticketId: ticket.id
+                        )
+                    } label: {
+                        TicketRowView(ticket: ticket)
                     }
-                }
-                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
-                    if swipeActionsEnabled {
-                        ticketStatusSwipeAction(ticket, viewModel: viewModel)
-                        ticketLabelSwipeAction(ticket, viewModel: viewModel)
+                    .swipeActions(edge: .leading, allowsFullSwipe: true) {
+                        if swipeActionsEnabled {
+                            ticketAssignSwipeAction(ticket, viewModel: viewModel)
+                        }
+                    }
+                    .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+                        if swipeActionsEnabled {
+                            ticketStatusSwipeAction(ticket, viewModel: viewModel)
+                            ticketLabelSwipeAction(ticket, viewModel: viewModel)
+                        }
+                    }
+                    .task {
+                        await viewModel.loadMoreIfNeeded(currentItem: ticket)
                     }
                 }
-                .task {
-                    await viewModel.loadMoreIfNeeded(currentItem: ticket)
-                }
-            }
 
-            if viewModel.isLoadingMore {
-                HStack {
-                    Spacer()
-                    ProgressView()
-                    Spacer()
+                if viewModel.isLoadingMore {
+                    HStack {
+                        Spacer()
+                        ProgressView()
+                        Spacer()
+                    }
+                    .listRowSeparator(.hidden)
                 }
-                .listRowSeparator(.hidden)
             }
         }
         .listStyle(.plain)
+        .listSectionSpacing(.compact)
         .searchable(
             text: $vm.searchText,
             placement: .navigationBarDrawer(displayMode: .always),
@@ -538,11 +530,12 @@ private struct TicketLabelsSheet: View {
     }
 }
 
-private struct TicketQuickFilterBar: View {
+private struct TicketListFilterHeader: View {
     let selectedLabels: [TicketLabel]
     let savedFilters: [SavedTicketFilter]
     let activeSavedFilterID: SavedTicketFilter.ID?
     let canSaveCurrentFilter: Bool
+    @Binding var filter: TicketFilter
     let onShowLabels: () -> Void
     let onSaveFilter: () -> Void
     let onResetFilters: () -> Void
@@ -551,26 +544,45 @@ private struct TicketQuickFilterBar: View {
 
     var body: some View {
         VStack(alignment: .leading, spacing: 10) {
-            HStack(spacing: 8) {
-                Button(action: onShowLabels) {
-                    Label(labelButtonTitle, systemImage: "tag")
-                        .font(.caption.weight(.medium))
+            Picker("Filter", selection: $filter) {
+                ForEach(TicketFilter.allCases, id: \.self) { filter in
+                    Text(filter.rawValue).tag(filter)
                 }
-                .buttonStyle(.bordered)
+            }
+            .pickerStyle(.segmented)
+
+            TicketQuickFilterBar(
+                selectedLabels: selectedLabels,
+                savedFilters: savedFilters,
+                activeSavedFilterID: activeSavedFilterID,
+                canSaveCurrentFilter: canSaveCurrentFilter,
+                onShowLabels: onShowLabels,
+                onSaveFilter: onSaveFilter,
+                onResetFilters: onResetFilters,
+                onApplySavedFilter: onApplySavedFilter,
+                onDeleteSavedFilter: onDeleteSavedFilter
+            )
+        }
+        .padding(.horizontal, 16)
+        .padding(.top, 6)
+        .padding(.bottom, 10)
+    }
+}
 
-                Button(action: onSaveFilter) {
-                    Label("Save Filter", systemImage: "square.and.arrow.down")
-                        .font(.caption.weight(.medium))
-                }
-                .buttonStyle(.bordered)
-                .disabled(!canSaveCurrentFilter)
+private struct TicketQuickFilterBar: View {
+    let selectedLabels: [TicketLabel]
+    let savedFilters: [SavedTicketFilter]
+    let activeSavedFilterID: SavedTicketFilter.ID?
+    let canSaveCurrentFilter: Bool
+    let onShowLabels: () -> Void
+    let onSaveFilter: () -> Void
+    let onResetFilters: () -> Void
+    let onApplySavedFilter: (SavedTicketFilter) -> Void
+    let onDeleteSavedFilter: (SavedTicketFilter) -> Void
 
-                if canSaveCurrentFilter {
-                    Button("Reset", action: onResetFilters)
-                        .font(.caption.weight(.medium))
-                        .buttonStyle(.bordered)
-                }
-            }
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            actionButtons
 
             if !selectedLabels.isEmpty {
                 ScrollView(.horizontal, showsIndicators: false) {
@@ -623,6 +635,51 @@ private struct TicketQuickFilterBar: View {
     private var labelButtonTitle: String {
         selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))"
     }
+
+    @ViewBuilder
+    private var actionButtons: some View {
+        ViewThatFits(in: .horizontal) {
+            HStack(spacing: 8) {
+                labelsButton
+                saveFilterButton
+                if canSaveCurrentFilter {
+                    resetButton
+                }
+                Spacer(minLength: 0)
+            }
+
+            FlowLayout(spacing: 8) {
+                labelsButton
+                saveFilterButton
+                if canSaveCurrentFilter {
+                    resetButton
+                }
+            }
+        }
+    }
+
+    private var labelsButton: some View {
+        Button(action: onShowLabels) {
+            Label(labelButtonTitle, systemImage: "tag")
+                .font(.caption.weight(.medium))
+        }
+        .buttonStyle(.bordered)
+    }
+
+    private var saveFilterButton: some View {
+        Button(action: onSaveFilter) {
+            Label("Save Filter", systemImage: "square.and.arrow.down")
+                .font(.caption.weight(.medium))
+        }
+        .buttonStyle(.bordered)
+        .disabled(!canSaveCurrentFilter)
+    }
+
+    private var resetButton: some View {
+        Button("Reset", action: onResetFilters)
+            .font(.caption.weight(.medium))
+            .buttonStyle(.bordered)
+    }
 }
 
 private struct TicketFilterLabelsSheet: View {
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
index a4ee0f7..17b4397 100644
--- a/HutchTests/ProjectPinStoreTests.swift
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -25,4 +25,17 @@ struct ProjectPinStoreTests {
 
         #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults).isEmpty)
     }
+
+    @Test
+    func loadPinnedProjectsNormalizesWhitespaceAndDuplicates() {
+        let defaults = UserDefaults(suiteName: #function)!
+        defaults.removePersistentDomain(forName: #function)
+
+        let encoded = try! JSONEncoder().encode([
+            "~alice": [" project-1 ", "", "project-1", "project-2"]
+        ])
+        defaults.set(encoded, forKey: AppStorageKeys.pinnedHomeProjects)
+
+        #expect(ProjectPinStore.loadPinnedProjectIDs(for: "~alice", defaults: defaults) == ["project-1", "project-2"])
+    }
 }
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
index b47356e..eff800f 100644
--- a/HutchTests/ProjectTests.swift
+++ b/HutchTests/ProjectTests.swift
@@ -71,4 +71,25 @@ struct ProjectTests {
 
         #expect(project.resourceSummary == "Website linked")
     }
+
+    @Test
+    func displayHelpersNormalizeBlankValues() {
+        let project = Project(
+            id: "project-1",
+            name: "  ",
+            description: "\n",
+            website: "https://example.com",
+            visibility: .unlisted,
+            tags: [" docs ", "", "Docs", "ios"],
+            updated: Date(timeIntervalSince1970: 0),
+            mailingLists: [],
+            sources: [],
+            trackers: []
+        )
+
+        #expect(project.displayName == "Untitled Project")
+        #expect(project.displayDescription == nil)
+        #expect(project.displayTags == ["docs", "ios"])
+        #expect(project.metadataLine.contains("Unlisted"))
+    }
 }