diff --git a/App/Compatibility.swift b/App/Compatibility.swift index 5020720..40a038f 100644 --- a/App/Compatibility.swift +++ b/App/Compatibility.swift @@ -340,6 +340,22 @@ extension View { } } + @ViewBuilder + func readingNavigationBarBackground() -> some View { + if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI { + toolbarBackground(ReadingTheme.canvas, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } else { + background(ReadingUIKitConfiguration { controller in + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor(ReadingTheme.canvas) + controller.navigationController?.navigationBar.standardAppearance = appearance + controller.navigationController?.navigationBar.scrollEdgeAppearance = appearance + }) + } + } + @ViewBuilder func readingTabBarBackground() -> some View { if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI { diff --git a/App/DesignSystem.swift b/App/DesignSystem.swift index b259ee6..445924c 100644 --- a/App/DesignSystem.swift +++ b/App/DesignSystem.swift @@ -77,6 +77,29 @@ private struct ReadingUnavailableLabelStyle: LabelStyle { } extension View { + /// Keep filled controls dark in both appearances, independently of icon tint. + func readingProminentButtonStyle() -> some View { + buttonStyle(.borderedProminent) + .tint(ReadingTheme.accentFill) + .foregroundStyle(ReadingTheme.onAccent) + } + + func readingSecondaryButtonStyle() -> some View { + buttonStyle(.bordered) + .tint(ReadingTheme.accentFill) + .foregroundStyle(ReadingTheme.accent) + } + + @ViewBuilder + func readingChoiceButtonStyle(isSelected: Bool) -> some View { + if isSelected { + readingProminentButtonStyle() + .accessibilityAddTraits(.isSelected) + } else { + readingSecondaryButtonStyle() + } + } + func readingSearchable(text: Binding, isPresented: Binding, prompt: String) -> some View { modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt)) } @@ -144,7 +167,11 @@ private struct ReadingSearchModifier: ViewModifier { } enum ReadingTheme { - static let accent = adaptive(0x171717, dark: 0xF2F2F2) + static let accent = adaptive(0x171717, dark: 0xDADAE0) + static let accentFill = adaptive(0x171717, dark: 0x414148) + static let emphasisSurface = adaptive(0x171717, dark: 0x29292E) + static let selectionBorder = adaptive(0x171717, dark: 0x777780) + static let toggleTint = Color(uiColor: .systemGreen) static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C) static let coral = Color(red: 0.76, green: 0.48, blue: 0.27) static let paper = Color(red: 0.965, green: 0.945, blue: 0.895) @@ -153,7 +180,7 @@ enum ReadingTheme { static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F) static let surface = adaptive(0xFFFFFF, dark: 0x232326) static let separator = adaptive(0xE6E6E8, dark: 0x363639) - static let onAccent = adaptive(0xFFFFFF, dark: 0x171717) + static let onAccent = Color.white private static func adaptive(_ light: UInt32, dark: UInt32) -> Color { Color(uiColor: UIColor { traits in diff --git a/App/Features/Activity/ReadingStatisticsView.swift b/App/Features/Activity/ReadingStatisticsView.swift index 2b15c66..78a0f62 100644 --- a/App/Features/Activity/ReadingStatisticsView.swift +++ b/App/Features/Activity/ReadingStatisticsView.swift @@ -47,7 +47,9 @@ struct ReadingStatisticsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(24) - .background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20)) + .background(ReadingTheme.emphasisSurface, in: RoundedRectangle(cornerRadius: 20)) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("statistics.todayCard") } private var overviewGrid: some View { diff --git a/App/Features/Books/BookDetailView.swift b/App/Features/Books/BookDetailView.swift index 79d2d7d..f7218ac 100644 --- a/App/Features/Books/BookDetailView.swift +++ b/App/Features/Books/BookDetailView.swift @@ -194,7 +194,7 @@ struct BookDetailView: View { .font(.body.weight(.medium)) .frame(maxWidth: .infinity, minHeight: 28) } - .buttonStyle(.borderedProminent) + .readingProminentButtonStyle() .disabled(model.chapters.isEmpty) Button { if library.contains(model.book) { @@ -210,7 +210,7 @@ struct BookDetailView: View { .font(.subheadline) .frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28) } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() } @ViewBuilder diff --git a/App/Features/Explore/ExploreView.swift b/App/Features/Explore/ExploreView.swift index 645d79d..d17721c 100644 --- a/App/Features/Explore/ExploreView.swift +++ b/App/Features/Explore/ExploreView.swift @@ -155,7 +155,7 @@ struct ExploreView: View { Text("添加带有发现分类的书源,即可在这里浏览书籍。") } actions: { NavigationLink("管理书源") { SourceManagementView() } - .buttonStyle(.borderedProminent) + .readingProminentButtonStyle() } } else { LazyVStack(spacing: 0) { diff --git a/App/Features/RSS/RSSViews.swift b/App/Features/RSS/RSSViews.swift index 4a86e31..dd5fa75 100644 --- a/App/Features/RSS/RSSViews.swift +++ b/App/Features/RSS/RSSViews.swift @@ -142,7 +142,7 @@ private struct RSSArticleListView: View { ScrollView(.horizontal, showsIndicators: false) { HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } } - .buttonStyle(.bordered).tint(selectedSort == index ? ReadingTheme.accent : .secondary) + .readingChoiceButtonStyle(isSelected: selectedSort == index) }}.padding(.horizontal) }.padding(.vertical, 6) } @@ -435,7 +435,7 @@ private struct RSSDownloadView: View { Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent) Text(fileName).font(.headline) Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary) - Button("选择保存位置", systemImage: "folder") { showsExporter = true }.buttonStyle(.borderedProminent) + Button("选择保存位置", systemImage: "folder") { showsExporter = true }.readingProminentButtonStyle() } }.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } } .task(id: url) { await download() } @@ -464,7 +464,7 @@ private struct RSSBinaryDocument: FileDocument { func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) } } -private struct RSSPhotoView: View { +struct RSSPhotoView: View { @Environment(\.dismiss) private var dismiss let value: String let source: RssSource @@ -480,9 +480,13 @@ private struct RSSPhotoView: View { else if loading { ProgressView() } else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") } }.frame(minWidth: proxy.size.width, minHeight: proxy.size.height) - }.background(.black) + } + .background(.black) + .environment(\.colorScheme, .dark) + .tint(.white) } .navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline) + .readingNavigationBarBackground() .toolbar { ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) } ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } @@ -490,6 +494,7 @@ private struct RSSPhotoView: View { .task(id: value) { await load() } .alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") } } + .readingTabBarHidden() } private func load() async { diff --git a/App/Features/RSS/RSSWebReaderView.swift b/App/Features/RSS/RSSWebReaderView.swift index 418bbf3..2c0bf5f 100644 --- a/App/Features/RSS/RSSWebReaderView.swift +++ b/App/Features/RSS/RSSWebReaderView.swift @@ -41,6 +41,9 @@ struct RSSWebReaderView: UIViewRepresentable { controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) } let webView = WKWebView(frame: .zero, configuration: configuration) + webView.isOpaque = false + webView.backgroundColor = UIColor(ReadingTheme.canvas) + webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas) webView.navigationDelegate = context.coordinator webView.uiDelegate = context.coordinator webView.allowsBackForwardNavigationGestures = true diff --git a/App/Features/Reader/ReaderView.swift b/App/Features/Reader/ReaderView.swift index 5caef36..c55ad9f 100644 --- a/App/Features/Reader/ReaderView.swift +++ b/App/Features/Reader/ReaderView.swift @@ -492,7 +492,7 @@ struct ReaderView: View { .accessibilityIdentifier("reader.failureChangeSource") } } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() } private func openSourceSwitch() { @@ -1118,7 +1118,7 @@ struct ReaderView: View { Image(systemName: "textformat.size.smaller") .frame(width: 38, height: 32) } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() VStack(spacing: 2) { Text("字号") @@ -1135,7 +1135,7 @@ struct ReaderView: View { Image(systemName: "textformat.size.larger") .frame(width: 38, height: 32) } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() } HStack(spacing: 8) { @@ -1285,14 +1285,19 @@ struct ReaderView: View { Text(choice.title) .font(.caption2) } - .foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary) + .foregroundStyle(pageAnimation == choice.value ? ReadingTheme.onAccent : Color.primary) .frame(width: 58, height: 54) .background( - pageAnimation == choice.value ? ReadingTheme.accent : Color.secondary.opacity(0.1), + pageAnimation == choice.value ? ReadingTheme.accentFill : Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous) ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(pageAnimation == choice.value ? ReadingTheme.selectionBorder : .clear, lineWidth: 1) + } } .buttonStyle(.plain) + .accessibilityAddTraits(pageAnimation == choice.value ? .isSelected : []) } } } @@ -1392,12 +1397,16 @@ struct ReaderView: View { Button(action: action) { Text(title) .font(.caption.weight(.medium)) - .foregroundStyle(selected ? Color.white : Color.primary) + .foregroundStyle(selected ? ReadingTheme.onAccent : Color.primary) .padding(.horizontal, 12) .padding(.vertical, 7) - .background(selected ? ReadingTheme.accent : Color.secondary.opacity(0.1), in: Capsule()) + .background(selected ? ReadingTheme.accentFill : Color.secondary.opacity(0.1), in: Capsule()) + .overlay { + Capsule().strokeBorder(selected ? ReadingTheme.selectionBorder : .clear, lineWidth: 1) + } } .buttonStyle(.plain) + .accessibilityAddTraits(selected ? .isSelected : []) } private func valueSlider( diff --git a/App/Features/Rules/Editor/QRCodeImportView.swift b/App/Features/Rules/Editor/QRCodeImportView.swift index 35e8d9e..3589760 100644 --- a/App/Features/Rules/Editor/QRCodeImportView.swift +++ b/App/Features/Rules/Editor/QRCodeImportView.swift @@ -39,7 +39,7 @@ struct QRCodeImportView: View { } label: { Label("从照片选择二维码", systemImage: "photo") .frame(maxWidth: .infinity) - }.buttonStyle(.borderedProminent).disabled(isReading) + }.readingProminentButtonStyle().disabled(isReading) Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。") .font(.footnote).foregroundStyle(.secondary) if values.count > 1 { diff --git a/App/Features/Rules/Editor/SyntaxCodeEditor.swift b/App/Features/Rules/Editor/SyntaxCodeEditor.swift index 913a07b..fbd8195 100644 --- a/App/Features/Rules/Editor/SyntaxCodeEditor.swift +++ b/App/Features/Rules/Editor/SyntaxCodeEditor.swift @@ -3,6 +3,26 @@ import UIKit struct SyntaxCodeEditor: UIViewRepresentable { enum Language { case json, rule, javascript, regex } + + // Syntax colors are text, so they need more contrast than system icon tints. + private enum Palette { + static let keyword = adaptive(0x7A2BA6, dark: 0xD5A5F5) + static let number = adaptive(0x895900, dark: 0xE6B66A) + static let operation = adaptive(0xAD285F, dark: 0xF49AB8) + static let punctuation = adaptive(0x14666E, dark: 0x83CED8) + static let string = adaptive(0x2D6B39, dark: 0x8CCF96) + static let key = adaptive(0x1E5EB2, dark: 0x93BFFF) + static let comment = adaptive(0x64646B, dark: 0xA4A4AE) + + private static func adaptive(_ light: UInt32, dark: UInt32) -> UIColor { + UIColor { traits in + let hex = traits.userInterfaceStyle == .dark ? dark : light + return UIColor(red: CGFloat((hex >> 16) & 0xff) / 255, + green: CGFloat((hex >> 8) & 0xff) / 255, + blue: CGFloat(hex & 0xff) / 255, alpha: 1) + } + } + } @Binding var text: String var language: Language = .rule @@ -13,6 +33,7 @@ struct SyntaxCodeEditor: UIViewRepresentable { view.delegate = context.coordinator view.font = .monospacedSystemFont(ofSize: 14, weight: .regular) view.backgroundColor = .secondarySystemGroupedBackground + view.tintColor = UIColor(ReadingTheme.accent) view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12) view.autocorrectionType = .no view.autocapitalizationType = .none @@ -24,6 +45,7 @@ struct SyntaxCodeEditor: UIViewRepresentable { view.accessibilityIdentifier = "syntaxCodeEditor" if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true } let bar = UIToolbar() + bar.tintColor = UIColor(ReadingTheme.accent) bar.sizeToFit() bar.items = [ UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in @@ -126,12 +148,12 @@ struct SyntaxCodeEditor: UIViewRepresentable { storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all) if storage.length <= 500_000 { let tokens: [(String, UIColor)] = [ - (#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple), - (#"\b\d+(?:\.\d+)?\b"#, .systemOrange), - (#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink), - (#"[{}\[\]()]"#, .systemTeal), - (#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen), - (#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue), + (#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, Palette.keyword), + (#"\b\d+(?:\.\d+)?\b"#, Palette.number), + (#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, Palette.operation), + (#"[{}\[\]()]"#, Palette.punctuation), + (#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, Palette.string), + (#""(?:\\.|[^"\\])*"(?=\s*:)"#, Palette.key), ] for (pattern, color) in tokens { guard let regex = try? NSRegularExpression(pattern: pattern) else { continue } @@ -143,7 +165,7 @@ struct SyntaxCodeEditor: UIViewRepresentable { for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] { let regex = try? NSRegularExpression(pattern: pattern) regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in - if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) } + if let result { storage.addAttribute(.foregroundColor, value: Palette.operation, range: result.range) } } } } @@ -151,7 +173,7 @@ struct SyntaxCodeEditor: UIViewRepresentable { let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"# let regex = try? NSRegularExpression(pattern: pattern) regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in - if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) } + if let result { storage.addAttribute(.foregroundColor, value: Palette.comment, range: result.range) } } } } diff --git a/App/Features/Sources/SourceDebugView.swift b/App/Features/Sources/SourceDebugView.swift index 689120e..395e8c6 100644 --- a/App/Features/Sources/SourceDebugView.swift +++ b/App/Features/Sources/SourceDebugView.swift @@ -81,8 +81,7 @@ struct SourceDebugView: View { Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill") .font(.headline).frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) - .tint(ReadingTheme.accent) + .readingProminentButtonStyle() .disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } diff --git a/App/Features/Sources/SourceEditorView.swift b/App/Features/Sources/SourceEditorView.swift index 1a28f30..5defbdd 100644 --- a/App/Features/Sources/SourceEditorView.swift +++ b/App/Features/Sources/SourceEditorView.swift @@ -44,11 +44,10 @@ struct SourceEditorView: View { HStack { ForEach(BookSourceEditorSchema.sections) { section in Button(section.title) { sectionID = section.id } - .buttonStyle(.bordered) - .tint(sectionID == section.id ? ReadingTheme.accent : .secondary) + .readingChoiceButtonStyle(isSelected: sectionID == section.id) } - Button("选项") { sectionID = "options" }.buttonStyle(.bordered) - .tint(sectionID == "options" ? ReadingTheme.accent : .secondary) + Button("选项") { sectionID = "options" } + .readingChoiceButtonStyle(isSelected: sectionID == "options") }.padding(.horizontal) }.padding(.vertical, 6) Form { diff --git a/App/Features/Sources/SourceLoginView.swift b/App/Features/Sources/SourceLoginView.swift index 97ce444..a28ca93 100644 --- a/App/Features/Sources/SourceLoginView.swift +++ b/App/Features/Sources/SourceLoginView.swift @@ -1,5 +1,6 @@ @preconcurrency import WebKit import SwiftUI +import UIKit struct SourceLoginView: View { let source: BookSource @@ -74,6 +75,9 @@ private struct SourceLoginWebView: UIViewRepresentable { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .default() let webView = WKWebView(frame: .zero, configuration: configuration) + webView.isOpaque = false + webView.backgroundColor = UIColor(ReadingTheme.canvas) + webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas) webView.navigationDelegate = context.coordinator context.coordinator.installExistingCookies(in: webView) { webView.load(request) } return webView diff --git a/App/Features/Sources/SourceManagementView.swift b/App/Features/Sources/SourceManagementView.swift index 92a4712..89f4af9 100644 --- a/App/Features/Sources/SourceManagementView.swift +++ b/App/Features/Sources/SourceManagementView.swift @@ -61,7 +61,7 @@ struct SourceManagementView: View { Text("新建书源,或从文件、链接、JSON、二维码导入。") } actions: { Button("导入书源", action: presentFileImporter) - .buttonStyle(.borderedProminent) + .readingProminentButtonStyle() } } else { List { diff --git a/App/RootTabView.swift b/App/RootTabView.swift index e7f6011..fab388e 100644 --- a/App/RootTabView.swift +++ b/App/RootTabView.swift @@ -50,6 +50,10 @@ struct RootTabView: View { SearchView(initialKeyword: automationSearchKeyword) } else if requestedDemoPage == "sources" { SourceManagementView() + } else if requestedDemoPage == "rss" { + RSSHomeView() + } else if requestedDemoPage == "rss-photo" { + RSSPhotoView(value: "data:image/png;base64,", source: RssSource(sourceUrl: "https://appearance.invalid", sourceName: "图片外观检查")) } else { BookshelfView() } @@ -99,7 +103,6 @@ struct RootTabView: View { .tabItem { Label("我的", systemImage: "person.crop.circle") } .tag(Tab.my) } - .tint(ReadingTheme.accent) .readingTabBarBackground() .preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil)) .transaction { transaction in @@ -123,6 +126,8 @@ struct RootTabView: View { dismissButton: .default(Text("好")) { sourceLibrary.notice = nil } ) } + .tint(ReadingTheme.accent) + .toggleStyle(SwitchToggleStyle(tint: ReadingTheme.toggleTint)) } private var requestedDemoPage: String? { @@ -487,7 +492,7 @@ private struct ContinueReadingCard: View { .foregroundStyle(ReadingTheme.onAccent) .padding(.horizontal, 18) .frame(minHeight: 40) - .background(ReadingTheme.accent, in: Capsule()) + .background(ReadingTheme.accentFill, in: Capsule()) .padding(.top, 20) } .frame(maxWidth: .infinity, alignment: .leading) @@ -719,7 +724,7 @@ private struct EmptyBookshelfHero: View { } VStack(spacing: 12) { Button("导入本地书", action: onImport) - .buttonStyle(.borderedProminent) + .readingProminentButtonStyle() .controlSize(.large) NavigationLink("添加书源") { SourceManagementView() } .font(.subheadline) @@ -986,7 +991,7 @@ struct SearchView: View { Spacer() if model.isPaused { Button("继续", systemImage: "play.fill") { model.resume() } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() .controlSize(.small) } else if model.state == .searching { StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass") @@ -1037,10 +1042,10 @@ struct SearchView: View { ) { if model.isPaused { Button("继续搜索", systemImage: "play.fill") { model.resume() } - .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) + .readingProminentButtonStyle() } Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() } } @@ -1051,13 +1056,13 @@ struct SearchView: View { systemImage: "network.slash" ) { Button("调整范围", systemImage: "scope") { showsScope = true } - .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) + .readingProminentButtonStyle() NavigationLink { SourceManagementView() } label: { Label("管理书源", systemImage: "network") } - .buttonStyle(.bordered) + .readingSecondaryButtonStyle() } } @@ -1281,7 +1286,7 @@ private struct SettingsView: View { .clipped() .overlay { if colorScheme == .dark { - Color.black.opacity(0.62) + Color.black.opacity(0.78) } } .overlay(alignment: .bottom) { diff --git a/Legado.xcodeproj/project.pbxproj b/Legado.xcodeproj/project.pbxproj index ca12a8b..50fd36a 100644 --- a/Legado.xcodeproj/project.pbxproj +++ b/Legado.xcodeproj/project.pbxproj @@ -6,6 +6,8 @@ objects = { /* Begin PBXBuildFile section */ + DA1000000000000000000002 /* AppearanceContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000002; }; + DA1000000000000000000001 /* DarkModeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000001; }; B10000000000000000000050 = {isa = PBXBuildFile; fileRef = B20000000000000000000050; }; B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; }; BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; }; @@ -124,6 +126,8 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + DA2000000000000000000002 /* AppearanceContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/AppearanceContrastTests.swift; sourceTree = SOURCE_ROOT; }; + DA2000000000000000000001 /* DarkModeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/DarkModeUITests.swift; sourceTree = SOURCE_ROOT; }; B20000000000000000000050 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift; sourceTree = SOURCE_ROOT; }; B20000000000000000000051 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = LegadoReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B20000000000000000000030 /* ReaderScrollGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift; sourceTree = SOURCE_ROOT; }; @@ -272,6 +276,8 @@ AA2200000000000000000010, B20000000000000000000030 /* ReaderScrollGeometryTests.swift */, BC2000000000000000000001 /* WebReaderBridgeTests.swift */, + DA2000000000000000000001 /* DarkModeUITests.swift */, + DA2000000000000000000002 /* AppearanceContrastTests.swift */, A30000000000000000000001 /* App */, C30000000000000000000001 /* LegadoCore */, A20000000000000000000024 /* txtTocRule.json */, @@ -678,8 +684,8 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010); runOnlyForDeploymentPostprocessing = 0; }; - B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; + B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010, DA1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; }; + B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001, DA1000000000000000000002); runOnlyForDeploymentPostprocessing = 0; }; D30000000000000000000002 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; diff --git a/Sources/LegadoCore/RSS/RSSReadingPolicy.swift b/Sources/LegadoCore/RSS/RSSReadingPolicy.swift index 82f6254..1e9105e 100644 --- a/Sources/LegadoCore/RSS/RSSReadingPolicy.swift +++ b/Sources/LegadoCore/RSS/RSSReadingPolicy.swift @@ -34,6 +34,12 @@ public struct RSSReadingPolicy: Sendable { public struct RSSHTMLProcessor: Sendable { public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}" + // Keep source CSS later in the cascade so intentional source styling wins. + public static let appearanceStyle = """ + :root{color-scheme:light dark;} + body{background-color:#fcfcfc;color:#171717;} + @media(prefers-color-scheme:dark){body{background-color:#111113;color:#f2f2f2;}a{color:#9ecbff;}} + """ public init() {} public func contentHTML(_ content: String, style: String?) -> String { @@ -53,7 +59,7 @@ public struct RSSHTMLProcessor: Sendable { } private func injectStyle(into content: String, style: String) -> String { - let tag = "" + let tag = "" if let range = content.range(of: "") { return String(content[..x", style: "body{color:red}").contains("")) - #expect(processor.contentHTML("

x

", style: nil).hasPrefix("")) + #expect(processor.contentHTML("

x

", style: nil).hasPrefix("")) var source = RssSource(sourceUrl: "https://rss", sourceName: "源") source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}" let start = processor.startHTML("

x

", source: source) diff --git a/Tests/LegadoReaderTests/AppearanceContrastTests.swift b/Tests/LegadoReaderTests/AppearanceContrastTests.swift new file mode 100644 index 0000000..158b3b7 --- /dev/null +++ b/Tests/LegadoReaderTests/AppearanceContrastTests.swift @@ -0,0 +1,86 @@ +import SwiftUI +import UIKit +import WebKit +import XCTest +@testable import Legado + +@MainActor +final class AppearanceContrastTests: XCTestCase { + func testLoginWebViewUsesAdaptiveBackgroundBeforeContentArrives() async throws { + var source = BookSource(bookSourceUrl: "https://appearance.invalid", bookSourceName: "外观测试") + source.loginUrl = "about:blank" + let controller = UIHostingController(rootView: SourceLoginView(source: source)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.overrideUserInterfaceStyle = .dark + window.rootViewController = controller + window.makeKeyAndVisible() + defer { window.isHidden = true } + func webView(in view: UIView) -> WKWebView? { + if let view = view as? WKWebView { return view } + return view.subviews.lazy.compactMap { webView(in: $0) }.first + } + window.layoutIfNeeded() + for _ in 0..<20 { + if webView(in: controller.view) != nil { break } + try await Task.sleep(nanoseconds: 50_000_000) + } + let web = try XCTUnwrap(webView(in: controller.view)) + XCTAssertFalse(web.isOpaque) + for style in [UIUserInterfaceStyle.dark, .light] { + window.overrideUserInterfaceStyle = style + window.layoutIfNeeded() + let traits = UITraitCollection(userInterfaceStyle: style) + let background = try XCTUnwrap(web.backgroundColor).resolvedColor(with: traits) + let scroll = try XCTUnwrap(web.scrollView.backgroundColor).resolvedColor(with: traits) + if style == .dark { + XCTAssertLessThan(luminance(background), 0.15) + XCTAssertLessThan(luminance(scroll), 0.15) + } else { + XCTAssertGreaterThan(luminance(background), 0.6) + XCTAssertGreaterThan(luminance(scroll), 0.6) + } + } + } + + func testSyntaxTokensHaveReadableContrastInBaseAndElevatedAppearances() { + let samples: [(SyntaxCodeEditor.Language, String)] = [ + (.json, #"{"title":"正文","count":12,"enabled":true,"empty":null}"#), + (.rule, "@css:div && $.title {{key}} ##"), + (.regex, #"\d+.*(word){2,4}"#), + (.javascript, "// comment\nconst value = 'text'; return 123; /* comment */") + ] + for style in [UIUserInterfaceStyle.light, .dark] { + for level in [UIUserInterfaceLevel.base, .elevated] { + let traits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: style), + UITraitCollection(userInterfaceLevel: level) + ]) + for (language, text) in samples { + let editor = SyntaxCodeEditor(text: .constant(text), language: language) + let view = UITextView() + view.overrideUserInterfaceStyle = style + view.text = text + editor.makeCoordinator().highlight(view) + let background = UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits) + view.textStorage.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: view.textStorage.length)) { color, _, _ in + guard let color = color as? UIColor else { return XCTFail("Missing syntax color") } + let foreground = color.resolvedColor(with: traits) + let values = [luminance(foreground), luminance(background)].sorted() + XCTAssertGreaterThanOrEqual((values[1] + 0.05) / (values[0] + 0.05), 4.5, + "Low contrast for \(language), \(style), \(level)") + } + } + } + } + } + + private func luminance(_ color: UIColor) -> Double { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + XCTAssertTrue(color.getRed(&r, green: &g, blue: &b, alpha: &a)) + func linear(_ value: CGFloat) -> Double { + let value = Double(value) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + } +} diff --git a/Tests/LegadoReaderTests/WebReaderBridgeTests.swift b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift index 3bdd2a5..c420f9d 100644 --- a/Tests/LegadoReaderTests/WebReaderBridgeTests.swift +++ b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift @@ -5,6 +5,56 @@ import XCTest @MainActor final class WebReaderBridgeTests: XCTestCase { + func testGeneratedRSSContentFollowsAppearanceWithoutReloading() async throws { + let source = RssSource(sourceUrl: "https://appearance-regression.test", sourceName: "测试") + let loaded = expectation(description: "HTML loaded") + let surface = RSSWebReaderView( + mode: .html(content: "

订阅正文

链接", baseURL: nil), + source: source, onTextChange: { _ in loaded.fulfill() } + ) + let coordinator = surface.makeCoordinator() + let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + webView.navigationDelegate = coordinator + let window = UIWindow(frame: webView.frame) + let controller = UIViewController() + controller.overrideUserInterfaceStyle = .dark + controller.view.addSubview(webView) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator) + window.isHidden = true + } + coordinator.installRulesAndLoad(mode: surface.mode, in: webView) + await fulfillment(of: [loaded], timeout: 20) + let appearanceScript = """ + [matchMedia('(prefers-color-scheme:dark)').matches, + getComputedStyle(document.body).backgroundColor, + getComputedStyle(document.body).color, + getComputedStyle(document.querySelector('a')).color, + getComputedStyle(document.documentElement).colorScheme].map(String).join('|') + """ + let dark = try await webView.evaluateJavaScript(appearanceScript) + XCTAssertEqual(dark as? String, "true|rgb(17, 17, 19)|rgb(242, 242, 242)|rgb(158, 203, 255)|light dark") + _ = try await webView.evaluateJavaScript("window.appearanceMarker = 'preserved'") + controller.overrideUserInterfaceStyle = .light + window.layoutIfNeeded() + XCTAssertEqual(webView.traitCollection.userInterfaceStyle, .light) + var light = try await webView.evaluateJavaScript(appearanceScript) + for _ in 0..<50 { + if (light as? String)?.hasPrefix("false|") == true { break } + try await Task.sleep(nanoseconds: 100_000_000) + light = try await webView.evaluateJavaScript(appearanceScript) + } + XCTAssertEqual(light as? String, "false|rgb(252, 252, 252)|rgb(23, 23, 23)|rgb(0, 0, 238)|light dark") + let marker = try await webView.evaluateJavaScript("window.appearanceMarker") + XCTAssertEqual(marker as? String, "preserved") + // A source may intentionally override the generated article palette. + _ = try await webView.evaluateJavaScript("document.head.insertAdjacentHTML('beforeend', '')") + let custom = try await webView.evaluateJavaScript("getComputedStyle(document.body).color") + XCTAssertEqual(custom as? String, "rgb(120, 30, 40)") + } + func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws { let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试") let loaded = expectation(description: "HTML loaded") diff --git a/Tests/LegadoReaderUITests/DarkModeUITests.swift b/Tests/LegadoReaderUITests/DarkModeUITests.swift new file mode 100644 index 0000000..10be1c8 --- /dev/null +++ b/Tests/LegadoReaderUITests/DarkModeUITests.swift @@ -0,0 +1,326 @@ +import UIKit +import XCTest + +@MainActor +final class DarkModeUITests: XCTestCase { + func testPrimaryActionsKeepDarkFillInBothAppearances() throws { + continueAfterFailure = false + for theme in ["2", "1"] { + let app = launch(["-uiDemoBookDetail", "-themeMode", theme]) + let read = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch + XCTAssertTrue(read.waitForExistence(timeout: 20)) + XCTAssertTrue(read.isEnabled) + try assertReadableDarkFill(read, in: app) + attach(app, name: "Book detail theme \(theme)") + app.terminate() + } + } + + func testNightReaderSelectionsKeepDarkFillAndReadableLabels() throws { + continueAfterFailure = false + let app = launch([ + "-uiDemoReader", "-uiDemoReaderStyle", "-themeMode", "2", + "-readerTheme", "night", "-readerUseBackgroundImage", "NO", + "-lineSpacingExtra", "12", "-pageAnim", "0" + ]) + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20)) + // Expand the sheet so the controls can be inspected without clipping. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.48)) + .press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12))) + let standard = app.buttons["标准"] + XCTAssertTrue(standard.waitForExistence(timeout: 5)) + try assertReadableDarkFill(standard, in: app) + attach(app, name: "Night reader spacing selection") + let cover = app.buttons["覆盖"] + for _ in 0..<16 { + if cover.exists && cover.isHittable && cover.frame.maxY < app.frame.height - 50 { break } + app.swipeUp() + } + XCTAssertTrue(cover.exists && cover.isHittable) + try assertReadableDarkFill(cover, in: app) + attach(app, name: "Night reader animation selection") + } + + func testDarkAppearanceAcrossTabsAndPresentedEditors() throws { + continueAfterFailure = false + let app = launch(["-themeMode", "2"]) + XCTAssertTrue(app.tabBars.buttons["书架"].waitForExistence(timeout: 20)) + for (tab, heading) in [("书架", "阅读,让时间有了形状"), ("发现", "下一本好书,在这里遇见"), + ("统计", "每一页,都算数"), ("我的", "按照你的方式阅读")] { + app.tabBars.buttons[tab].tap() + XCTAssertTrue(app.staticTexts[heading].waitForExistence(timeout: 5)) + if tab == "统计" { + let today = app.otherElements["statistics.todayCard"] + XCTAssertTrue(today.waitForExistence(timeout: 5)) + try assertReadableDarkFill(today, in: app) + } + attach(app, name: "Dark tab \(tab)") + } + app.buttons["profile.globe"].tap() + XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10)) + attach(app, name: "Dark source library") + app.buttons["更多"].firstMatch.tap() + app.buttons["新建书源"].tap() + XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10)) + let basic = app.buttons["基础"] + try assertReadableDarkFill(basic, in: app) + let login = app.buttons["登录"] + login.tap() + try assertReadableDarkFill(login, in: app) + attach(app, name: "Dark source category selection") + attach(app, name: "Dark presented source editor") + app.segmentedControls.buttons["JSON"].tap() + XCTAssertTrue(app.textViews["syntaxCodeEditor"].waitForExistence(timeout: 5)) + attach(app, name: "Dark JSON editor") + app.textViews["syntaxCodeEditor"].tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + attach(app, name: "Dark editor keyboard and accessory toolbar") + let visibleKeyboard = app.keyboards.firstMatch.frame.intersection(app.frame) + if !visibleKeyboard.isNull && visibleKeyboard.width > 20 && visibleKeyboard.height > 20 { + let keyboard = try luminances(app.keyboards.firstMatch, in: app) + XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15) + } + let accessory = app.buttons["完成"] + let accessoryFrame = accessory.frame.intersection(app.frame) + if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 { + try assertReadableDarkFill(accessory, in: app) + } + } + + func testDarkSettingsRulesAndManagementPages() throws { + continueAfterFailure = false + let app = launch(["-uiDemoTab", "settings", "-themeMode", "2"]) + let routes: [(String, String, String?)] = [ + ("textformat.size", "阅读设置", nil), + ("waveform", "在线朗读引擎", "新建"), + ("bookmark", "书签", nil), + ("clock", "阅读记录", nil), + ("link", "规则订阅", "新建"), + ("text.badge.checkmark", "净化规则", "新建规则"), + ("list.bullet.rectangle", "TXT目录规则", "新建"), + ("folder", "文件管理", nil), + ("gearshape", "其他设置", nil), + ("info.circle", "关于", nil) + ] + for (icon, title, create) in routes { + let entry = app.buttons["profile.\(icon)"] + scrollTo(entry, in: app) + entry.tap() + XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 10)) + try captureDarkPage(app, name: "Management \(title)") + if let create { + app.buttons[create].firstMatch.tap() + XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Editor \(title)") + if title == "净化规则" { + app.segmentedControls.buttons["测试规则"].tap() + try captureDarkPage(app, name: "Rule test form") + } + app.buttons["取消"].tap() + } + if title == "其他设置" { + for (entry, destination) in [("网络与缓存设置", "网络与缓存"), ("章节缓存管理", "缓存管理"), ("日志与诊断", "日志与诊断")] { + let link = app.buttons[entry] + scrollTo(link, in: app) + link.tap() + XCTAssertTrue(app.navigationBars[destination].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Settings \(destination)") + app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap() + } + } + if title == "关于" { + let license = app.buttons["开源许可"] + scrollTo(license, in: app) + license.tap() + XCTAssertTrue(app.navigationBars["开源许可"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Licenses") + app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap() + } + app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap() + XCTAssertTrue(app.scrollViews["profile.scroll"].waitForExistence(timeout: 5)) + } + } + + func testDarkSearchFiltersAndSourceImportSheets() throws { + continueAfterFailure = false + let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"]) + let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch + if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() } + XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10)) + app.buttons["搜索选项"].tap() + app.buttons["分组或书源"].tap() + XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Search group filter") + app.segmentedControls.buttons["书源"].tap() + try captureDarkPage(app, name: "Search source filter") + app.buttons["取消"].tap() + app.terminate() + + app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources", "-themeMode", "2"] + app.launch() + XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10)) + app.buttons["排序"].tap() + XCTAssertTrue(app.buttons["倒序"].waitForExistence(timeout: 5)) + try assertReadableDarkFill(app.buttons["倒序"], in: app) + attach(app, name: "Source sorting menu") + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).tap() + app.buttons["更多"].firstMatch.tap() + app.buttons["URL 或粘贴 JSON"].tap() + XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Source text import") + } + + func testDarkRSSAndBlackPhotoViewerInLightAppearance() throws { + continueAfterFailure = false + let app = launch(["-uiDemoTab", "rss", "-themeMode", "2"]) + XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 10)) + for tab in ["订阅", "收藏", "记录"] { + app.segmentedControls.buttons[tab].tap() + try captureDarkPage(app, name: "RSS \(tab)") + } + app.segmentedControls.buttons["订阅"].tap() + app.buttons["RSS操作"].tap() + app.buttons["新建RSS源"].tap() + XCTAssertTrue(app.navigationBars["新建RSS源"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "RSS source editor") + app.terminate() + + app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss-photo", "-themeMode", "1"] + app.launch() + XCTAssertTrue(app.alerts["图片"].waitForExistence(timeout: 10)) + app.alerts["图片"].buttons["好"].tap() + let error = app.staticTexts["图片加载失败"] + XCTAssertTrue(error.waitForExistence(timeout: 5)) + try assertReadableDarkFill(error, in: app) + let done = app.navigationBars.buttons["完成"] + XCTAssertTrue(done.exists && done.isEnabled) + attach(app, name: "Photo viewer navigation and content") + let toolbar = try luminances(done, in: app) + XCTAssertGreaterThanOrEqual((toolbar[toolbar.count * 98 / 100] + 0.05) / (toolbar[toolbar.count * 2 / 100] + 0.05), 4.5) + attach(app, name: "Readable photo error on black canvas") + } + + func testNightReaderMenusAndSettings() throws { + continueAfterFailure = false + let app = launch(["-uiDemoReader", "-uiDemoReaderMenu", "-readerTheme", "night", "-themeMode", "2", "-readerUseBackgroundImage", "NO"]) + XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 15)) + try captureDarkPage(app, name: "Night reader menu") + app.buttons["目录"].tap() + XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Night table of contents") + app.buttons["关闭"].tap() + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + app.buttons["设置"].tap() + XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Night reader settings") + let click = app.buttons["点击区域设置"] + scrollTo(click, in: app) + click.tap() + XCTAssertTrue(app.navigationBars["点击区域设置"].waitForExistence(timeout: 5)) + try captureDarkPage(app, name: "Night click action grid") + } + + func testAppearanceMenuRestoresSystemAndUpdatesVisiblePage() throws { + continueAfterFailure = false + // Launch arguments override writes to UserDefaults; leave themeMode mutable here. + let app = launch(["-uiDemoTab", "settings"]) + let appearance = app.buttons["profile.appearance"] + XCTAssertTrue(appearance.waitForExistence(timeout: 10)) + appearance.tap() + let system = app.buttons["跟随系统"] + XCTAssertTrue(system.waitForExistence(timeout: 3)) + system.tap() + let followsSystem = expectation(for: NSPredicate(format: "value == %@", "跟随系统"), evaluatedWith: appearance) + wait(for: [followsSystem], timeout: 5) + let initial = try luminances(app, in: app) + let systemIsDark = initial[initial.count / 2] < 0.15 + for (choice, expectsDark) in [("深色", true), ("浅色", false), ("跟随系统", systemIsDark)] { + appearance.tap() + let option = app.buttons[choice] + if !option.waitForExistence(timeout: 2), app.buttons["外观"].exists { app.buttons["外观"].tap() } + XCTAssertTrue(option.waitForExistence(timeout: 3)) + option.tap() + let changed = expectation(for: NSPredicate(format: "value == %@", choice), evaluatedWith: appearance) + wait(for: [changed], timeout: 5) + let values = try luminances(app, in: app) + let median = values[values.count / 2] + if expectsDark { XCTAssertLessThan(median, 0.15) } + else { XCTAssertGreaterThan(median, 0.6) } + attach(app, name: "Live appearance \(choice)") + } + } + + private func scrollTo(_ element: XCUIElement, in app: XCUIApplication) { + for _ in 0..<10 { + if element.exists && element.isHittable { return } + if element.exists && element.frame.maxY < app.frame.height / 2 { + app.swipeDown() + } else { + app.swipeUp() + } + } + XCTAssertTrue(element.exists && element.isHittable) + } + + private func captureDarkPage(_ app: XCUIApplication, name: String) throws { + let values = try luminances(app, in: app) + XCTAssertLessThan(values[values.count / 2], 0.15, "Unexpected light page or sheet: \(name)") + attach(app, name: name) + } + + // Check both the rendered label contrast and the fill's actual brightness. + // A readable white surface must still fail the dark appearance regression. + private func assertReadableDarkFill(_ element: XCUIElement, in app: XCUIApplication, + file: StaticString = #filePath, line: UInt = #line) throws { + let luminances = try luminances(element, in: app, file: file, line: line) + let low = luminances[luminances.count * 2 / 100] + let high = luminances[luminances.count * 98 / 100] + XCTAssertGreaterThanOrEqual((high + 0.05) / (low + 0.05), 4.5, + "Rendered label has insufficient contrast", file: file, line: line) + XCTAssertLessThan(luminances[luminances.count / 2], 0.15, + "Filled control or card is too bright for the dark palette", file: file, line: line) + } + + private func luminances(_ element: XCUIElement, in app: XCUIApplication, + file: StaticString = #filePath, line: UInt = #line) throws -> [Double] { + let image = try XCTUnwrap(app.screenshot().image.cgImage, file: file, line: line) + let scaleX = CGFloat(image.width) / app.frame.width + let scaleY = CGFloat(image.height) / app.frame.height + let bounds = element.frame.intersection(app.frame).insetBy(dx: 8, dy: 8) + let rect = CGRect(x: bounds.minX * scaleX, y: bounds.minY * scaleY, + width: bounds.width * scaleX, height: bounds.height * scaleY).integral + let crop = try XCTUnwrap(image.cropping(to: rect), file: file, line: line) + var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try XCTUnwrap(CGContext( + data: buffer.baseAddress, width: crop.width, height: crop.height, + bitsPerComponent: 8, bytesPerRow: crop.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ), file: file, line: line) + context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height)) + } + func linear(_ component: UInt8) -> Double { + let value = Double(component) / 255 + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + let sampleStride = crop.width * crop.height > 250_000 ? 64 : 4 + return stride(from: 0, to: bytes.count, by: sampleStride).map { + 0.2126 * linear(bytes[$0]) + 0.7152 * linear(bytes[$0 + 1]) + 0.0722 * linear(bytes[$0 + 2]) + }.sorted() + } + + private func launch(_ arguments: [String]) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-uiDemo"] + arguments + app.launch() + return app + } + + private func attach(_ app: XCUIApplication, name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +}