feat(client/apple): an About page worth opening, and profile colours you can actually pick
About was a bare "Punktfunk / Version x" header over 885 KB of license text — it answered the one question nobody opens About to ask. It is now the shape every Mac user already knows: the app's icon, its name, the version (selectable, because a bug report is worth more with it), the tagline, and then the ways out — documentation, community, source, licenses. The license wall is still `AcknowledgementsView`, one push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation stack to push onto. Every platform hides the app icon somewhere different and tvOS can't hand it over at all (layered assets, no single image), so there's a drawn fallback in the same brand gradient and Geist monogram as the host cards — where the real icon is unavailable it reads as a mark, not as a broken image. tvOS also has no browser: the addresses are text to read off the screen rather than links to nowhere. And profile colours were half-built by me: the catalog stored `accent`, the chips tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸" over a fixed palette — the chip is small tinted text on a tinted capsule, and a colour picked freehand lands somewhere unreadable often enough to matter. The palette is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform writes still renders, which is what Android needs from this field. The colour now shows wherever the profile is named — the scope switcher's dot, the card chips, and the HUD's session line, which tints to the same colour the card that launched it wore. A colour is an identifier, and an identifier that changes between surfaces isn't one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -325,7 +325,7 @@ struct ProfileChip: View {
|
||||
var prominent = false
|
||||
|
||||
var body: some View {
|
||||
let tint = profile.accentColor ?? Color.brand
|
||||
let tint = profile.accentColor
|
||||
return HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(tint)
|
||||
@@ -343,20 +343,6 @@ struct ProfileChip: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamProfile {
|
||||
/// The `#RRGGBB` accent as a colour, or nil when the profile hasn't been given one (the
|
||||
/// schema reserves the field; every surface falls back to the brand tint).
|
||||
var accentColor: Color? {
|
||||
guard let accent, accent.hasPrefix("#"), accent.count == 7,
|
||||
let value = UInt32(accent.dropFirst(), radix: 16)
|
||||
else { return nil }
|
||||
return Color(
|
||||
red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
|
||||
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
|
||||
struct DiscoveredCardView: View {
|
||||
|
||||
@@ -91,7 +91,8 @@ struct StreamHUDView: View {
|
||||
if let profile = model.settings.profileName {
|
||||
Text("· \(profile)")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.foregroundStyle(
|
||||
Color(hex: model.settings.profileAccent ?? "") ?? Color.accentColor)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// The About page — the app's identity card, on the shape every Mac user already knows from an
|
||||
// About panel: the icon, the name, the version, and then the ways out (documentation, community,
|
||||
// source, licenses). It replaced a bare header over an 885 KB wall of license text, which answered
|
||||
// the one question nobody opens About to ask.
|
||||
//
|
||||
// The license wall itself is still `AcknowledgementsView`; About links to it — pushed on
|
||||
// iOS/tvOS, where this page lives in a navigation stack, and as a sheet on macOS, where the
|
||||
// preferences TabView has no stack to push onto.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutView: View {
|
||||
/// Where the product actually lives. Kept here rather than scattered through the view so the
|
||||
/// three of them can be checked against the README in one glance.
|
||||
private enum Destination {
|
||||
static let docs = URL(string: "https://docs.punktfunk.unom.io")!
|
||||
static let community = URL(string: "https://discord.gg/kaPNvzMuGU")!
|
||||
static let source = URL(string: "https://git.unom.io/unom/punktfunk")!
|
||||
}
|
||||
|
||||
private static let tagline =
|
||||
"Low-latency desktop and game streaming with first-class Linux and Windows hosts."
|
||||
|
||||
#if os(macOS)
|
||||
@State private var showAcknowledgements = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
tvBody
|
||||
#else
|
||||
Form {
|
||||
Section {
|
||||
identity
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
// The header is a statement, not a control: no separator, no row insets
|
||||
// fighting the centring.
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
Section {
|
||||
linkRow("Documentation", systemImage: "book", url: Destination.docs)
|
||||
linkRow("Community", systemImage: "bubble.left.and.bubble.right",
|
||||
url: Destination.community)
|
||||
linkRow("Source Code", systemImage: "chevron.left.forwardslash.chevron.right",
|
||||
url: Destination.source)
|
||||
}
|
||||
Section {
|
||||
acknowledgementsRow
|
||||
} footer: {
|
||||
Text("Punktfunk is free software under MIT or Apache-2.0.")
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showAcknowledgements) {
|
||||
NavigationStack {
|
||||
AcknowledgementsView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { showAcknowledgements = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 640, height: 560)
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Identity
|
||||
|
||||
/// Icon, name, version, tagline — centred, in that order, at the sizes an About panel uses.
|
||||
private var identity: some View {
|
||||
VStack(spacing: 10) {
|
||||
AppIconView(side: Self.iconSide)
|
||||
Text("Punktfunk")
|
||||
.font(.geist(Self.nameFont, .bold, relativeTo: .largeTitle))
|
||||
Text(Self.versionLine)
|
||||
.font(.geist(Self.versionFont, relativeTo: .callout))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.modifier(SelectableIfPossible())
|
||||
Text(Self.tagline)
|
||||
.font(.geist(Self.taglineFont, relativeTo: .footnote))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: 320)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Version 0.21.0 (1)" — the build number only when it says something the version doesn't.
|
||||
/// A bug report is worth more with it, which is why it's selectable above.
|
||||
private static var versionLine: String {
|
||||
let info = Bundle.main.infoDictionary
|
||||
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
|
||||
let build = info?["CFBundleVersion"] as? String
|
||||
guard let build, !build.isEmpty, build != short else { return "Version \(short)" }
|
||||
return "Version \(short) (\(build))"
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
#if !os(tvOS)
|
||||
private func linkRow(_ title: String, systemImage: String, url: URL) -> some View {
|
||||
Link(destination: url) {
|
||||
HStack {
|
||||
Label(title, systemImage: systemImage)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
// The row already announces itself as a link.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
#if os(macOS)
|
||||
// A Link in a grouped Form renders as raw blue body text on macOS; the row is the button.
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
@ViewBuilder private var acknowledgementsRow: some View {
|
||||
#if os(macOS)
|
||||
Button {
|
||||
showAcknowledgements = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Acknowledgements", systemImage: "text.document")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
#else
|
||||
NavigationLink {
|
||||
AcknowledgementsView()
|
||||
} label: {
|
||||
Label("Acknowledgements", systemImage: "text.document")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - tvOS
|
||||
|
||||
#if os(tvOS)
|
||||
/// tvOS has no browser and no `openURL`, so the addresses are text to read off the screen
|
||||
/// rather than links to nowhere. Acknowledgements still pushes — that one is in the app.
|
||||
private var tvBody: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
identity
|
||||
VStack(spacing: 10) {
|
||||
tvAddress("Documentation", Destination.docs)
|
||||
tvAddress("Community", Destination.community)
|
||||
tvAddress("Source code", Destination.source)
|
||||
}
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
Text("Punktfunk is free software under MIT or Apache-2.0.")
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("About")
|
||||
}
|
||||
|
||||
private func tvAddress(_ title: String, _ url: URL) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(title)
|
||||
.font(.geist(22, .semibold, relativeTo: .headline))
|
||||
Text(url.absoluteString.replacingOccurrences(of: "https://", with: ""))
|
||||
.font(.geist(20, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Panel-sized on the Mac, a touch smaller in hand, and read-from-the-couch on TV.
|
||||
#if os(tvOS)
|
||||
private static let iconSide: CGFloat = 160
|
||||
private static let nameFont: CGFloat = 44
|
||||
private static let versionFont: CGFloat = 24
|
||||
private static let taglineFont: CGFloat = 22
|
||||
#else
|
||||
private static let iconSide: CGFloat = 96
|
||||
private static let nameFont: CGFloat = 28
|
||||
private static let versionFont: CGFloat = 14
|
||||
private static let taglineFont: CGFloat = 13
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The app's own icon, at whatever size About asks for.
|
||||
///
|
||||
/// Every platform hides it somewhere different, and one of them can't hand it over at all: macOS
|
||||
/// has the running app's icon; iOS keeps the primary icon's file names in `CFBundleIcons`; tvOS
|
||||
/// app icons are layered assets with no single image to load, and an unbundled `swift run` build
|
||||
/// has no icon in the first place. So there is a drawn fallback, built from the same brand
|
||||
/// gradient and Geist monogram as the host cards — where the real icon is unavailable this reads
|
||||
/// as a mark, not as a missing image.
|
||||
struct AppIconView: View {
|
||||
let side: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = Self.bundleIcon {
|
||||
image
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
monogram
|
||||
}
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.accessibilityHidden(true) // the app's name is the next line
|
||||
}
|
||||
|
||||
private var monogram: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: side * 0.22, style: .continuous)
|
||||
return shape
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.brand, Color.brand.opacity(0.7)],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.overlay {
|
||||
Text("P")
|
||||
.font(.geistFixed(side * 0.5, .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
private static var bundleIcon: Image? {
|
||||
#if os(macOS)
|
||||
return Image(nsImage: NSApplication.shared.applicationIconImage)
|
||||
#elseif os(iOS)
|
||||
// The last entry is the largest — `CFBundleIconFiles` is ordered small to large.
|
||||
guard let icons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let files = primary["CFBundleIconFiles"] as? [String],
|
||||
let name = files.last,
|
||||
let image = UIImage(named: name)
|
||||
else { return nil }
|
||||
return Image(uiImage: image)
|
||||
#else
|
||||
return nil // tvOS: layered icons have no single image to load
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// `textSelection(.enabled)` doesn't exist on tvOS, and a version string is the one thing here
|
||||
/// worth copying into a bug report.
|
||||
private struct SelectableIfPossible: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
#if os(tvOS)
|
||||
content
|
||||
#else
|
||||
content.textSelection(.enabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -316,16 +316,66 @@ extension SettingsView {
|
||||
nameDraft = Self.copyName(of: active.name, in: profiles)
|
||||
nameAction = .duplicate(active.id)
|
||||
}
|
||||
colorMenu(active)
|
||||
Button("Delete “\(active.name)”…", role: .destructive) {
|
||||
profilePendingDelete = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Color ▸" — what tints this profile's chip on the host cards and its dot here. A palette
|
||||
/// rather than a colour well: the chip is small tinted text on a tinted capsule, and a colour
|
||||
/// picked freehand lands somewhere unreadable often enough to matter (see `ProfileAccent`).
|
||||
@ViewBuilder
|
||||
private func colorMenu(_ active: StreamProfile) -> some View {
|
||||
Menu("Color") {
|
||||
Button {
|
||||
profiles.setAccent(active.id, to: nil)
|
||||
} label: {
|
||||
Self.checkable("Default", on: active.accent == nil)
|
||||
}
|
||||
ForEach(ProfileAccent.palette) { accent in
|
||||
Button {
|
||||
profiles.setAccent(active.id, to: accent.hex)
|
||||
} label: {
|
||||
Self.checkable(
|
||||
accent.name,
|
||||
on: active.accent?.caseInsensitiveCompare(accent.hex) == .orderedSame)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu row carrying a checkmark when it is the current choice. Two shapes rather than one
|
||||
/// `Label` with an empty symbol name — `Image(systemName: "")` is not a blank image.
|
||||
@ViewBuilder
|
||||
private static func checkable(_ title: String, on: Bool) -> some View {
|
||||
if on {
|
||||
Label(title, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the layer being edited, and one line on what editing it means. Shared by both
|
||||
/// chromes — the caption is a stacked line on macOS and the list section's footer on iOS.
|
||||
var scopeName: String { activeProfile?.name ?? "Default settings" }
|
||||
|
||||
/// The edited layer as a glyph: a profile's own colour, or the settings gear for the
|
||||
/// defaults. Colour is only worth choosing if it shows up where you chose it.
|
||||
@ViewBuilder
|
||||
var scopeDot: some View {
|
||||
if let profile = activeProfile {
|
||||
Circle()
|
||||
.fill(profile.accentColor)
|
||||
.frame(width: 9, height: 9)
|
||||
.accessibilityHidden(true) // the name is right beside it
|
||||
} else {
|
||||
Image(systemName: "gearshape")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
var scopeCaption: String {
|
||||
activeProfile == nil
|
||||
? "The settings every profile inherits from."
|
||||
@@ -339,9 +389,10 @@ extension SettingsView {
|
||||
profilePrompts(
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Menu { scopeMenuContent } label: {
|
||||
Label(
|
||||
scopeName,
|
||||
systemImage: activeProfile == nil ? "gearshape" : "slider.horizontal.3")
|
||||
HStack(spacing: 6) {
|
||||
scopeDot
|
||||
Text(scopeName)
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.fixedSize()
|
||||
@@ -365,6 +416,7 @@ extension SettingsView {
|
||||
Text("Editing")
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
scopeDot
|
||||
Text(scopeName)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -218,7 +218,7 @@ struct SettingsView: View {
|
||||
.tabItem { Label("Controllers", systemImage: "gamecontroller") }
|
||||
.tag(MacTab.controllers)
|
||||
|
||||
AcknowledgementsView()
|
||||
AboutView()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag(MacTab.about)
|
||||
}
|
||||
@@ -346,10 +346,11 @@ struct SettingsView: View {
|
||||
.navigationTitle("Controllers")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
case .about:
|
||||
// Already a full scrollable view that sets its own "Acknowledgements" title; pin the
|
||||
// display mode inline to match the five sibling detail pages (it would otherwise inherit
|
||||
// the large title from the "Settings" sidebar root).
|
||||
AcknowledgementsView()
|
||||
// The identity card; the license wall is one push further in. Inline title to match
|
||||
// the five sibling detail pages (it would otherwise inherit the large title from the
|
||||
// "Settings" sidebar root).
|
||||
AboutView()
|
||||
.navigationTitle("About")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -481,7 +482,7 @@ struct SettingsView: View {
|
||||
title: "Gamepad-optimized browsing",
|
||||
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||
tvCaption(Self.controllersFooter)
|
||||
NavigationLink("Acknowledgements") { AcknowledgementsView() }
|
||||
NavigationLink("About") { AboutView() }
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
|
||||
@@ -55,6 +55,9 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
/// to be guessed from the settings themselves.
|
||||
public var profileID: String?
|
||||
public var profileName: String?
|
||||
/// The profile's `#RRGGBB` chip colour, so the HUD names it in the same colour the card that
|
||||
/// launched it wore — the colour is an identifier, and it only works if it's the same one.
|
||||
public var profileAccent: String?
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -170,6 +173,7 @@ public struct EffectiveSettings: Equatable, Sendable {
|
||||
var out = base.applying(profile.overrides)
|
||||
out.profileID = profile.id
|
||||
out.profileName = profile.name
|
||||
out.profileAccent = profile.accent
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// A profile's chip colour (design/client-settings-profiles.md §5.1 "Change color"). The catalog
|
||||
// stores `accent` as `#RRGGBB` — a plain string, so the Rust and Kotlin catalogs read the same
|
||||
// value — and this is the palette the Apple client offers for it.
|
||||
//
|
||||
// A fixed palette rather than a full colour well, for two reasons. The chip is small, tinted text
|
||||
// on a tinted capsule, so a colour picked freehand can land somewhere unreadable; and a profile's
|
||||
// colour is an IDENTIFIER — "the orange one" — which works when there are eight of them and stops
|
||||
// working when there are sixteen million. Any `#RRGGBB` a newer client (or another platform)
|
||||
// writes still renders: the palette is what this client OFFERS, not what it accepts.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct ProfileAccent: Identifiable, Sendable, Hashable {
|
||||
/// `#RRGGBB`, lowercase — exactly what lands in the catalog.
|
||||
public let hex: String
|
||||
public let name: String
|
||||
|
||||
public var id: String { hex }
|
||||
|
||||
public var color: Color { Color(hex: hex) ?? .brand }
|
||||
|
||||
/// Hues that stay legible as tinted text on their own tinted capsule, in both appearances,
|
||||
/// and that stay tellable apart from each other at chip size.
|
||||
public static let palette: [ProfileAccent] = [
|
||||
ProfileAccent(hex: "#8b5cf6", name: "Violet"),
|
||||
ProfileAccent(hex: "#3aa0ff", name: "Blue"),
|
||||
ProfileAccent(hex: "#2bb8a6", name: "Teal"),
|
||||
ProfileAccent(hex: "#35c759", name: "Green"),
|
||||
ProfileAccent(hex: "#e0a800", name: "Amber"),
|
||||
ProfileAccent(hex: "#ff8800", name: "Orange"),
|
||||
ProfileAccent(hex: "#ff4f6d", name: "Red"),
|
||||
ProfileAccent(hex: "#ff4f9a", name: "Pink"),
|
||||
ProfileAccent(hex: "#8e8e93", name: "Graphite"),
|
||||
]
|
||||
|
||||
public static func named(_ hex: String?) -> ProfileAccent? {
|
||||
guard let hex else { return nil }
|
||||
return palette.first { $0.hex.caseInsensitiveCompare(hex) == .orderedSame }
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
/// Parse a `#RRGGBB` string. nil for anything else — a profile with an accent this build
|
||||
/// can't read falls back to the brand tint rather than rendering as black.
|
||||
init?(hex: String) {
|
||||
guard hex.hasPrefix("#"), hex.count == 7,
|
||||
let value = UInt32(hex.dropFirst(), radix: 16)
|
||||
else { return nil }
|
||||
self.init(
|
||||
red: Double((value >> 16) & 0xFF) / 255,
|
||||
green: Double((value >> 8) & 0xFF) / 255,
|
||||
blue: Double(value & 0xFF) / 255)
|
||||
}
|
||||
}
|
||||
|
||||
public extension StreamProfile {
|
||||
/// This profile's colour, or the brand tint when it has none — every surface that shows a
|
||||
/// profile reads it through here, so "no colour chosen" looks deliberate everywhere.
|
||||
var accentColor: Color { Color(hex: accent ?? "") ?? .brand }
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
// • the settings-profile catalog — including the don't-clobber rule that keeps an older build
|
||||
// from erasing a newer one's overlay fields just by opening a profile.
|
||||
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
@@ -354,6 +355,26 @@ final class SharedFoundationTests: XCTestCase {
|
||||
XCTAssertEqual(catalog.pinned(for: host).map(\.id), ["222222222222", "111111111111"])
|
||||
}
|
||||
|
||||
/// The accent is a plain `#RRGGBB` string in the catalog — the palette is what this client
|
||||
/// OFFERS, not what it accepts, so a colour another platform wrote still renders and a
|
||||
/// malformed one falls back to the brand tint rather than to black.
|
||||
func testProfileAccentParsing() {
|
||||
XCTAssertNotNil(Color(hex: "#ff8800"))
|
||||
XCTAssertNil(Color(hex: "ff8800"), "a missing # is not a colour")
|
||||
XCTAssertNil(Color(hex: "#ff88"), "a short value is not a colour")
|
||||
XCTAssertNil(Color(hex: "#gggggg"), "non-hex is not a colour")
|
||||
XCTAssertNil(Color(hex: ""))
|
||||
|
||||
// Every offered swatch parses, and each is distinguishable by name and value.
|
||||
XCTAssertEqual(Set(ProfileAccent.palette.map(\.hex)).count, ProfileAccent.palette.count)
|
||||
for accent in ProfileAccent.palette {
|
||||
XCTAssertNotNil(Color(hex: accent.hex), accent.name)
|
||||
XCTAssertEqual(ProfileAccent.named(accent.hex.uppercased())?.name, accent.name)
|
||||
}
|
||||
XCTAssertNil(ProfileAccent.named(nil))
|
||||
XCTAssertNil(ProfileAccent.named("#123456"), "an unlisted colour has no palette name")
|
||||
}
|
||||
|
||||
/// The whole per-connect resolution, in the precedence every client shares:
|
||||
/// one-off pick ?? host binding ?? none.
|
||||
func testEffectiveSettingsResolutionPrecedence() {
|
||||
@@ -367,7 +388,8 @@ final class SharedFoundationTests: XCTestCase {
|
||||
var workOverrides = SettingsOverlay()
|
||||
workOverrides.bitrateKbps = 8_000
|
||||
let catalog = ProfileCatalog(profiles: [
|
||||
StreamProfile(name: "Game", id: "111111111111", overrides: gameOverrides),
|
||||
StreamProfile(
|
||||
name: "Game", id: "111111111111", accent: "#ff8800", overrides: gameOverrides),
|
||||
StreamProfile(name: "Work", id: "222222222222", overrides: workOverrides),
|
||||
])
|
||||
var host = StoredHost(name: "Desk", address: "10.0.0.1")
|
||||
@@ -383,6 +405,9 @@ final class SharedFoundationTests: XCTestCase {
|
||||
let bound = EffectiveSettings.resolve(host: host, catalog: catalog, defaults: defaults)
|
||||
XCTAssertEqual(bound.bitrateKbps, 80_000)
|
||||
XCTAssertEqual(bound.profileName, "Game")
|
||||
// The chip colour rides along, so the HUD can name the session in the same colour the
|
||||
// card that launched it wore.
|
||||
XCTAssertEqual(bound.profileAccent, "#ff8800")
|
||||
|
||||
// A one-off pick wins over the binding — and does not rebind anything.
|
||||
let oneOff = EffectiveSettings.resolve(
|
||||
|
||||
Reference in New Issue
Block a user