Bläddra i källkod

Add interactive Translate and Earth desktop widgets

- Translate: large WKWebView with Text/Documents/History toolbar, recent
  extraction, small/medium shortcut variants preserved
- Earth: large embedded earth.google.com/web with header and open-in-browser;
  medium Explorer grid unchanged; remove small variant
- WidgetLayoutMode.translate / .earth, panel sizes, key-capable panels
- DesktopWidgetView wiring for TranslateDesktopWidgetView and EarthDesktopWidgetView

Made-with: Cursor
huzaifahayat12 3 månader sedan
förälder
incheckning
b58548ed5c

+ 5 - 1
google_apps/Widgets/DesktopWidgetView.swift

@@ -21,6 +21,10 @@ struct DesktopWidgetView: View {
                 DriveDesktopWidgetView(app: app, mode: driveMode, isPreview: isPreview)
             } else if case .keep(let keepMode) = layoutMode {
                 KeepDesktopWidgetView(app: app, mode: keepMode, isPreview: isPreview)
+            } else if case .translate(let translateMode) = layoutMode {
+                TranslateDesktopWidgetView(app: app, mode: translateMode, isPreview: isPreview)
+            } else if case .earth(let earthMode) = layoutMode {
+                EarthDesktopWidgetView(app: app, mode: earthMode, isPreview: isPreview)
             } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
@@ -56,7 +60,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap, .gmail, .drive, .keep:
+                        case .interactiveMap, .gmail, .drive, .keep, .translate, .earth:
                             EmptyView()
                         }
                     }

+ 224 - 0
google_apps/Widgets/EarthDesktopWidgetView.swift

