Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
420 lines
16 KiB
Swift
420 lines
16 KiB
Swift
import SwiftUI
|
|
import WebKit
|
|
|
|
// MARK: - Reading progress persistence
|
|
|
|
enum ReadingProgress {
|
|
private static let key = "readingProgress"
|
|
|
|
static func get(url: String) -> Double {
|
|
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
|
|
}
|
|
|
|
static func set(url: String, progress: Double) {
|
|
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
|
if progress >= 0.99 {
|
|
store.removeValue(forKey: url) // fully read — clean up
|
|
} else {
|
|
store[url] = progress
|
|
}
|
|
UserDefaults.standard.set(store, forKey: key)
|
|
}
|
|
|
|
static func all() -> [String: Double] {
|
|
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
|
}
|
|
}
|
|
|
|
// MARK: - Indeterminate page load bar
|
|
|
|
private struct PageLoadBar: View {
|
|
@State private var phase: CGFloat = 0
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
let barWidth = geo.size.width * 0.38
|
|
Capsule()
|
|
.fill(.blue.opacity(0.75))
|
|
.frame(width: barWidth, height: 3)
|
|
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
|
|
}
|
|
.frame(height: 3)
|
|
.clipped()
|
|
.onAppear {
|
|
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
|
|
phase = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - BrowserState
|
|
|
|
@Observable
|
|
final class BrowserState: NSObject, WKNavigationDelegate, WKUIDelegate {
|
|
let webView: WKWebView
|
|
var isLoading = false
|
|
var canGoBack = false
|
|
var canGoForward = false
|
|
var pageTitle = ""
|
|
var currentURL: URL?
|
|
var readingProgress: Double = 0
|
|
|
|
// Weak wrapper breaks WKUserContentController's strong retain of the handler
|
|
private final class ScrollHandler: NSObject, WKScriptMessageHandler {
|
|
weak var state: BrowserState?
|
|
init(_ state: BrowserState) { self.state = state }
|
|
func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) {
|
|
if let p = msg.body as? Double { state?.readingProgress = p }
|
|
}
|
|
}
|
|
private var scrollHandler: ScrollHandler?
|
|
|
|
init(url: URL) {
|
|
let config = WKWebViewConfiguration()
|
|
config.allowsInlineMediaPlayback = true
|
|
webView = WKWebView(frame: .zero, configuration: config)
|
|
super.init()
|
|
let handler = ScrollHandler(self)
|
|
scrollHandler = handler
|
|
webView.configuration.userContentController.add(handler, name: "scrollProgress")
|
|
webView.navigationDelegate = self
|
|
webView.uiDelegate = self
|
|
webView.load(URLRequest(url: url))
|
|
}
|
|
|
|
deinit {
|
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
|
|
isLoading = true
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
|
|
currentURL = webView.url
|
|
canGoBack = webView.canGoBack
|
|
canGoForward = webView.canGoForward
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
|
isLoading = false
|
|
canGoBack = webView.canGoBack
|
|
canGoForward = webView.canGoForward
|
|
pageTitle = webView.title ?? ""
|
|
currentURL = webView.url
|
|
|
|
// Restore persisted progress, then start tracking scroll
|
|
if let urlStr = webView.url?.absoluteString {
|
|
let saved = ReadingProgress.get(url: urlStr)
|
|
if saved > 0 {
|
|
let pct = saved * 100
|
|
webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil)
|
|
}
|
|
}
|
|
webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil)
|
|
}
|
|
|
|
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false }
|
|
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false }
|
|
|
|
// target="_blank" and window.open() — load in the same webview instead of opening Safari
|
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
|
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
if let url = action.request.url {
|
|
webView.load(URLRequest(url: url))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func enterReaderMode(completion: @escaping () -> Void) {
|
|
webView.evaluateJavaScript(readerModeJS) { _, _ in
|
|
DispatchQueue.main.async {
|
|
completion()
|
|
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func exitReaderMode() { webView.reload() }
|
|
|
|
private var scrollTrackingJS: String {
|
|
"""
|
|
(function(){
|
|
function report(){
|
|
var h=document.body.scrollHeight-window.innerHeight;
|
|
var p=h>0?window.scrollY/h:0;
|
|
window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p)));
|
|
}
|
|
window.removeEventListener('scroll',window._marksScrollFn);
|
|
window._marksScrollFn=report;
|
|
window.addEventListener('scroll',report,{passive:true});
|
|
report();
|
|
})();
|
|
"""
|
|
}
|
|
|
|
private var readerModeJS: String {
|
|
"""
|
|
(function() {
|
|
var title = document.title;
|
|
var selectors = ['article','[role="article"]','main','[role="main"]',
|
|
'.post-content','.article-content','.entry-content',
|
|
'.post-body','.article-body','.story-body','.content','#content'];
|
|
var content = null;
|
|
for (var i = 0; i < selectors.length; i++) {
|
|
var el = document.querySelector(selectors[i]);
|
|
if (el && (el.innerText||'').trim().length > 300) { content = el; break; }
|
|
}
|
|
if (!content) {
|
|
var best = { el: null, len: 0 };
|
|
Array.from(document.querySelectorAll('div')).forEach(function(d) {
|
|
var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0);
|
|
if (len > best.len) best = { el: d, len: len };
|
|
});
|
|
content = best.el || document.body;
|
|
}
|
|
var clone = content.cloneNode(true);
|
|
['script','style','nav','header','footer','aside'].forEach(function(tag){
|
|
clone.querySelectorAll(tag).forEach(function(el){ el.remove(); });
|
|
});
|
|
var html = clone.innerHTML;
|
|
var css = [
|
|
'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;',
|
|
'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}',
|
|
'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}',
|
|
'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}',
|
|
'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}',
|
|
'a{color:#007aff;text-decoration:none}',
|
|
'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}',
|
|
'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}',
|
|
'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}',
|
|
'@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}',
|
|
'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}'
|
|
].join('');
|
|
document.open();
|
|
document.write('<!DOCTYPE html><html><head>' +
|
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
'<style>' + css + '</style>' +
|
|
'<title>' + title + '</title></head><body>' +
|
|
'<h1>' + title + '</h1>' + html + '</body></html>');
|
|
document.close();
|
|
return 'ok';
|
|
})()
|
|
"""
|
|
}
|
|
}
|
|
|
|
// MARK: - WebView representable
|
|
|
|
private struct WebViewRepresentable: UIViewRepresentable {
|
|
let webView: WKWebView
|
|
func makeUIView(context: Context) -> WKWebView { webView }
|
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
|
}
|
|
|
|
// MARK: - BrowserView
|
|
|
|
struct BrowserView: View {
|
|
let initialURL: URL
|
|
let initialTitle: String
|
|
let claude: ClaudeService
|
|
let podcastPlayer: PodcastPlayerViewModel
|
|
let podcastGenerator: PodcastGenerationManager
|
|
var onArchive: (() async -> Void)? = nil
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var state: BrowserState
|
|
@State private var readerMode = false
|
|
@State private var showPodcast = false
|
|
@State private var archiveTapCount = 0
|
|
@State private var navBackCount = 0
|
|
@State private var navForwardCount = 0
|
|
@State private var toolbarHidden = false
|
|
|
|
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
|
|
self.initialURL = url
|
|
self.initialTitle = title
|
|
self.claude = claude
|
|
self.podcastPlayer = podcastPlayer
|
|
self.podcastGenerator = podcastGenerator
|
|
self.onArchive = onArchive
|
|
self._state = State(initialValue: BrowserState(url: url))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack(alignment: .top) {
|
|
WebViewRepresentable(webView: state.webView)
|
|
.ignoresSafeArea(edges: .bottom)
|
|
|
|
// Reading progress bar
|
|
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(Paper.accent.opacity(0.55))
|
|
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
|
}
|
|
.frame(height: 3)
|
|
.animation(.linear(duration: 0.1), value: state.readingProgress)
|
|
}
|
|
|
|
// Page loading sweep bar (replaces center spinner)
|
|
if state.isLoading {
|
|
PageLoadBar()
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button { dismiss() } label: {
|
|
Image(systemName: "xmark")
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button { openURL(state.currentURL ?? initialURL) } label: {
|
|
Image(systemName: "safari")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
bottomBar
|
|
.offset(y: toolbarHidden ? 88 : 0)
|
|
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
|
|
}
|
|
.onChange(of: state.readingProgress) { old, new in
|
|
let delta = new - old
|
|
withAnimation(.spring(duration: 0.3, bounce: 0)) {
|
|
if delta > 0.012 && new > 0.07 {
|
|
toolbarHidden = true
|
|
} else if delta < -0.005 || new < 0.05 {
|
|
toolbarHidden = false
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: state.isLoading) { _, loading in
|
|
if loading {
|
|
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
|
|
}
|
|
}
|
|
.onDisappear {
|
|
let urlStr = (state.currentURL ?? initialURL).absoluteString
|
|
let p = state.readingProgress
|
|
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
|
}
|
|
.sheet(isPresented: $showPodcast) {
|
|
PodcastPlayerView(
|
|
vm: podcastPlayer,
|
|
articleUrl: (state.currentURL ?? initialURL).absoluteString,
|
|
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
|
|
claude: claude,
|
|
stopOnDismiss: false
|
|
)
|
|
}
|
|
}
|
|
|
|
private var bottomBar: some View {
|
|
HStack {
|
|
Button {
|
|
navBackCount += 1
|
|
state.webView.goBack()
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(!state.canGoBack)
|
|
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
navForwardCount += 1
|
|
state.webView.goForward()
|
|
} label: {
|
|
Image(systemName: "chevron.right")
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(!state.canGoForward)
|
|
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
if readerMode { state.exitReaderMode(); readerMode = false }
|
|
else { state.enterReaderMode { readerMode = true } }
|
|
} label: {
|
|
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
|
.contentTransition(.symbolEffect(.replace))
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(state.isLoading)
|
|
|
|
if onArchive != nil { Spacer() }
|
|
|
|
if let onArchive {
|
|
Button {
|
|
archiveTapCount += 1
|
|
Task { await onArchive() }
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "archivebox")
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
let currentUrl = (state.currentURL ?? initialURL).absoluteString
|
|
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
|
|
// Never interrupt current playback: play-and-show only when idle,
|
|
// otherwise generate in the background.
|
|
if podcastPlayer.currentArticleUrl.isEmpty {
|
|
podcastPlayer.start(
|
|
articleUrl: currentUrl,
|
|
articleTitle: currentTitle,
|
|
claude: claude,
|
|
parentBookmarkUrl: initialURL.absoluteString
|
|
)
|
|
showPodcast = true
|
|
} else {
|
|
podcastGenerator.enqueue(
|
|
articleUrl: currentUrl,
|
|
title: currentTitle,
|
|
parentBookmarkUrl: initialURL.absoluteString
|
|
)
|
|
}
|
|
} label: {
|
|
Image(systemName: "headphones")
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.disabled(state.isLoading)
|
|
|
|
Spacer()
|
|
|
|
ShareLink(item: state.currentURL ?? initialURL) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.frame(width: 44, height: 44)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
.font(PaperType.label)
|
|
.foregroundStyle(Paper.ink)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
.background(Paper.raised)
|
|
.overlay(alignment: .top) { Divider() }
|
|
}
|
|
}
|