Localise BrewUIComponents copy

Every literal in the shared components now goes through
String(localized:bundle:comment:) or Text(verbatim:), and the target's
catalog is synced from the compiler's extraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Graeme Arthur
2026-09-21 22:03:35 +10:00
co-authored by Claude Opus 5
parent 294b922c76
commit 35ea43d3db
22 changed files with 353 additions and 159 deletions
@@ -9,7 +9,7 @@ import Foundation
/// the UI-free `BrewConfigSnapshot` never carries section labels (`AGENTS.md` — presentation boundary).
struct ConfigSectionItem: Identifiable {
let id: String
let title: String
let title: LocalizedStringResource
let rows: [ConfigDisplayRow]
}
@@ -15,9 +15,27 @@ extension ConfigViewModel {
/// `HOMEBREW_*` Environment surface is handled by the editor card and intentionally absent here.
func sections(for snapshot: BrewConfigSnapshot) -> [ConfigSectionItem] {
var result: [ConfigSectionItem] = []
appendIfNonEmpty(&result, id: "homebrew", title: "Homebrew", group: .homebrew, in: snapshot.entries)
appendIfNonEmpty(&result, id: "system", title: "System", group: .system, in: snapshot.entries)
appendIfNonEmpty(&result, id: "build", title: "Build settings", group: .build, in: snapshot.entries)
appendIfNonEmpty(
&result,
id: "homebrew",
title: LocalizedStringResource("Homebrew", bundle: #bundle, comment: "Configuration card title: brew version, prefix, taps"),
group: .homebrew,
in: snapshot.entries,
)
appendIfNonEmpty(
&result,
id: "system",
title: LocalizedStringResource("System", bundle: #bundle, comment: "Configuration card title: macOS, CPU, Xcode"),
group: .system,
in: snapshot.entries,
)
appendIfNonEmpty(
&result,
id: "build",
title: LocalizedStringResource("Build settings", bundle: #bundle, comment: "Configuration card title: HOMEBREW_* build options"),
group: .build,
in: snapshot.entries,
)
return result
}
@@ -47,10 +65,11 @@ extension ConfigViewModel {
}
private func sectionReport(for section: ConfigSectionItem) -> String {
let title = String(localized: section.title)
let body = section.rows
.map { "\($0.label): \($0.value)" }
.joined(separator: "\n")
return body.isEmpty ? "\(section.title)\n(none)" : "\(section.title)\n\(body)"
return body.isEmpty ? "\(title)\n(none)" : "\(title)\n\(body)"
}
/// `brew` could not be located — the view shows a dedicated empty state rather than the error chrome.
@@ -82,7 +101,7 @@ extension ConfigViewModel {
private func appendIfNonEmpty(
_ result: inout [ConfigSectionItem],
id: String,
title: String,
title: LocalizedStringResource,
group: ConfigGroup,
in entries: [BrewConfigEntry],
) {
@@ -29,8 +29,8 @@ struct ConsoleToolbar: View {
.frame(width: 18, height: 18)
}
.buttonStyle(.borderless)
.help("Hide console")
.accessibilityLabel("Hide console")
.help(hideLabel)
.accessibilityLabel(Text(hideLabel))
.axid(.consoleToggle)
}
.padding(.horizontal, BrewSpacing.md)
@@ -38,12 +38,16 @@ struct ConsoleToolbar: View {
.background(Color.brewSurface)
}
private var hideLabel: LocalizedStringResource {
LocalizedStringResource("Hide console", bundle: #bundle, comment: "Console toolbar: collapse button")
}
@ViewBuilder
private var jobPills: some View {
let selectedID = viewModel.selectedJob?.id
let orderedJobs = viewModel.orderedJobs
if orderedJobs.isEmpty {
Text("No activity")
Text("No activity", bundle: #bundle, comment: "Console toolbar title when nothing has run")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.brewTextSecondary)
.padding(.leading, BrewSpacing.xs)
@@ -67,23 +71,27 @@ struct ConsoleToolbar: View {
private var actionButtons: some View {
if let job = viewModel.selectedJob {
// Saving ends in a save panel, which is its own confirmation.
BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") {
BrewActionButton(
LocalizedStringResource("Save", bundle: #bundle, comment: "Console toolbar: save output to a file"),
systemImage: "square.and.arrow.down",
help: LocalizedStringResource("Save output to file", bundle: #bundle, comment: "Tooltip: console save button"),
) {
ConsoleOutputExport.save(job)
}
BrewActionButton(
"Copy",
LocalizedStringResource("Copy", bundle: #bundle, comment: "Console toolbar: copy output"),
systemImage: "doc.on.doc",
confirmationTitle: "Copied",
help: "Copy output to clipboard",
confirmationTitle: LocalizedStringResource("Copied", bundle: #bundle, comment: "Console toolbar: shown briefly after copying"),
help: LocalizedStringResource("Copy output to clipboard", bundle: #bundle, comment: "Tooltip: console copy button"),
) {
ConsoleOutputExport.copy(job)
}
}
BrewActionButton(
"Clear",
LocalizedStringResource("Clear", bundle: #bundle, comment: "Console toolbar: remove finished jobs"),
systemImage: "trash",
confirmationTitle: "Cleared",
help: "Clear completed jobs",
confirmationTitle: LocalizedStringResource("Cleared", bundle: #bundle, comment: "Console toolbar: shown briefly after clearing"),
help: LocalizedStringResource("Clear completed jobs", bundle: #bundle, comment: "Tooltip: console clear button"),
) {
viewModel.clearCompleted()
}
@@ -123,7 +131,7 @@ private struct JobPill: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Dismiss")
.help(String(localized: "Dismiss", bundle: #bundle, comment: "Tooltip: close a finished job tab"))
}
}
.padding(.horizontal, BrewSpacing.sm)
@@ -139,27 +139,31 @@ private struct DiscoverPackageDetailMetadataSection: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
PackageDetailSectionHeading(title: "Details")
PackageDetailSectionHeading(title: LocalizedStringResource("Details", bundle: #bundle, comment: "Package detail section heading"))
if let installedVersion = viewModel.installedVersionLabel {
detailRow(
label: "Installed",
label: String(localized: "Installed", bundle: #bundle, comment: "Package detail row: installed version(s)"),
value: installedVersion,
valueColor: viewModel.isInstalledVersionOutdated ? .brewStatusWarning : .brewTextPrimary,
valueFontWeight: .heavy,
)
}
detailRow(label: "Latest version", value: viewModel.stableVersionLabel)
detailRow(label: String(localized: "Latest version", bundle: #bundle, comment: "Package detail row"), value: viewModel.stableVersionLabel)
if viewModel.showsInstallMetrics {
detailRow(label: "30-day installs", value: viewModel.installs30DayLabel)
detailRow(label: String(
localized: "30-day installs",
bundle: #bundle,
comment: "Package detail row: Homebrew analytics install count",
), value: viewModel.installs30DayLabel)
}
if let dateValue = viewModel.installDateValue {
detailRow(label: "Installed on", value: dateValue)
detailRow(label: String(localized: "Installed on", bundle: #bundle, comment: "Package detail row: install date"), value: dateValue)
}
if let reason = viewModel.installReasonValue {
detailRow(label: "Install reason", value: reason)
detailRow(label: String(localized: "Install reason", bundle: #bundle, comment: "Package detail row: on request or as a dependency"), value: reason)
}
if let license = viewModel.licenseLabel {
detailRow(label: "License", value: license)
detailRow(label: String(localized: "License", bundle: #bundle, comment: "Package detail row"), value: license)
}
if let tap = viewModel.tapDisplayValue {
sourceRow(tap: tap, url: viewModel.sourceURL)
@@ -186,7 +190,7 @@ private struct DiscoverPackageDetailMetadataSection: View {
private func sourceRow(tap: String, url: URL?) -> some View {
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
Text("Source")
Text("Source", bundle: #bundle, comment: "Package detail row: source tap")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
.frame(width: labelWidth, alignment: .leading)
@@ -211,7 +215,7 @@ private struct DiscoverPackageDetailMetadataSection: View {
private func homepageRow(url: URL) -> some View {
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
Text("Homepage")
Text("Homepage", bundle: #bundle, comment: "Package detail row: homepage link")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
.frame(width: labelWidth, alignment: .leading)
@@ -233,10 +237,10 @@ private struct DiscoverPackageDetailDependenciesSection: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
PackageDetailSectionHeading(title: "Dependencies")
PackageDetailSectionHeading(title: LocalizedStringResource("Dependencies", bundle: #bundle, comment: "Package detail section heading"))
let deps = viewModel.dependencyNames
if deps.isEmpty {
Text("No dependencies.")
Text("No dependencies.", bundle: #bundle, comment: "Package detail: empty dependency list")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
} else {
@@ -262,11 +266,11 @@ private struct DiscoverPackageInstallSection: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
PackageDetailSectionHeading(title: "Install")
PackageDetailSectionHeading(title: LocalizedStringResource("Install", bundle: #bundle, comment: "Package detail section heading"))
VStack(alignment: .leading, spacing: BrewSpacing.md) {
CommandBlockView(
command: viewModel.installCommand,
summaryText: "Installs this package on your Mac",
summaryText: LocalizedStringResource("Installs this package on your Mac", bundle: #bundle, comment: "Command block summary above brew install"),
)
Button {
@@ -277,12 +281,12 @@ private struct DiscoverPackageInstallSection: View {
.controlSize(.small)
.frame(minWidth: 120)
} else {
Text("Install")
Text("Install", bundle: #bundle, comment: "Discover detail: install button")
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isInstalling)
.accessibilityLabel(Text("Install"))
.accessibilityLabel(String(localized: "Install", bundle: #bundle, comment: "VoiceOver: install button"))
.axid(.installButton)
if let installErrorMessage = viewModel.installErrorMessage {
@@ -299,10 +303,10 @@ private struct DiscoverPackageInstallSection: View {
struct DiscoverPackageDetailPlaceholder: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
Text("No selection")
Text("No selection", bundle: #bundle, comment: "Package detail placeholder title when nothing is selected")
.font(.brewTitle2)
.foregroundStyle(Color.brewTextPrimary)
Text("Choose a package from Discover to see details.")
Text("Choose a package from Discover to see details.", bundle: #bundle, comment: "Package detail placeholder body when nothing is selected")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
Spacer()
@@ -102,7 +102,11 @@ struct DoctorIssueDetailView: View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
captionText(block.caption)
if steps.contains(where: \.needsAdmin) {
Label("Needs admin · runs in Terminal", systemImage: "lock.fill")
Label(String(
localized: "Needs admin · runs in Terminal",
bundle: #bundle,
comment: "Doctor fix note: the command needs sudo so it opens in Terminal",
), systemImage: "lock.fill")
.font(.brewCaption)
.foregroundStyle(Color.brewTextTertiary)
}
@@ -127,12 +131,12 @@ struct DoctorIssueDetailView: View {
.controlSize(.small)
.frame(minWidth: 120)
} else {
Text("Run Fix")
Text("Run Fix", bundle: #bundle, comment: "Doctor detail: run the suggested fix button")
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isFixRunning(item))
.accessibilityLabel("Run Fix")
.accessibilityLabel(String(localized: "Run Fix", bundle: #bundle, comment: "VoiceOver: run-fix button"))
if let fixError = viewModel.fixError(item) {
Text(fixError)
@@ -176,7 +180,11 @@ struct DoctorIssueDetailView: View {
// MARK: - Raw output
private var rawOutputSection: some View {
CommandBlockView(command: item.rawText, title: "Raw output", collapsible: true)
CommandBlockView(command: item.rawText, title: LocalizedStringResource(
"Raw output",
bundle: #bundle,
comment: "Doctor detail: collapsible header for the raw brew doctor text",
), collapsible: true)
}
}
@@ -193,7 +201,11 @@ private struct DoctorSeverityBadge: View {
.padding(.horizontal, BrewSpacing.sm)
.padding(.vertical, BrewSpacing.xxs)
.background(DoctorSeverityStyle.background(severity), in: Capsule())
.accessibilityLabel("Severity: \(DoctorSeverityStyle.displayName(severity))")
.accessibilityLabel(String(
localized: "Severity: \(DoctorSeverityStyle.displayName(severity))",
bundle: #bundle,
comment: "VoiceOver: Doctor severity badge; %@ is the severity name",
))
}
}
@@ -220,10 +232,10 @@ private struct DoctorLinkRow: View {
struct DoctorDetailPlaceholder: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
Text("No selection")
Text("No selection", bundle: #bundle, comment: "Doctor detail placeholder title when nothing is selected")
.font(.brewTitle2)
.foregroundStyle(Color.brewTextPrimary)
Text("Run diagnostics, then choose an issue to see details and fixes.")
Text("Run diagnostics, then choose an issue to see details and fixes.", bundle: #bundle, comment: "Doctor detail placeholder body when nothing is selected")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
Spacer()
@@ -28,14 +28,18 @@ struct DoctorView: View {
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
Text("Doctor")
Text("Doctor", bundle: #bundle, comment: "Doctor tab heading")
.font(.brewTitle2)
.foregroundStyle(Color.brewTextPrimary)
Text(viewModel.subtitle)
.font(.brewSubheadline)
.foregroundStyle(Color.brewTextSecondary)
if let lastCheckedAt = viewModel.lastCheckedAt {
LastUpdatedLabel(lead: "Last checked", date: lastCheckedAt)
LastUpdatedLabel(lead: LocalizedStringResource(
"Last checked",
bundle: #bundle,
comment: "Doctor header: lead-in before a relative time, e.g. “Last checked 5 minutes ago”",
), date: lastCheckedAt)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -54,16 +58,16 @@ struct DoctorView: View {
if viewModel.isRefreshing {
ProgressView()
.controlSize(.small)
.accessibilityLabel("Re-checking")
.accessibilityLabel(String(localized: "Re-checking", bundle: #bundle, comment: "VoiceOver: Doctor re-check spinner"))
}
if viewModel.rawDoctorOutput != nil {
Button("Copy output") {
Button(String(localized: "Copy output", bundle: #bundle, comment: "Doctor header: copy raw brew doctor output")) {
viewModel.copyDoctorOutput()
}
.controlSize(.small)
.accessibilityLabel("Copy brew doctor output")
.accessibilityLabel(String(localized: "Copy brew doctor output", bundle: #bundle, comment: "VoiceOver: Doctor copy-output button"))
}
Button("Run Again") {
Button(String(localized: "Run Again", bundle: #bundle, comment: "Doctor header: re-run brew doctor")) {
Task { await viewModel.load(forceRefresh: true) }
}
.controlSize(.small)
@@ -92,10 +96,10 @@ struct DoctorView: View {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 44))
.foregroundStyle(Color.brewStatusSuccess)
Text("Your system is ready to brew")
Text("Your system is ready to brew", bundle: #bundle, comment: "Doctor healthy state title")
.font(.brewTitle3)
.foregroundStyle(Color.brewTextPrimary)
Text("brew doctor found no problems.")
Text("brew doctor found no problems.", bundle: #bundle, comment: "Doctor healthy state body")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
}
@@ -138,7 +142,7 @@ struct DoctorView: View {
}
.focused($isFocused)
.listStyle(.inset)
.accessibilityLabel("Doctor issues")
.accessibilityLabel(String(localized: "Doctor issues", bundle: #bundle, comment: "VoiceOver: the Doctor issues list"))
.onKeyPress(.upArrow) {
viewModel.selectPrevious()
return .handled
@@ -163,7 +167,7 @@ private struct DoctorSeveritySectionHeader: View {
private struct DoctorReassuranceNote: View {
var body: some View {
NoteCallout(DoctorCopy.warningPreamble, tone: .info)
NoteCallout(verbatim: DoctorCopy.warningPreamble, tone: .info)
.padding(.horizontal, BrewSpacing.lg)
.padding(.bottom, BrewSpacing.sm)
}
@@ -109,7 +109,7 @@ final class UpgradesViewModel {
/// subtitle is a static tab description owned by `MainWindowView`.
var outdatedSubtitle: String {
if shouldShowInitialLoadingIndicator {
return String(localized: "Loading packages…", comment: "Upgrades tab subtitle while fetching")
return String(localized: "Loading packages…", bundle: #bundle, comment: "Upgrades tab subtitle while fetching")
}
if showsUpgradeCheckFailure {
return Self.upgradeCheckFailedTitle
@@ -121,12 +121,14 @@ final class UpgradesViewModel {
// The count came from the last check that succeeded, so it must not read as current.
return String(
localized: "\(subtitle) — last check failed",
bundle: #bundle,
comment: "Upgrades tab subtitle when cached upgrades are shown after a failed re-check",
)
}
static let upgradeCheckFailedTitle = String(
localized: "Couldn't check for upgrades",
bundle: #bundle,
comment: "Upgrades tab: the outdated check failed, so the tab cannot report an answer",
)
@@ -137,11 +139,13 @@ final class UpgradesViewModel {
case 1:
String(
localized: "1 package can be upgraded",
bundle: #bundle,
comment: "Upgrades tab subtitle for a single outdated package",
)
default:
String(
localized: "\(totalOutdatedCount) packages can be upgraded",
bundle: #bundle,
comment: "Upgrades tab subtitle when multiple packages are outdated",
)
}
@@ -155,17 +159,20 @@ final class UpgradesViewModel {
if visible > 0 {
return String(
localized: "Showing \(visible) of \(total) upgrades",
bundle: #bundle,
comment: "Upgrades tab subtitle while filtering with at least one match",
)
}
if total == 1 {
return String(
localized: "No matches in 1 outdated package",
bundle: #bundle,
comment: "Upgrades tab subtitle when filters hide the single available upgrade",
)
}
return String(
localized: "No matches in \(total) outdated packages",
bundle: #bundle,
comment: "Upgrades tab subtitle when filters hide every available upgrade",
)
}
@@ -333,21 +340,24 @@ extension UpgradesViewModel {
upgradeSelection.displayCommand
}
var bulkUpgradeSummary: String {
var bulkUpgradeSummary: LocalizedStringResource {
switch upgradeSelection {
case .all:
String(
localized: "Upgrades every outdated package",
LocalizedStringResource(
"Upgrades every outdated package",
bundle: #bundle,
comment: "Upgrades header command summary for an unfiltered batch",
)
case .formulae:
String(
localized: "Upgrades every outdated formula",
LocalizedStringResource(
"Upgrades every outdated formula",
bundle: #bundle,
comment: "Upgrades header command summary scoped to formulae",
)
case .casks:
String(
localized: "Upgrades every outdated cask",
LocalizedStringResource(
"Upgrades every outdated cask",
bundle: #bundle,
comment: "Upgrades header command summary scoped to casks",
)
case let .explicit(names):
@@ -355,15 +365,17 @@ extension UpgradesViewModel {
}
}
private static func searchedUpgradeSummary(count: Int) -> String {
private static func searchedUpgradeSummary(count: Int) -> LocalizedStringResource {
if count == 1 {
return String(
localized: "Upgrades the 1 package matching your search",
return LocalizedStringResource(
"Upgrades the 1 package matching your search",
bundle: #bundle,
comment: "Upgrades header command summary for a single searched package",
)
}
return String(
localized: "Upgrades the \(count) packages matching your search",
return LocalizedStringResource(
"Upgrades the \(count) packages matching your search",
bundle: #bundle,
comment: "Upgrades header command summary for multiple searched packages",
)
}
@@ -379,6 +391,7 @@ extension UpgradesViewModel {
if isFilteringOutEveryUpgrade {
return String(
localized: "Nothing to upgrade here",
bundle: #bundle,
comment: "Upgrades header stand-in when filters hide every available upgrade",
)
}
@@ -399,6 +412,7 @@ extension UpgradesViewModel {
}
return String(
localized: "\(message)\n\nUntil this succeeds the app can't tell whether anything needs upgrading.",
bundle: #bundle,
comment: "Upgrades empty state under a failed check: brew's error, then why the list is empty",
)
}
@@ -68,7 +68,7 @@ struct InstalledPackageDetailHeroSection: View {
Image(systemName: "checkmark.circle.fill")
.font(.brewTitle3)
.foregroundStyle(Color.brewStatusSuccess)
.accessibilityLabel("Installed")
.accessibilityLabel(String(localized: "Installed", bundle: #bundle, comment: "VoiceOver: package is installed"))
}
}
}
@@ -90,26 +90,27 @@ struct InstalledPackageDetailMetadataSection: View {
let viewModel: InstalledPackageDetailViewModel
private let labelWidth: CGFloat = 100
private let yes = String(localized: "Yes", bundle: #bundle, comment: "Package detail value for a true flag")
var body: some View {
let metadata = viewModel.metadataItem
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
PackageDetailSectionHeading(title: "Details")
PackageDetailSectionHeading(title: LocalizedStringResource("Details", bundle: #bundle, comment: "Package detail section heading"))
detailRow(
label: "Installed",
label: String(localized: "Installed", bundle: #bundle, comment: "Package detail row: installed version(s)"),
value: metadata.installedVersionsValue,
valueColor: metadata.isOutdated ? .brewStatusWarning : .brewTextPrimary,
valueFontWeight: .heavy,
)
detailRow(label: "Latest version", value: metadata.latestVersionValue)
detailRow(label: String(localized: "Latest version", bundle: #bundle, comment: "Package detail row"), value: metadata.latestVersionValue)
if let dateValue = metadata.installDateValue {
detailRow(label: "Installed on", value: dateValue)
detailRow(label: String(localized: "Installed on", bundle: #bundle, comment: "Package detail row: install date"), value: dateValue)
}
if let reason = metadata.installReasonValue {
detailRow(label: "Install reason", value: reason)
detailRow(label: String(localized: "Install reason", bundle: #bundle, comment: "Package detail row: on request or as a dependency"), value: reason)
}
if let license = metadata.licenseValue {
detailRow(label: "License", value: license)
detailRow(label: String(localized: "License", bundle: #bundle, comment: "Package detail row"), value: license)
}
if let tap = metadata.tapDisplayValue {
sourceRow(tap: tap, url: metadata.sourceURL)
@@ -118,10 +119,10 @@ struct InstalledPackageDetailMetadataSection: View {
homepageRow(url: homepageURL, title: metadata.homepageDisplayTitle ?? homepageURL.absoluteString)
}
if metadata.isPinned {
detailRow(label: "Pinned", value: "Yes")
detailRow(label: String(localized: "Pinned", bundle: #bundle, comment: "Package detail row: brew pin"), value: yes)
}
if metadata.isKegOnly {
detailRow(label: "Keg-only", value: "Yes")
detailRow(label: String(localized: "Keg-only", bundle: #bundle, comment: "Package detail row: formula is keg-only"), value: yes)
}
if let caveats = metadata.caveatsText {
caveatsCallout(text: caveats)
@@ -145,7 +146,7 @@ struct InstalledPackageDetailMetadataSection: View {
private func sourceRow(tap: String, url: URL?) -> some View {
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
Text("Source")
Text("Source", bundle: #bundle, comment: "Package detail row: source tap")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
.frame(width: labelWidth, alignment: .leading)
@@ -170,7 +171,7 @@ struct InstalledPackageDetailMetadataSection: View {
private func homepageRow(url: URL, title: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
Text("Homepage")
Text("Homepage", bundle: #bundle, comment: "Package detail row: homepage link")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
.frame(width: labelWidth, alignment: .leading)
@@ -187,7 +188,7 @@ struct InstalledPackageDetailMetadataSection: View {
}
private func caveatsCallout(text: String) -> some View {
NoteCallout(text)
NoteCallout(verbatim: text)
.padding(.top, BrewSpacing.lg)
}
}
@@ -200,7 +201,7 @@ struct InstalledPackageDetailDependentsSection: View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
dependentsHeading
if viewModel.dependentRelationships.isEmpty {
Text("No dependents.")
Text("No dependents.", bundle: #bundle, comment: "Package detail: nothing depends on this package")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
} else {
@@ -215,7 +216,7 @@ struct InstalledPackageDetailDependentsSection: View {
private var dependentsHeading: some View {
HStack(spacing: BrewSpacing.sm) {
PackageDetailSectionHeading(title: "Dependents")
PackageDetailSectionHeading(title: LocalizedStringResource("Dependents", bundle: #bundle, comment: "Package detail section heading: packages that depend on this one"))
if let badgeTitle = viewModel.uninstallItem.usedByBlockingBadgeTitle {
Text(badgeTitle)
.font(.brewCaption2.weight(.semibold))
@@ -238,7 +239,7 @@ struct UninstallBlockedCallout: View {
Image(systemName: "exclamationmark.triangle.fill")
.brewWarningGlyphStyle()
.font(.brewSubheadline)
Text("\(Text(lead).fontWeight(.semibold)) \(bodyText)")
(Text(lead).fontWeight(.semibold) + Text(verbatim: " ") + Text(bodyText))
.font(.brewCallout)
.foregroundStyle(Color.brewTextPrimary)
}
@@ -256,7 +257,7 @@ struct InstalledPackageDetailRelationshipList: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
if relationships.isEmpty {
Text("None.")
Text("None.", bundle: #bundle, comment: "Package detail: empty relationship list")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
} else {
@@ -295,8 +296,16 @@ struct InstalledPackageDetailRelationshipList: View {
.disabled(!isInstalled)
.accessibilityLabel(
isInstalled
? "Open installed package \(relationship.displayName)"
: "\(relationship.displayName), not installed",
? String(
localized: "Open installed package \(relationship.displayName)",
bundle: #bundle,
comment: "VoiceOver: dependency row that jumps to that package; %@ is its name",
)
: String(
localized: "\(relationship.displayName), not installed",
bundle: #bundle,
comment: "VoiceOver: dependency row for a package that is not installed; %@ is its name",
),
)
}
}
@@ -112,9 +112,9 @@ struct InstalledPackageDetailView: View {
InstalledPackageDetailMetadataSection(viewModel: viewModel)
PackageDetailSectionDivider()
PackageRelationshipSection(
title: "Dependencies",
title: LocalizedStringResource("Dependencies", bundle: #bundle, comment: "Package detail section heading"),
relationships: viewModel.dependencyRelationships,
emptyText: "No dependencies.",
emptyText: LocalizedStringResource("No dependencies.", bundle: #bundle, comment: "Package detail: empty dependency list"),
dotStyle: .neutral,
onSelectInstalledPackage: onSelectInstalledPackage,
)
@@ -128,9 +128,9 @@ struct InstalledPackageDetailView: View {
/// Heading + always-expanded relationship list, used for Dependencies.
private struct PackageRelationshipSection: View {
let title: String
let title: LocalizedStringResource
let relationships: [PackageRelationshipItem]
let emptyText: String
let emptyText: LocalizedStringResource
let dotStyle: PackageRelationshipDotStyle
let onSelectInstalledPackage: (InstalledBrewPackage.ID) -> Void
@@ -159,14 +159,14 @@ private struct InstalledPackageDetailUninstallChrome: View {
var body: some View {
let uninstall = viewModel.uninstallItem
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
Text("Uninstall")
Text("Uninstall", bundle: #bundle, comment: "Package detail section heading")
.font(.brewSubheadline.weight(.semibold))
.foregroundStyle(Color.brewTextPrimary)
VStack(alignment: .leading, spacing: BrewSpacing.md) {
CommandBlockView(
command: uninstall.displayCommand,
summaryText: "Uninstalls this package from this Mac",
summaryText: LocalizedStringResource("Uninstalls this package from this Mac", bundle: #bundle, comment: "Command block summary above brew uninstall"),
)
Button {
@@ -198,7 +198,7 @@ private struct InstalledPackageDetailUninstallChrome: View {
Button(uninstall.primaryButtonTitle, role: .destructive) {
viewModel.uninstallSelectedPackage()
}
Button("Cancel", role: .cancel) {}
Button(String(localized: "Cancel", bundle: #bundle, comment: "Uninstall confirmation: keep the package"), role: .cancel) {}
} message: {
Text(uninstall.confirmationMessage)
}
@@ -227,14 +227,18 @@ private struct InstalledPackageDetailUpgradeChrome: View {
var body: some View {
let upgrade = viewModel.upgradeItem
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
Text("Upgrade")
Text("Upgrade", bundle: #bundle, comment: "Package detail section heading")
.font(.brewSubheadline.weight(.semibold))
.foregroundStyle(Color.brewTextPrimary)
VStack(alignment: .leading, spacing: BrewSpacing.md) {
CommandBlockView(
command: upgrade.displayCommand,
summaryText: "Upgrades this package to the latest available version",
summaryText: LocalizedStringResource(
"Upgrades this package to the latest available version",
bundle: #bundle,
comment: "Command block summary above brew upgrade",
),
)
if let title = upgrade.primaryButtonTitle {
@@ -271,10 +275,10 @@ private struct InstalledPackageDetailUpgradeChrome: View {
struct InstalledPackageDetailPlaceholder: View {
var body: some View {
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
Text("No selection")
Text("No selection", bundle: #bundle, comment: "Package detail placeholder title when nothing is selected")
.font(.brewTitle2)
.foregroundStyle(Color.brewTextPrimary)
Text("Choose a package from the list to see details.")
Text("Choose a package from the list to see details.", bundle: #bundle, comment: "Package detail placeholder body when nothing is selected")
.font(.brewCallout)
.foregroundStyle(Color.brewTextSecondary)
Spacer()
@@ -13,7 +13,7 @@ public struct RefreshCommands: Commands {
public var body: some Commands {
CommandGroup(after: .sidebar) {
Button("Refresh") { refreshAll?() }
Button(String(localized: "Refresh", bundle: #bundle, comment: "View menu: refresh every surface (⌘R)")) { refreshAll?() }
.keyboardShortcut("r", modifiers: .command)
.disabled(refreshAll == nil)
}
@@ -13,7 +13,7 @@ public struct SearchCommands: Commands {
public var body: some Commands {
CommandGroup(after: .textEditing) {
Button("Find") { focusSearchField?() }
Button(String(localized: "Find", bundle: #bundle, comment: "Edit menu: focus the search field (⌘F)")) { focusSearchField?() }
.keyboardShortcut("f") // ⌘F
.disabled(focusSearchField == nil)
}
@@ -1,7 +1,71 @@
{
"sourceLanguage" : "en",
"strings" : {
"%@ %@" : {
"comment" : "Last-updated label: %1$@ is the lead phrase (e.g. “Last checked”), %2$@ the relative time",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@ %2$@"
}
}
}
},
"%lld days ago" : {
"comment" : "Relative time, %lld ≥ 2"
},
"%lld hours ago" : {
"comment" : "Relative time, %lld ≥ 2"
},
"%lld minutes ago" : {
"comment" : "Relative time, %lld ≥ 2"
},
"1 day ago" : {
"comment" : "Relative time"
},
"1 hour ago" : {
"comment" : "Relative time"
},
"1 minute ago" : {
"comment" : "Relative time"
},
"Copied" : {
"comment" : "Command block: shown briefly after copying"
},
"Copy" : {
"comment" : "Command block: copy the command"
},
"Copy all" : {
"comment" : "Command block: copy every command"
},
"Could not find Homebrew. Install it or ensure brew is in the default location." : {
"comment" : "Error when the brew executable is not in either default prefix"
},
"Find" : {
"comment" : "Edit menu: focus the search field (⌘F)"
},
"Homebrew command failed." : {
"comment" : "Error when brew exits non-zero without printing anything to stderr"
},
"Homebrew returned output the app couldn’t read." : {
"comment" : "Error when brew succeeded but its JSON output failed to decode"
},
"Refresh" : {
"comment" : "View menu: refresh every surface (⌘R)"
},
"Retry" : {
"comment" : "Error state: retry the failed load"
},
"Terminal command" : {
"comment" : "Command block header, one command"
},
"Terminal commands" : {
"comment" : "Command block header, several commands"
},
"just now" : {
"comment" : "Relative time: under a minute ago"
}
},
"version" : "1.0"
}
}
@@ -68,7 +68,7 @@ struct ErrorStateView: View {
.foregroundStyle(Color.brewStatusError)
.multilineTextAlignment(.center)
if let onRetry {
Button("Retry", action: onRetry)
Button(String(localized: "Retry", bundle: #bundle, comment: "Error state: retry the failed load"), action: onRetry)
.axid(.errorRetryButton)
}
}
@@ -10,10 +10,10 @@ import SwiftUI
/// An action that leaves no visible trace (copying to the pasteboard, clearing a list) passes a
/// `confirmationTitle`: the button swaps to a tick and that title for a few seconds.
public struct BrewActionButton: View {
private let title: String
private let title: LocalizedStringResource
private let systemImage: String
private let confirmationTitle: String?
private let help: String?
private let confirmationTitle: LocalizedStringResource?
private let help: LocalizedStringResource?
private let action: () -> Void
@State private var isHovered = false
@@ -23,10 +23,10 @@ public struct BrewActionButton: View {
private static let confirmationDuration: Duration = .seconds(5)
public init(
_ title: String,
_ title: LocalizedStringResource,
systemImage: String,
confirmationTitle: String? = nil,
help: String? = nil,
confirmationTitle: LocalizedStringResource? = nil,
help: LocalizedStringResource? = nil,
action: @escaping () -> Void,
) {
self.title = title
@@ -54,7 +54,7 @@ public struct BrewActionButton: View {
.onHover { isHovered = $0 }
.help(help ?? title)
// The label changes while confirming; what a screen reader or a UI test matches on must not.
.accessibilityLabel(title)
.accessibilityLabel(Text(title))
}
private func confirm() {
@@ -74,10 +74,15 @@ public struct BrewActionButton: View {
/// What a ``BrewActionButton`` shows right now.
struct BrewActionButtonAppearance: Equatable {
let title: String
let title: LocalizedStringResource
let systemImage: String
init(title: String, systemImage: String, confirmationTitle: String?, isConfirming: Bool) {
init(
title: LocalizedStringResource,
systemImage: String,
confirmationTitle: LocalizedStringResource?,
isConfirming: Bool,
) {
if isConfirming, let confirmationTitle {
self.title = confirmationTitle
self.systemImage = "checkmark"
@@ -118,18 +123,22 @@ private struct BrewActionButtonStyle: ButtonStyle {
#if DEBUG
#Preview {
HStack(spacing: BrewSpacing.xs) {
BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") {}
BrewActionButton(
"Copy",
systemImage: "doc.on.doc",
confirmationTitle: "Copied",
help: "Copy output to clipboard",
previewCopy("Save"),
systemImage: "square.and.arrow.down",
help: previewCopy("Save output to file"),
) {}
BrewActionButton(
"Clear",
previewCopy("Copy"),
systemImage: "doc.on.doc",
confirmationTitle: previewCopy("Copied"),
help: previewCopy("Copy output to clipboard"),
) {}
BrewActionButton(
previewCopy("Clear"),
systemImage: "trash",
confirmationTitle: "Cleared",
help: "Clear completed jobs",
confirmationTitle: previewCopy("Cleared"),
help: previewCopy("Clear completed jobs"),
) {}
}
.padding()
@@ -11,11 +11,16 @@ import SwiftUI
/// renders a numbered list with one "Copy all" affordance instead of N loose buttons.
public struct CommandBlockView: View {
let commands: [String]
let summaryText: String?
let title: String?
let summaryText: LocalizedStringResource?
let title: LocalizedStringResource?
let collapsible: Bool
public init(command: String, summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) {
public init(
command: String,
summaryText: LocalizedStringResource? = nil,
title: LocalizedStringResource? = nil,
collapsible: Bool = false,
) {
commands = [command]
self.summaryText = summaryText
self.title = title
@@ -23,7 +28,12 @@ public struct CommandBlockView: View {
_isExpanded = State(initialValue: !collapsible)
}
public init(commands: [String], summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) {
public init(
commands: [String],
summaryText: LocalizedStringResource? = nil,
title: LocalizedStringResource? = nil,
collapsible: Bool = false,
) {
self.commands = commands
self.summaryText = summaryText
self.title = title
@@ -69,7 +79,11 @@ public struct CommandBlockView: View {
.foregroundStyle(Color.brewTextSecondary)
}
Spacer()
BrewActionButton(copyTitle, systemImage: "doc.on.doc", confirmationTitle: "Copied") {
BrewActionButton(
copyTitle,
systemImage: "doc.on.doc",
confirmationTitle: LocalizedStringResource("Copied", bundle: #bundle, comment: "Command block: shown briefly after copying"),
) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(commands.joined(separator: "\n"), forType: .string)
}
@@ -79,12 +93,16 @@ public struct CommandBlockView: View {
.background(Color.brewSurfaceRecessed)
}
private var headerTitle: String {
commands.count > 1 ? "Terminal commands" : "Terminal command"
private var headerTitle: LocalizedStringResource {
commands.count > 1
? LocalizedStringResource("Terminal commands", bundle: #bundle, comment: "Command block header, several commands")
: LocalizedStringResource("Terminal command", bundle: #bundle, comment: "Command block header, one command")
}
private var copyTitle: String {
commands.count > 1 ? "Copy all" : "Copy"
private var copyTitle: LocalizedStringResource {
commands.count > 1
? LocalizedStringResource("Copy all", bundle: #bundle, comment: "Command block: copy every command")
: LocalizedStringResource("Copy", bundle: #bundle, comment: "Command block: copy the command")
}
/// Deliberately not on ``Color/brewTerminal``: light-on-dark text under the system selection
@@ -103,7 +121,7 @@ public struct CommandBlockView: View {
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
ForEach(Array(commands.enumerated()), id: \.offset) { index, command in
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
Text("\(index + 1).")
Text(verbatim: "\(index + 1).")
.font(.brewCode)
.foregroundStyle(Color.brewTextSecondary)
Text(command)
@@ -119,7 +137,7 @@ public struct CommandBlockView: View {
}
}
private func footer(_ text: String) -> some View {
private func footer(_ text: LocalizedStringResource) -> some View {
Text(text)
.font(.brewCaption)
.foregroundStyle(Color.brewTextSecondary)
@@ -135,7 +153,7 @@ public struct CommandBlockView: View {
#Preview("Single") {
CommandBlockView(
command: "brew upgrade --formula git",
summaryText: "Upgrades this package to the latest available version",
summaryText: previewCopy("Upgrades this package to the latest available version"),
)
.frame(width: 360)
.padding()
@@ -147,7 +165,7 @@ public struct CommandBlockView: View {
"rm -rf /usr/local/Library",
"brew tap homebrew/core",
],
summaryText: "Re-establishes the Homebrew/core tap",
summaryText: previewCopy("Re-establishes the Homebrew/core tap"),
)
.frame(width: 360)
.padding()
@@ -6,43 +6,52 @@
import Foundation
import SwiftUI
/// Spelled out rather than left to `RelativeDateTimeFormatter`, which follows the system locale and would
/// put a translated phrase after an English lead-in.
/// Coarser than `RelativeDateTimeFormatter` on purpose: minute granularity, no seconds, no "in …".
public enum RelativeTimeText {
public static func string(for date: Date, relativeTo now: Date) -> String {
let seconds = now.timeIntervalSince(date)
guard seconds >= 60 else {
// Also covers a future date: a clock that moved backwards should read as "now", not a countdown.
return "just now"
return String(localized: "just now", bundle: #bundle, comment: "Relative time: under a minute ago")
}
let minutes = Int(seconds / 60)
if minutes < 60 {
return "\(minutes) \(minutes == 1 ? "minute" : "minutes") ago"
return minutes == 1
? String(localized: "1 minute ago", bundle: #bundle, comment: "Relative time")
: String(localized: "\(minutes) minutes ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
}
let hours = minutes / 60
if hours < 24 {
return "\(hours) \(hours == 1 ? "hour" : "hours") ago"
return hours == 1
? String(localized: "1 hour ago", bundle: #bundle, comment: "Relative time")
: String(localized: "\(hours) hours ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
}
let days = hours / 24
return "\(days) \(days == 1 ? "day" : "days") ago"
return days == 1
? String(localized: "1 day ago", bundle: #bundle, comment: "Relative time")
: String(localized: "\(days) days ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
}
}
public struct LastUpdatedLabel: View {
private let lead: String
private let lead: LocalizedStringResource
private let date: Date
/// `lead` is the phrase the relative time is appended to, e.g. `"Last checked"`.
public init(lead: String, date: Date) {
public init(lead: LocalizedStringResource, date: Date) {
self.lead = lead
self.date = date
}
public var body: some View {
TimelineView(.periodic(from: date, by: 60)) { context in
Text("\(lead) \(RelativeTimeText.string(for: date, relativeTo: context.date))")
.font(.brewCaption)
.foregroundStyle(Color.brewTextTertiary)
// One key, not concatenation: translations reorder the two halves.
Text(LocalizedStringResource(
"\(String(localized: lead)) \(RelativeTimeText.string(for: date, relativeTo: context.date))",
bundle: #bundle,
comment: "Last-updated label: %1$@ is the lead phrase (e.g. “Last checked”), %2$@ the relative time",
))
.font(.brewCaption)
.foregroundStyle(Color.brewTextTertiary)
}
}
}
@@ -50,8 +59,8 @@ public struct LastUpdatedLabel: View {
#if DEBUG
#Preview("Last updated") {
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
LastUpdatedLabel(lead: "Last checked", date: .now)
LastUpdatedLabel(lead: "Last checked", date: .now.addingTimeInterval(-3600))
LastUpdatedLabel(lead: previewCopy("Last checked"), date: .now)
LastUpdatedLabel(lead: previewCopy("Last checked"), date: .now.addingTimeInterval(-3600))
}
.padding()
}
@@ -30,11 +30,16 @@ public enum NoteCalloutTone: Sendable {
}
public struct NoteCallout: View {
private let text: String
private let text: Text
private let tone: NoteCalloutTone
public init(_ text: String, tone: NoteCalloutTone = .brand) {
self.text = text
public init(_ text: LocalizedStringResource, tone: NoteCalloutTone = .brand) {
self.text = Text(text)
self.tone = tone
}
public init(verbatim text: String, tone: NoteCalloutTone = .brand) {
self.text = Text(verbatim: text)
self.tone = tone
}
@@ -43,7 +48,7 @@ public struct NoteCallout: View {
Image(systemName: "info.circle.fill")
.font(.brewSubheadline)
.foregroundStyle(tone.iconColor)
Text(text)
text
.font(.brewCallout)
.foregroundStyle(Color.brewTextPrimary)
.fixedSize(horizontal: false, vertical: true)
@@ -60,9 +65,9 @@ public struct NoteCallout: View {
#if DEBUG
#Preview("Note callout") {
VStack(alignment: .leading, spacing: BrewSpacing.md) {
NoteCallout("Casks and formulae are installed to different prefixes.")
NoteCallout(verbatim: "Casks and formulae are installed to different prefixes.")
NoteCallout(
"Please note that these warnings are just used to help the Homebrew maintainers.",
verbatim: "Please note that these warnings are just used to help the Homebrew maintainers.",
tone: .info,
)
}
@@ -7,9 +7,9 @@ import SwiftUI
/// Section heading used across package-detail surfaces.
public struct PackageDetailSectionHeading: View {
let title: String
let title: LocalizedStringResource
public init(title: String) {
public init(title: LocalizedStringResource) {
self.title = title
}
@@ -0,0 +1,15 @@
//
// PreviewCopy.swift
// BrewUIComponents
//
import Foundation
#if DEBUG
/// Sample copy for `#Preview` bodies. A literal written straight into a
/// `LocalizedStringResource` parameter is an extraction site; routing through a runtime `String`
/// keeps preview text out of the catalogs.
public func previewCopy(_ text: String) -> LocalizedStringResource {
LocalizedStringResource(stringLiteral: text)
}
#endif
@@ -29,7 +29,7 @@ struct ConfigViewModelTests {
return
}
let sections = viewModel.sections
#expect(sections.map(\.title) == ["Homebrew", "System", "Build settings"])
#expect(sections.map { String(localized: $0.title) } == ["Homebrew", "System", "Build settings"])
let homebrew = sections.first { $0.id == "homebrew" }
#expect(homebrew?.rows.map(\.label) == ["HOMEBREW_VERSION", "HOMEBREW_PREFIX"])
@@ -203,13 +203,13 @@ struct UpgradesViewModelScopeTests {
@Test @MainActor func `bulkUpgradeSummary describes the scoped selection`() {
let vm = Self.makeViewModel(packages: Self.mixedOutdated)
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated package")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated package")
vm.scope = .formulae
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated formula")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated formula")
vm.scope = .casks
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated cask")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated cask")
}
@Test @MainActor func `bulkUpgradeSummary counts the searched packages`() {
@@ -220,13 +220,13 @@ struct UpgradesViewModelScopeTests {
])
vm.searchQuery = "git"
#expect(vm.bulkUpgradeSummary == "Upgrades the 2 packages matching your search")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades the 2 packages matching your search")
vm.searchQuery = "wget"
#expect(vm.bulkUpgradeSummary == "Upgrades the 1 package matching your search")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades the 1 package matching your search")
vm.searchQuery = "no-such-package"
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated package")
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated package")
}
// MARK: - Empty upgrade action
@@ -32,7 +32,7 @@ struct BrewActionButtonAppearanceTests {
isConfirming: true,
)
#expect((appearance.title, appearance.systemImage) == ("Cleared", "checkmark"))
#expect((String(localized: appearance.title), appearance.systemImage) == ("Cleared", "checkmark"))
}
@Test func `a button without a confirmation title never changes`() {
@@ -43,6 +43,6 @@ struct BrewActionButtonAppearanceTests {
isConfirming: true,
)
#expect((appearance.title, appearance.systemImage) == ("Save", "square.and.arrow.down"))
#expect((String(localized: appearance.title), appearance.systemImage) == ("Save", "square.and.arrow.down"))
}
}