@@ -0,0 +1,224 @@
+import AppKit
+import Combine
+import SwiftUI
+import WebKit
+
+/// Holds the embedded Google Earth `WKWebView` and last loaded URL for `NSViewRepresentable` updates.
+private final class EarthWebViewRef: ObservableObject {
+    weak var webView: WKWebView?
+    var lastLoadedURLString: String?
+}
+
+/// Large interactive Google Earth Web: full globe in an embedded web view with an open-in-browser control.
+struct EarthDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: EarthWidgetMode
+    var isPreview: Bool = false
+
+    @StateObject private var webRef = EarthWebViewRef()
+    @State private var webURL: URL
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://earth.google.com")! }
+
+    init(app: LauncherApp, mode: EarthWidgetMode, isPreview: Bool = false) {
+        self.app = app
+        self.mode = mode
+        self.isPreview = isPreview
+        let base = app.webURL ?? URL(string: "https://earth.google.com")!
+        _webURL = State(
+            initialValue: WidgetDeepLinkURL.resolved(base: base, actionPath: "/web/", accountSlot: 0)
+        )
+    }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .interactive:
+                interactiveBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    private var interactiveBody: some View {
+        Group {
+            if isPreview {
+                earthPreviewChrome
+            } else {
+                earthLiveChrome
+            }
+        }
+    }
+
+    private var earthPreviewChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.1, green: 0.35, blue: 0.65), Color(red: 0.02, green: 0.12, blue: 0.28)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Google Earth")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("Explore the full globe inside this widget, or open Earth in your browser.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.7))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    private var earthLiveChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(Color.black.opacity(0.24))
+
+            VStack(spacing: 0) {
+                earthToolbar
+                EarthWebWidgetView(url: $webURL, webRef: webRef)
+                    .frame(minHeight: 340)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+            }
+            .padding(10)
+        }
+    }
+
+    private var earthToolbar: some View {
+        HStack(spacing: 8) {
+            AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+            Text("Earth")
+                .font(.system(size: 14, weight: .bold))
+                .foregroundStyle(.white.opacity(0.95))
+            Spacer(minLength: 0)
+            Button(action: {
+                NSApp.activate(ignoringOtherApps: true)
+                openInAppBrowser(baseURL)
+            }) {
+                Image(systemName: "arrow.up.right.square")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .padding(8)
+                    .background(Circle().fill(Color.white.opacity(0.12)))
+            }
+            .buttonStyle(.plain)
+            .help("Open Earth in browser")
+        }
+        .padding(.horizontal, 6)
+        .padding(.vertical, 8)
+        .background(
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+                .fill(Color(red: 0.04, green: 0.1, blue: 0.22))
+        )
+        .zIndex(20)
+    }
+
+    private func openInAppBrowser(_ url: URL) {
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+}
+
+// MARK: - WKWebView
+
+private final class EarthWebViewClippingContainer: NSView {
+    override var isFlipped: Bool { true }
+}
+
+private struct EarthWebWidgetView: NSViewRepresentable {
+    @Binding var url: URL
+    var webRef: EarthWebViewRef
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(webRef: webRef)
+    }
+
+    func makeNSView(context: Context) -> NSView {
+        let container = EarthWebViewClippingContainer()
+        container.wantsLayer = true
+        container.layer?.masksToBounds = true
+        container.layer?.cornerRadius = 12
+
+        let config = WKWebViewConfiguration()
+        config.defaultWebpagePreferences.allowsContentJavaScript = true
+        let wv = WKWebView(frame: .zero, configuration: config)
+        wv.translatesAutoresizingMaskIntoConstraints = false
+        wv.focusRingType = .none
+        wv.customUserAgent =
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+        wv.navigationDelegate = context.coordinator
+
+        container.addSubview(wv)
+        NSLayoutConstraint.activate([
+            wv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            wv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            wv.topAnchor.constraint(equalTo: container.topAnchor),
+            wv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        context.coordinator.webView = wv
+        webRef.webView = wv
+        webRef.lastLoadedURLString = url.absoluteString
+        wv.load(URLRequest(url: url))
+        DispatchQueue.main.async {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        return container
+    }
+
+    func updateNSView(_ container: NSView, context: Context) {
+        guard let wv = context.coordinator.webView else { return }
+        let next = url.absoluteString
+        if webRef.lastLoadedURLString == next { return }
+        webRef.lastLoadedURLString = next
+        wv.load(URLRequest(url: url))
+    }
+
+    private static func stripWebKitOuterChrome(_ webView: WKWebView) {
+        webView.focusRingType = .none
+        func walk(_ view: NSView) {
+            view.focusRingType = .none
+            view.layer?.borderWidth = 0
+            view.layer?.borderColor = NSColor.clear.cgColor
+            if let scroll = view as? NSScrollView {
+                scroll.borderType = .noBorder
+                scroll.drawsBackground = false
+                scroll.scrollerStyle = .overlay
+            }
+            if let clip = view as? NSClipView {
+                clip.drawsBackground = false
+            }
+            for sub in view.subviews {
+                walk(sub)
+            }
+        }
+        walk(webView)
+    }
+
+    final class Coordinator: NSObject, WKNavigationDelegate {
+        weak var webView: WKWebView?
+        let webRef: EarthWebViewRef
+
+        init(webRef: EarthWebViewRef) {
+            self.webRef = webRef
+        }
+
+        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+            self.webView = webView
+            webRef.webView = webView
+            EarthWebWidgetView.stripWebKitOuterChrome(webView)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
+                EarthWebWidgetView.stripWebKitOuterChrome(webView)
+            }
+        }
+    }
+}

+ 469 - 0
google_apps/Widgets/TranslateDesktopWidgetView.swift

@@ -0,0 +1,469 @@
+import AppKit
+import Combine
+import SwiftUI
+import WebKit
+
+private extension Notification.Name {
+    /// Posted when Translate’s web view finishes loading (object: `WKWebView`).
+    static let translateWidgetPageLoaded = Notification.Name("google_apps.translateWidgetPageLoaded")
+}
+
+/// Holds the embedded translator `WKWebView` so toolbar actions (voice, conversation JS) can target it.
+private final class TranslateWebViewRef: ObservableObject {
+    weak var webView: WKWebView?
+}
+
+/// Large interactive Google Translate: full web UI in-panel (text, mic, conversation, history) plus local recent chips.
+struct TranslateDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: TranslateWidgetMode
+    var isPreview: Bool = false
+
+    @StateObject private var webRef = TranslateWebViewRef()
+    @State private var webURL: URL
+    @State private var recentEntries: [TranslateRecentEntry] = []
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://translate.google.com")! }
+
+    private static let recentDefaultsKey = "translateWidgetRecentJSON"
+
+    init(app: LauncherApp, mode: TranslateWidgetMode, isPreview: Bool = false) {
+        self.app = app
+        self.mode = mode
+        self.isPreview = isPreview
+        let base = app.webURL ?? URL(string: "https://translate.google.com")!
+        _webURL = State(
+            initialValue: WidgetDeepLinkURL.resolved(base: base, actionPath: "/?sl=auto&tl=en", accountSlot: 0)
+        )
+    }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .interactive:
+                interactiveBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .onAppear {
+            loadRecents()
+        }
+    }
+
+    private var interactiveBody: some View {
+        Group {
+            if isPreview {
+                translatePreviewChrome
+            } else {
+                translateLiveChrome
+            }
+        }
+    }
+
+    private var translatePreviewChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.2, green: 0.45, blue: 0.95), Color(red: 0.08, green: 0.22, blue: 0.55)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Google Translate")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("Type, translate documents, and browse history — all inside this widget.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.7))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    private var translateLiveChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(Color.black.opacity(0.22))
+
+            VStack(spacing: 0) {
+                translateToolbar
+                recentStrip
+                TranslateWebWidgetView(url: $webURL, webRef: webRef)
+                    .frame(minHeight: 320)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+            }
+            .padding(10)
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .translateWidgetPageLoaded)) { note in
+            guard let wv = note.object as? WKWebView, wv === webRef.webView else { return }
+            extractAndMergeRecents(webView: wv)
+        }
+    }
+
+    private var translateToolbar: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(spacing: 8) {
+                AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Translate")
+                    .font(.system(size: 14, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.95))
+                Spacer(minLength: 0)
+                Button(action: {
+                    NSApp.activate(ignoringOtherApps: true)
+                    openInAppBrowser(baseURL)
+                }) {
+                    Image(systemName: "arrow.up.right.square")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.9))
+                        .padding(8)
+                        .background(Circle().fill(Color.white.opacity(0.12)))
+                }
+                .buttonStyle(.plain)
+                .help("Open Translate in browser")
+            }
+
+            ScrollView(.horizontal, showsIndicators: false) {
+                HStack(spacing: 8) {
+                    toolbarChip("Text", systemImage: "textformat", path: "/?sl=auto&tl=en", action: nil)
+                    toolbarChip("Documents", systemImage: "doc.text", path: "/?tr=f&hl=en&tab=wT", action: nil)
+                    toolbarChip("History", systemImage: "clock.arrow.circlepath", path: nil, action: { runOpenHistory() })
+                }
+            }
+        }
+        .padding(.horizontal, 6)
+        .padding(.vertical, 8)
+        .background(
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
+                .fill(Color(red: 0.08, green: 0.14, blue: 0.28))
+        )
+        .zIndex(20)
+    }
+
+    @ViewBuilder
+    private var recentStrip: some View {
+        if recentEntries.isEmpty {
+            EmptyView()
+        } else {
+            VStack(alignment: .leading, spacing: 4) {
+                Text("Recent")
+                    .font(.system(size: 10, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.55))
+                ScrollView(.horizontal, showsIndicators: false) {
+                    HStack(spacing: 8) {
+                        ForEach(recentEntries) { entry in
+                            Button(action: {
+                                NSApp.activate(ignoringOtherApps: true)
+                                applyRecentEntry(entry)
+                            }) {
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(entry.sourcePreview)
+                                        .font(.system(size: 10, weight: .semibold))
+                                        .foregroundStyle(.white.opacity(0.95))
+                                        .lineLimit(1)
+                                    Text(entry.targetPreview)
+                                        .font(.system(size: 9, weight: .medium))
+                                        .foregroundStyle(.white.opacity(0.6))
+                                        .lineLimit(1)
+                                }
+                                .padding(.horizontal, 10)
+                                .padding(.vertical, 6)
+                                .background(Capsule().fill(Color.white.opacity(0.1)))
+                            }
+                            .buttonStyle(.plain)
+                        }
+                    }
+                }
+            }
+            .padding(.horizontal, 6)
+            .padding(.bottom, 6)
+        }
+    }
+
+    private func toolbarChip(
+        _ title: String,
+        systemImage: String,
+        path: String?,
+        action: (() -> Void)?
+    ) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            if let action {
+                action()
+            } else if let path {
+                navigateWeb(to: path)
+            }
+        }) {
+            HStack(spacing: 4) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 11, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 10, weight: .bold))
+            }
+            .foregroundStyle(.white.opacity(0.92))
+            .padding(.horizontal, 10)
+            .padding(.vertical, 6)
+            .background(Capsule().fill(Color.white.opacity(0.12)))
+        }
+        .buttonStyle(.plain)
+    }
+
+    // MARK: - URLs & actions
+
+    private func navigateWeb(to path: String) {
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: 0)
+        DispatchQueue.main.async {
+            webURL = url
+        }
+    }
+
+    private func openInAppBrowser(_ url: URL) {
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func runOpenHistory() {
+        guard let wv = webRef.webView else { return }
+        wv.evaluateJavaScript(Self.clickHistoryJavaScript, completionHandler: nil)
+    }
+
+    private func applyRecentEntry(_ entry: TranslateRecentEntry) {
+        navigateWeb(to: "/?sl=auto&tl=en")
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
+            guard let wv = webRef.webView else { return }
+            guard let jsonData = try? JSONEncoder().encode([entry.source]),
+                  let jsonLiteral = String(data: jsonData, encoding: .utf8)
+            else { return }
+            let script = """
+            (function(){
+              var arr = \(jsonLiteral);
+              var text = arr[0] || '';
+              var ta = document.querySelector('textarea');
+              if (!ta) return;
+              ta.focus();
+              ta.value = text;
+              ta.dispatchEvent(new Event('input', { bubbles: true }));
+              ta.dispatchEvent(new Event('change', { bubbles: true }));
+            })();
+            """
+            wv.evaluateJavaScript(script, completionHandler: nil)
+        }
+    }
+
+    // MARK: - Recent translations (local)
+
+    private func loadRecents() {
+        guard let data = UserDefaults.standard.string(forKey: Self.recentDefaultsKey)?.data(using: .utf8),
+              let decoded = try? JSONDecoder().decode([TranslateRecentEntry].self, from: data)
+        else {
+            recentEntries = []
+            return
+        }
+        recentEntries = decoded
+    }
+
+    private func saveRecents() {
+        guard let data = try? JSONEncoder().encode(recentEntries),
+              let str = String(data: data, encoding: .utf8)
+        else { return }
+        UserDefaults.standard.set(str, forKey: Self.recentDefaultsKey)
+    }
+
+    private func extractAndMergeRecents(webView: WKWebView) {
+        webView.evaluateJavaScript(Self.extractPairJavaScript) { result, _ in
+            Task { @MainActor in
+                guard let json = result as? String,
+                      let data = json.data(using: .utf8),
+                      let pair = try? JSONDecoder().decode(ExtractedPair.self, from: data),
+                      pair.source.count > 0,
+                      pair.target.count > 1
+                else { return }
+
+                var next = recentEntries
+                if let idx = next.firstIndex(where: { $0.source == pair.source }) {
+                    next[idx] = TranslateRecentEntry(
+                        id: next[idx].id,
+                        source: pair.source,
+                        target: pair.target,
+                        savedAt: Date()
+                    )
+                } else {
+                    let entry = TranslateRecentEntry(id: UUID(), source: pair.source, target: pair.target, savedAt: Date())
+                    next.insert(entry, at: 0)
+                }
+                if next.count > 8 {
+                    next = Array(next.prefix(8))
+                }
+                recentEntries = next
+                saveRecents()
+            }
+        }
+    }
+
+    private struct ExtractedPair: Decodable {
+        var source: String
+        var target: String
+    }
+
+    private static let extractPairJavaScript = #"""
+(function(){
+  try {
+    var tas = document.querySelectorAll('textarea');
+    var src = '';
+    var dst = '';
+    if (tas.length >= 1) src = (tas[0].value || tas[0].innerText || '').trim();
+    if (tas.length >= 2) dst = (tas[1].value || tas[1].innerText || '').trim();
+    if (!dst) {
+      var main = document.querySelector('[role="main"]');
+      if (main) {
+        var spans = main.querySelectorAll('span[lang], span[jsname]');
+        if (spans.length) {
+          var parts = [];
+          for (var i = 0; i < Math.min(spans.length, 6); i++) {
+            var t = (spans[i].innerText || '').trim();
+            if (t) parts.push(t);
+          }
+          dst = parts.join(' ').trim();
+        }
+      }
+    }
+    return JSON.stringify({ source: src, target: dst });
+  } catch (e) {
+    return JSON.stringify({ source: '', target: '' });
+  }
+})();
+"""#
+
+    private static let clickHistoryJavaScript = #"""
+(function(){
+  var nodes = document.querySelectorAll('button, [role="tab"], a, div[role="button"]');
+  for (var i = 0; i < nodes.length; i++) {
+    var b = nodes[i];
+    var t = (b.innerText || b.getAttribute('aria-label') || '') + '';
+    if (/^history$/i.test(t.trim()) || /translation\s+history/i.test(t)) { b.click(); return 'ok'; }
+  }
+  return 'no';
+})();
+"""#
+}
+
+private struct TranslateRecentEntry: Codable, Identifiable, Equatable {
+    var id: UUID
+    var source: String
+    var target: String
+    var savedAt: Date
+
+    var sourcePreview: String {
+        String(source.prefix(42))
+    }
+
+    var targetPreview: String {
+        String(target.prefix(42))
+    }
+}
+
+// MARK: - WKWebView
+
+private final class TranslateWebViewClippingContainer: NSView {
+    override var isFlipped: Bool { true }
+}
+
+private struct TranslateWebWidgetView: NSViewRepresentable {
+    @Binding var url: URL
+    var webRef: TranslateWebViewRef
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator(webRef: webRef)
+    }
+
+    func makeNSView(context: Context) -> NSView {
+        let container = TranslateWebViewClippingContainer()
+        container.wantsLayer = true
+        container.layer?.masksToBounds = true
+        container.layer?.cornerRadius = 12
+
+        let config = WKWebViewConfiguration()
+        config.defaultWebpagePreferences.allowsContentJavaScript = true
+        let wv = WKWebView(frame: .zero, configuration: config)
+        wv.translatesAutoresizingMaskIntoConstraints = false
+        wv.focusRingType = .none
+        wv.customUserAgent =
+            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
+        wv.navigationDelegate = context.coordinator
+
+        container.addSubview(wv)
+        NSLayoutConstraint.activate([
+            wv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            wv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            wv.topAnchor.constraint(equalTo: container.topAnchor),
+            wv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        context.coordinator.webView = wv
+        webRef.webView = wv
+        context.coordinator.lastLoadedURLString = url.absoluteString
+        wv.load(URLRequest(url: url))
+        DispatchQueue.main.async {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        return container
+    }
+
+    func updateNSView(_ container: NSView, context: Context) {
+        guard let wv = context.coordinator.webView else { return }
+        let next = url.absoluteString
+        guard context.coordinator.lastLoadedURLString != next else { return }
+        context.coordinator.lastLoadedURLString = next
+        wv.load(URLRequest(url: url))
+    }
+
+    private static func stripWebKitOuterChrome(_ webView: WKWebView) {
+        webView.focusRingType = .none
+        func walk(_ view: NSView) {
+            view.focusRingType = .none
+            view.layer?.borderWidth = 0
+            view.layer?.borderColor = NSColor.clear.cgColor
+            if let scroll = view as? NSScrollView {
+                scroll.borderType = .noBorder
+                scroll.drawsBackground = false
+                scroll.scrollerStyle = .overlay
+            }
+            if let clip = view as? NSClipView {
+                clip.drawsBackground = false
+            }
+            for sub in view.subviews {
+                walk(sub)
+            }
+        }
+        walk(webView)
+    }
+
+    final class Coordinator: NSObject, WKNavigationDelegate {
+        weak var webView: WKWebView?
+        var lastLoadedURLString: String?
+        let webRef: TranslateWebViewRef
+
+        init(webRef: TranslateWebViewRef) {
+            self.webRef = webRef
+        }
+
+        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+            self.webView = webView
+            webRef.webView = webView
+            TranslateWebWidgetView.stripWebKitOuterChrome(webView)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
+                TranslateWebWidgetView.stripWebKitOuterChrome(webView)
+            }
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) {
+                NotificationCenter.default.post(name: .translateWidgetPageLoaded, object: webView)
+            }
+        }
+    }
+}

+ 26 - 5
google_apps/Widgets/WidgetTemplates.swift

@@ -41,6 +41,16 @@ enum KeepWidgetMode: Hashable, Sendable {
     case interactive
 }
 
+enum TranslateWidgetMode: Hashable, Sendable {
+    /// Embedded Google Translate (`WKWebView`): text, voice, conversation, history.
+    case interactive
+}
+
+enum EarthWidgetMode: Hashable, Sendable {
+    /// Embedded Google Earth Web (`WKWebView`): globe, layers, Voyager, projects.
+    case interactive
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
@@ -53,6 +63,10 @@ enum WidgetLayoutMode: Hashable {
     case drive(DriveWidgetMode)
     /// Tiered Google Keep: recent notes or full embedded Keep.
     case keep(KeepWidgetMode)
+    /// Embedded Google Translate: full translator UI in-panel.
+    case translate(TranslateWidgetMode)
+    /// Embedded Google Earth Web: full globe UI in-panel.
+    case earth(EarthWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -244,6 +258,7 @@ enum WidgetTemplates {
                     a("conversation", "Conversation", "bubble.left.and.bubble.right", "/m/"),
                     a("history", "History", "clock", "/m/"),
                 ]),
+                v("translate_large", "Full Translate", .large, false, .translate(.interactive), []),
             ]
         case .search:
             return [
@@ -261,17 +276,13 @@ enum WidgetTemplates {
             ]
         case .earth:
             return [
-                v("earth_small", "Earth", .small, false, .actionsRow, [
-                    a("open", "Open", "globe.americas.fill", "/web/"),
-                    a("projects", "Projects", "folder", "/web/projects/"),
-                    a("search", "Search", "magnifyingglass", "/web/search"),
-                ]),
                 v("earth_medium", "Earth Explorer", .medium, true, .actionsGrid(columns: 2), [
                     a("open", "Open", "globe.americas.fill", "/web/"),
                     a("voyager", "Voyager", "sailboat", "/web/data/"),
                     a("projects", "Projects", "folder", "/web/projects/"),
                     a("search", "Search", "magnifyingglass", "/web/search"),
                 ]),
+                v("earth_large", "Full Earth", .large, false, .earth(.interactive), []),
             ]
         case .shopping:
             return [
@@ -577,6 +588,8 @@ extension WidgetLayoutMode {
         case .gmail(.interactive): true
         case .drive(.interactive): true
         case .keep(.interactive): true
+        case .translate(.interactive): true
+        case .earth(.interactive): true
         default: false
         }
     }
@@ -601,6 +614,10 @@ extension WidgetLayoutMode {
             case .compact: return CGSize(width: 230, height: 260)
             case .interactive: return CGSize(width: 540, height: 580)
             }
+        case .translate(.interactive):
+            return CGSize(width: 560, height: 640)
+        case .earth(.interactive):
+            return CGSize(width: 580, height: 640)
         default:
             return nil
         }
@@ -625,6 +642,10 @@ extension WidgetLayoutMode {
             case .compact: return CGSize(width: 210, height: 230)
             case .interactive: return CGSize(width: 480, height: 440)
             }
+        case .translate(.interactive):
+            return CGSize(width: 480, height: 460)
+        case .earth(.interactive):
+            return CGSize(width: 490, height: 470)
         default:
             return nil
         }