Browse Source

Add tiered Gmail desktop widgets and fix chrome/borders

- Introduce GmailWidgetMode (compact, inbox preview, interactive) and GmailDesktopWidgetView
- Small: unread placeholder, Open Mail, compose/unread; medium: sample rows with actions; large: embedded Gmail with search and chips
- Extract WidgetDeepLinkURL for shared {{u}} and path resolution
- Desktop panel sizing, key-capable panel for interactive tier, preview card sizes
- Reduce rectangular halos: disable panel window shadow, round/mask hosting container, strip WKWebView NSScrollView borders; remove outer SwiftUI strokes/shadows on Gmail and Maps

Made-with: Cursor
huzaifahayat12 3 tháng trước cách đây
mục cha
commit
88247bfb9e

+ 6 - 42
google_apps/Widgets/DesktopWidgetView.swift

@@ -15,7 +15,9 @@ struct DesktopWidgetView: View {
 
     var body: some View {
         Group {
-            if case .interactiveMap = layoutMode {
+            if case .gmail(let gmailMode) = layoutMode {
+                GmailDesktopWidgetView(app: app, mode: gmailMode, gmailAccountSlot: gmailAccountSlot, isPreview: isPreview)
+            } else if case .interactiveMap = layoutMode {
                 MapsInteractiveWidgetView(app: app, widgetSize: size, isPreview: isPreview)
             } else {
                 ZStack {
@@ -50,7 +52,7 @@ struct DesktopWidgetView: View {
                             }
                             gridActions(columns: columns, maxActions: maxActionsForSize)
 
-                        case .interactiveMap:
+                        case .interactiveMap, .gmail:
                             EmptyView()
                         }
                     }
@@ -108,7 +110,7 @@ struct DesktopWidgetView: View {
         case .medium: return 4
         case .large:
             switch WidgetAppProfile.from(app: app) {
-            case .gmail, .drive: return 15
+            case .drive: return 15
             default: return 8
             }
         }
@@ -200,7 +202,7 @@ struct DesktopWidgetView: View {
 
     private func open(action: WidgetAction) {
         guard !isPreview, let base = app.webURL else { return }
-        let target = resolvedURL(base: base, actionPath: action.urlPath, accountSlot: gmailAccountSlot)
+        let target = WidgetDeepLinkURL.resolved(base: base, actionPath: action.urlPath, accountSlot: gmailAccountSlot)
         if target.scheme?.lowercased() == "mailto" {
             NSWorkspace.shared.open(target)
         } else {
@@ -208,44 +210,6 @@ struct DesktopWidgetView: View {
         }
     }
 
-    private func resolvedURL(base: URL, actionPath: String?, accountSlot: Int) -> URL {
-        guard let actionPath, !actionPath.isEmpty else { return base }
-        let substituted = actionPath.replacingOccurrences(of: "{{u}}", with: "\(accountSlot)")
-        if substituted.hasPrefix("http://") || substituted.hasPrefix("https://") {
-            return URL(string: substituted) ?? base
-        }
-        if substituted.lowercased().hasPrefix("mailto:") {
-            return URL(string: substituted) ?? base
-        }
-        if substituted.hasPrefix("/") {
-            var c = URLComponents(url: base, resolvingAgainstBaseURL: false)
-            let pathAndQuery: String
-            let fragmentPart: String?
-            if let hashIdx = substituted.firstIndex(of: "#") {
-                pathAndQuery = String(substituted[..<hashIdx])
-                fragmentPart = String(substituted[substituted.index(after: hashIdx)...])
-            } else {
-                pathAndQuery = substituted
-                fragmentPart = nil
-            }
-            if let qIdx = pathAndQuery.firstIndex(of: "?") {
-                let pathPart = String(pathAndQuery[..<qIdx])
-                let queryPart = String(pathAndQuery[pathAndQuery.index(after: qIdx)...])
-                c?.path = pathPart
-                c?.query = queryPart
-            } else {
-                c?.path = pathAndQuery
-            }
-            if let fragmentPart, !fragmentPart.isEmpty {
-                c?.fragment = String(fragmentPart)
-            } else {
-                c?.fragment = nil
-            }
-            return c?.url ?? base
-        }
-        return URL(string: substituted, relativeTo: base)?.absoluteURL ?? base
-    }
-
     private func isPrimary(_ action: WidgetAction) -> Bool {
         actions.first?.id == action.id
     }

+ 14 - 3
google_apps/Widgets/DesktopWidgetWindowManager.swift

@@ -99,8 +99,8 @@ final class DesktopWidgetWindowManager: ObservableObject {
     private func makePanel(for instance: WidgetInstance, app: LauncherApp, variant: WidgetVariant) -> NSPanel {
         let size = panelSize(for: variant)
         let panel: NSPanel
-        if variant.layoutMode == .interactiveMap {
-            // Key-capable so the embedded Maps search field and controls receive clicks and typing reliably.
+        if variant.layoutMode.needsKeyCapablePanel {
+            // Key-capable so embedded web views and search fields receive clicks and typing reliably.
             panel = InteractiveMapsWidgetPanel(
                 contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
                 styleMask: [.nonactivatingPanel, .borderless],
@@ -124,7 +124,8 @@ final class DesktopWidgetWindowManager: ObservableObject {
         // One level above desktop icons so Finder desktop clicks do not leave our widget behind the desktop layer.
         let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
         panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
-        panel.hasShadow = true
+        // System window shadow is drawn as a rectangular halo; with transparent, rounded content it reads as ugly black lines.
+        panel.hasShadow = false
         panel.isOpaque = false
         panel.backgroundColor = .clear
         panel.isMovableByWindowBackground = true
@@ -149,8 +150,15 @@ final class DesktopWidgetWindowManager: ObservableObject {
         container.translatesAutoresizingMaskIntoConstraints = false
         container.wantsLayer = true
         container.layer?.backgroundColor = NSColor.clear.cgColor
+        container.layer?.borderWidth = 0
+        container.layer?.cornerRadius = 22
+        container.layer?.masksToBounds = true
         container.addSubview(hosting)
 
+        hosting.wantsLayer = true
+        hosting.layer?.borderWidth = 0
+        hosting.layer?.backgroundColor = NSColor.clear.cgColor
+
         NSLayoutConstraint.activate([
             hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
             hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
@@ -183,6 +191,9 @@ final class DesktopWidgetWindowManager: ObservableObject {
                 return CGSize(width: 520, height: 560)
             }
         }
+        if let override = variant.layoutMode.desktopPanelSizeOverride() {
+            return override
+        }
         switch variant.size {
         case .small:
             return CGSize(width: 220, height: 210)

+ 596 - 0
google_apps/Widgets/GmailDesktopWidgetView.swift

@@ -0,0 +1,596 @@
+import AppKit
+import SwiftUI
+import WebKit
+
+// MARK: - Public SwiftUI view
+
+/// Tiered Gmail desktop widget: compact unread + compose, inbox preview, or full embedded Gmail.
+struct GmailDesktopWidgetView: View {
+    let app: LauncherApp
+    let mode: GmailWidgetMode
+    var gmailAccountSlot: Int = 0
+    var isPreview: Bool = false
+
+    private var baseURL: URL { app.webURL ?? URL(string: "https://mail.google.com")! }
+
+    var body: some View {
+        Group {
+            switch mode {
+            case .compact:
+                compactBody
+            case .inboxPreview:
+                inboxPreviewBody
+            case .interactive:
+                interactiveBody
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+    }
+
+    // MARK: - Compact (small)
+
+    @AppStorage("gmailDesktopWidgetUnreadCount") private var unreadCountStored: Int = -1
+
+    private var compactBody: some View {
+        ZStack(alignment: .topLeading) {
+            gmailGradientBackground
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            VStack(alignment: .leading, spacing: 10) {
+                HStack(spacing: 10) {
+                    AppIconView(app: app, size: 32, showAppBackground: false, iconPaddingFactor: 0.08)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(app.name)
+                            .font(.system(size: 14, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.95))
+                        Text("Unread")
+                            .font(.system(size: 10, weight: .semibold))
+                            .foregroundStyle(.white.opacity(0.65))
+                    }
+                    Spacer(minLength: 0)
+                }
+
+                HStack(alignment: .firstTextBaseline, spacing: 6) {
+                    Text(unreadCountStored < 0 ? "—" : "\(unreadCountStored)")
+                        .font(.system(size: 36, weight: .bold, design: .rounded))
+                        .foregroundStyle(.white)
+                        .minimumScaleFactor(0.5)
+                        .lineLimit(1)
+                    Text("messages")
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.7))
+                    Spacer(minLength: 0)
+                }
+
+                Text("Local count only — use Open Mail for your live inbox.")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.52))
+                    .lineLimit(2)
+                    .fixedSize(horizontal: false, vertical: true)
+
+                compactButton(title: "Open Mail", systemImage: "envelope.open", emphasized: false) {
+                    openMailInBrowser()
+                }
+
+                HStack(spacing: 8) {
+                    compactButton(title: "Compose", systemImage: "square.and.pencil", emphasized: true) {
+                        openCompose()
+                    }
+                    compactButton(title: "Unread", systemImage: "envelope.badge", emphasized: false) {
+                        openUnreadSearch()
+                    }
+                }
+            }
+            .padding(14)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.85 : 1)
+    }
+
+    // MARK: - Medium (inbox preview)
+
+    private struct PreviewRow: Identifiable {
+        let id: String
+        let sender: String
+        let subject: String
+        let time: String
+        let searchQuery: String
+    }
+
+    private static let previewRows: [PreviewRow] = [
+        PreviewRow(id: "1", sender: "Alex Chen", subject: "Q4 roadmap — draft review", time: "9:42a", searchQuery: "from:alex"),
+        PreviewRow(id: "2", sender: "Google Alerts", subject: "News: your weekly digest", time: "8:10a", searchQuery: "from:googlealerts"),
+        PreviewRow(id: "3", sender: "HR Benefits", subject: "Open enrollment ends Friday", time: "Yesterday", searchQuery: "from:hr"),
+    ]
+
+    private var inboxPreviewBody: some View {
+        ZStack(alignment: .topLeading) {
+            gmailGradientBackground
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            ScrollView {
+                VStack(alignment: .leading, spacing: 10) {
+                    HStack(spacing: 8) {
+                        AppIconView(app: app, size: 32, showAppBackground: false, iconPaddingFactor: 0.08)
+                        Text("Inbox preview")
+                            .font(.system(size: 14, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.95))
+                        Spacer(minLength: 0)
+                        Text("Sample")
+                            .font(.system(size: 10, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.55))
+                            .padding(.horizontal, 8)
+                            .padding(.vertical, 4)
+                            .background(Capsule().fill(Color.white.opacity(0.12)))
+                    }
+
+                    compactButton(title: "Open Mail", systemImage: "envelope.open", emphasized: false) {
+                        openMailInBrowser()
+                    }
+
+                    Text("Recent messages")
+                        .font(.system(size: 11, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.62))
+
+                    VStack(spacing: 8) {
+                        ForEach(Self.previewRows) { row in
+                            previewRowView(row)
+                        }
+                    }
+                }
+                .frame(maxWidth: .infinity, alignment: .leading)
+                .padding(.bottom, 4)
+            }
+            .padding(14)
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .disabled(isPreview)
+        .opacity(isPreview ? 0.85 : 1)
+    }
+
+    private func previewRowView(_ row: PreviewRow) -> some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(alignment: .top, spacing: 8) {
+                VStack(alignment: .leading, spacing: 3) {
+                    Text(row.sender)
+                        .font(.system(size: 12, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.95))
+                        .lineLimit(1)
+                    Text(row.subject)
+                        .font(.system(size: 11, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.78))
+                        .lineLimit(2)
+                }
+                Spacer(minLength: 0)
+                Text(row.time)
+                    .font(.system(size: 10, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.5))
+            }
+            HStack(spacing: 8) {
+                previewActionButton(title: "Reply", systemImage: "arrowshape.turn.up.left") {
+                    openComposeReply(subject: row.subject)
+                }
+                previewActionButton(title: "Archive", systemImage: "archivebox") {
+                    openGmailSearch(query: row.searchQuery)
+                }
+            }
+        }
+        .padding(10)
+        .background(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .fill(Color.black.opacity(0.28))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 12, style: .continuous)
+                .stroke(Color.white.opacity(0.1), lineWidth: 1)
+        )
+    }
+
+    private func previewActionButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            action()
+        }) {
+            HStack(spacing: 5) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 10, 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.14)))
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview)
+        .allowsHitTesting(!isPreview)
+    }
+
+    // MARK: - Large (interactive)
+
+    @State private var searchText = ""
+    @State private var webURL: URL
+
+    init(app: LauncherApp, mode: GmailWidgetMode, gmailAccountSlot: Int = 0, isPreview: Bool = false) {
+        self.app = app
+        self.mode = mode
+        self.gmailAccountSlot = gmailAccountSlot
+        self.isPreview = isPreview
+        let base = app.webURL ?? URL(string: "https://mail.google.com")!
+        _webURL = State(initialValue: Self.defaultInboxURL(base: base, slot: gmailAccountSlot))
+    }
+
+    private static func defaultInboxURL(base: URL, slot: Int) -> URL {
+        WidgetDeepLinkURL.resolved(base: base, actionPath: "/mail/u/{{u}}/#inbox", accountSlot: slot)
+    }
+
+    private var interactiveBody: some View {
+        Group {
+            if isPreview {
+                interactivePreviewChrome
+            } else {
+                interactiveLiveChrome
+            }
+        }
+    }
+
+    private var interactivePreviewChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: [Color(red: 0.32, green: 0.14, blue: 0.12), Color(red: 0.12, green: 0.14, blue: 0.2)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+            VStack(spacing: 12) {
+                AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text("Gmail inbox")
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.92))
+                Text("Full inbox, search, and folder shortcuts load on your desktop.")
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.65))
+                    .multilineTextAlignment(.center)
+                    .padding(.horizontal, 8)
+            }
+            .padding(16)
+        }
+    }
+
+    private var interactiveLiveChrome: some View {
+        ZStack {
+            RoundedRectangle(cornerRadius: 22, style: .continuous)
+                .fill(Color.black.opacity(0.35))
+
+            VStack(spacing: 0) {
+                VStack(spacing: 8) {
+                    interactiveHeader
+                    interactiveSearchBar
+                    smartShortcutsRow
+                    folderShortcutsRow
+                }
+                .padding(.horizontal, 6)
+                .padding(.vertical, 8)
+                .background(
+                    RoundedRectangle(cornerRadius: 16, style: .continuous)
+                        .fill(Color(red: 0.07, green: 0.08, blue: 0.1))
+                )
+                .zIndex(20)
+                .allowsHitTesting(true)
+
+                GmailWebWidgetView(url: $webURL)
+                    .frame(minHeight: 280)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+                    .zIndex(0)
+            }
+            .padding(10)
+        }
+    }
+
+    private var interactiveHeader: some View {
+        HStack(spacing: 8) {
+            AppIconView(app: app, size: 28, showAppBackground: false, iconPaddingFactor: 0.08)
+            Text("Gmail")
+                .font(.system(size: 14, weight: .bold))
+                .foregroundStyle(.white.opacity(0.95))
+            Spacer(minLength: 0)
+            Button(action: {
+                NSApp.activate(ignoringOtherApps: true)
+                openMailInBrowser()
+            }) {
+                Image(systemName: "envelope.open")
+                    .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 Mail in app browser")
+            Button(action: {
+                NSApp.activate(ignoringOtherApps: true)
+                openCompose()
+            }) {
+                Image(systemName: "square.and.pencil")
+                    .font(.system(size: 13, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .padding(8)
+                    .background(Circle().fill(Color.white.opacity(0.12)))
+            }
+            .buttonStyle(.plain)
+            .help("Compose")
+        }
+    }
+
+    private var interactiveSearchBar: some View {
+        HStack(spacing: 8) {
+            Image(systemName: "magnifyingglass")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.55))
+            TextField("Search mail", text: $searchText)
+                .textFieldStyle(.plain)
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(.white.opacity(0.95))
+                .onSubmit { submitSearch() }
+            Button("Go") {
+                submitSearch()
+            }
+            .font(.system(size: 11, weight: .bold))
+            .foregroundStyle(Color(red: 0.95, green: 0.45, blue: 0.35))
+            .buttonStyle(.plain)
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 8)
+        .background(
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
+                .fill(Color.white.opacity(0.08))
+        )
+    }
+
+    private var smartShortcutsRow: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 8) {
+                shortcutChip("Primary", fragment: "#category/primary")
+                shortcutChip("Social", fragment: "#category/social")
+                shortcutChip("Promotions", fragment: "#category/promotions")
+                shortcutChip("Updates", fragment: "#category/updates")
+                shortcutChip("Forums", fragment: "#category/forums")
+            }
+        }
+    }
+
+    private var folderShortcutsRow: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 8) {
+                shortcutChip("Inbox", fragment: "#inbox")
+                shortcutChip("Starred", fragment: "#starred")
+                shortcutChip("Snoozed", fragment: "#snoozed")
+                shortcutChip("Sent", fragment: "#sent")
+                shortcutChip("Drafts", fragment: "#drafts")
+                shortcutChip("All Mail", fragment: "#all")
+                shortcutChip("Spam", fragment: "#spam")
+                shortcutChip("Trash", fragment: "#trash")
+            }
+        }
+    }
+
+    private func shortcutChip(_ title: String, fragment: String) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            navigateToMailPath("/mail/u/{{u}}/\(fragment)")
+        }) {
+            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)))
+                .overlay(Capsule().stroke(Color.white.opacity(0.14), lineWidth: 1))
+        }
+        .buttonStyle(.plain)
+    }
+
+    // MARK: - Shared chrome
+
+    private var gmailGradientBackground: some View {
+        LinearGradient(
+            colors: [
+                Color(red: 0.42, green: 0.2, blue: 0.18),
+                Color(red: 0.14, green: 0.18, blue: 0.26),
+            ],
+            startPoint: .topLeading,
+            endPoint: .bottomTrailing
+        )
+    }
+
+    private func compactButton(title: String, systemImage: String, emphasized: Bool, action: @escaping () -> Void) -> some View {
+        Button(action: {
+            NSApp.activate(ignoringOtherApps: true)
+            action()
+        }) {
+            HStack(spacing: 6) {
+                Image(systemName: systemImage)
+                    .font(.system(size: 12, weight: .semibold))
+                Text(title)
+                    .font(.system(size: 12, weight: .bold))
+            }
+            .foregroundStyle(emphasized ? Color(red: 0.12, green: 0.12, blue: 0.14) : .white.opacity(0.92))
+            .frame(maxWidth: .infinity)
+            .padding(.vertical, 10)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(emphasized ? Color.white.opacity(0.92) : Color.black.opacity(0.35))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .stroke(Color.white.opacity(emphasized ? 0.35 : 0.14), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+    }
+
+    // MARK: - Navigation
+
+    private func openMailInBrowser() {
+        guard !isPreview else { return }
+        let url = WidgetDeepLinkURL.resolved(
+            base: baseURL,
+            actionPath: "/mail/u/{{u}}/#inbox",
+            accountSlot: gmailAccountSlot
+        )
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openCompose() {
+        guard !isPreview else { return }
+        let url = WidgetDeepLinkURL.resolved(
+            base: baseURL,
+            actionPath: "/mail/u/{{u}}/#inbox?compose=new",
+            accountSlot: gmailAccountSlot
+        )
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openUnreadSearch() {
+        guard !isPreview else { return }
+        let url = WidgetDeepLinkURL.resolved(
+            base: baseURL,
+            actionPath: "/mail/u/{{u}}/#search/is:unread",
+            accountSlot: gmailAccountSlot
+        )
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openComposeReply(subject: String) {
+        guard !isPreview else { return }
+        var c = URLComponents()
+        c.scheme = "https"
+        c.host = "mail.google.com"
+        c.path = "/mail/u/\(gmailAccountSlot)/"
+        c.queryItems = [
+            URLQueryItem(name: "view", value: "cm"),
+            URLQueryItem(name: "fs", value: "1"),
+            URLQueryItem(name: "su", value: "Re: \(subject)"),
+        ]
+        guard let url = c.url else { return }
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func openGmailSearch(query: String) {
+        guard !isPreview else { return }
+        let encoded = query.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlFragmentAllowed) ?? query
+        let path = "/mail/u/{{u}}/#search/\(encoded)"
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: gmailAccountSlot)
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+
+    private func navigateToMailPath(_ path: String) {
+        let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: path, accountSlot: gmailAccountSlot)
+        DispatchQueue.main.async {
+            webURL = url
+        }
+    }
+
+    private func submitSearch() {
+        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return }
+        let encoded = q.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlFragmentAllowed) ?? q
+        navigateToMailPath("/mail/u/{{u}}/#search/\(encoded)")
+    }
+}
+
+// MARK: - WKWebView
+
+private final class GmailWebViewClippingContainer: NSView {
+    override var isFlipped: Bool { true }
+}
+
+private struct GmailWebWidgetView: NSViewRepresentable {
+    @Binding var url: URL
+
+    func makeCoordinator() -> Coordinator {
+        Coordinator()
+    }
+
+    func makeNSView(context: Context) -> NSView {
+        let container = GmailWebViewClippingContainer()
+        container.wantsLayer = true
+        container.layer?.masksToBounds = true
+        container.layer?.cornerRadius = 12
+        container.layer?.borderWidth = 0
+
+        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
+        context.coordinator.lastLoadedURLString = url.absoluteString
+        wv.load(URLRequest(url: url))
+        DispatchQueue.main.async {
+            Self.stripWebKitOuterChrome(wv)
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+            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))
+    }
+
+    /// WebKit embeds an `NSScrollView` that often draws a 1pt dark border around the web area (visible on large widgets).
+    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?
+
+        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+            GmailWebWidgetView.stripWebKitOuterChrome(webView)
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
+                GmailWebWidgetView.stripWebKitOuterChrome(webView)
+            }
+        }
+    }
+}

+ 1 - 7
google_apps/Widgets/MapsInteractiveWidgetView.swift

@@ -45,11 +45,6 @@ struct MapsInteractiveWidgetView: View {
             }
         }
         .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
-        .overlay(
-            RoundedRectangle(cornerRadius: 22, style: .continuous)
-                .stroke(Color.white.opacity(0.12), lineWidth: 1)
-        )
-        .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8)
     }
 
     private var previewChrome: some View {
@@ -495,8 +490,7 @@ private struct MapsWebWidgetView: NSViewRepresentable {
         container.wantsLayer = true
         container.layer?.masksToBounds = true
         container.layer?.cornerRadius = 12
-        container.layer?.borderWidth = 1
-        container.layer?.borderColor = NSColor.white.withAlphaComponent(0.08).cgColor
+        container.layer?.borderWidth = 0
 
         let config = WKWebViewConfiguration()
         config.defaultWebpagePreferences.allowsContentJavaScript = true

+ 42 - 0
google_apps/Widgets/WidgetDeepLinkURL.swift

@@ -0,0 +1,42 @@
+import Foundation
+
+/// Resolves widget action paths (including Gmail `{{u}}` account slots) against an app base URL.
+enum WidgetDeepLinkURL {
+    static func resolved(base: URL, actionPath: String?, accountSlot: Int) -> URL {
+        guard let actionPath, !actionPath.isEmpty else { return base }
+        let substituted = actionPath.replacingOccurrences(of: "{{u}}", with: "\(accountSlot)")
+        if substituted.hasPrefix("http://") || substituted.hasPrefix("https://") {
+            return URL(string: substituted) ?? base
+        }
+        if substituted.lowercased().hasPrefix("mailto:") {
+            return URL(string: substituted) ?? base
+        }
+        if substituted.hasPrefix("/") {
+            var c = URLComponents(url: base, resolvingAgainstBaseURL: false)
+            let pathAndQuery: String
+            let fragmentPart: String?
+            if let hashIdx = substituted.firstIndex(of: "#") {
+                pathAndQuery = String(substituted[..<hashIdx])
+                fragmentPart = String(substituted[substituted.index(after: hashIdx)...])
+            } else {
+                pathAndQuery = substituted
+                fragmentPart = nil
+            }
+            if let qIdx = pathAndQuery.firstIndex(of: "?") {
+                let pathPart = String(pathAndQuery[..<qIdx])
+                let queryPart = String(pathAndQuery[pathAndQuery.index(after: qIdx)...])
+                c?.path = pathPart
+                c?.query = queryPart
+            } else {
+                c?.path = pathAndQuery
+            }
+            if let fragmentPart, !fragmentPart.isEmpty {
+                c?.fragment = String(fragmentPart)
+            } else {
+                c?.fragment = nil
+            }
+            return c?.url ?? base
+        }
+        return URL(string: substituted, relativeTo: base)?.absoluteURL ?? base
+    }
+}

+ 3 - 0
google_apps/Widgets/WidgetPreviewCard.swift

@@ -25,6 +25,9 @@ struct WidgetPreviewCard: View {
                 return CGSize(width: 440, height: 420)
             }
         }
+        if let override = variant.layoutMode.widgetPreviewCardSize() {
+            return override
+        }
         switch variant.size {
         case .small:
             return CGSize(width: 190, height: 190)

+ 54 - 31
google_apps/Widgets/WidgetTemplates.swift

@@ -1,3 +1,4 @@
+import CoreGraphics
 import Foundation
 
 struct WidgetAction: Identifiable, Hashable {
@@ -15,12 +16,23 @@ struct WidgetAction: Identifiable, Hashable {
     }
 }
 
+enum GmailWidgetMode: Hashable, Sendable {
+    /// Unread count (local placeholder) and quick compose.
+    case compact
+    /// Sample inbox rows with reply / archive-style shortcuts.
+    case inboxPreview
+    /// Embedded Gmail (`WKWebView`) with search, category chips, and folder shortcuts.
+    case interactive
+}
+
 enum WidgetLayoutMode: Hashable {
     case iconOnly(title: String)
     case actionsRow
     case actionsGrid(columns: Int)
     /// Embedded Google Maps (`WKWebView`) with search, traffic, and navigation shortcuts.
     case interactiveMap
+    /// Tiered Gmail: compact preview, inbox list, or full embedded inbox.
+    case gmail(GmailWidgetMode)
 }
 
 struct WidgetVariant: Identifiable, Hashable {
@@ -56,37 +68,10 @@ enum WidgetTemplates {
                 ]),
             ]
         case .gmail:
-            // Paths use `{{u}}` for the signed-in account slot (0…3); resolved on the desktop widget.
-            func gm(_ path: String) -> String { path }
-            return [
-                v("gmail_small", "Quick triage", .small, false, .actionsRow, [
-                    a("compose", "Compose", "square.and.pencil", gm("/mail/u/{{u}}/#inbox?compose=new")),
-                    a("unread", "Unread", "envelope.badge", gm("/mail/u/{{u}}/#search/is:unread")),
-                    a("search", "Search", "magnifyingglass", gm("/mail/u/{{u}}/#advanced-search")),
-                ]),
-                v("gmail_medium", "Inbox & search", .medium, true, .actionsGrid(columns: 2), [
-                    a("inbox", "Inbox", "tray", gm("/mail/u/{{u}}/#inbox")),
-                    a("unread", "Unread", "envelope.badge", gm("/mail/u/{{u}}/#search/is:unread")),
-                    a("search", "Search", "magnifyingglass", gm("/mail/u/{{u}}/#search")),
-                    a("compose", "Compose", "square.and.pencil", gm("/mail/u/{{u}}/#inbox?compose=new")),
-                ]),
-                v("gmail_large", "Gmail hub", .large, true, .actionsGrid(columns: 2), [
-                    a("compose", "Compose", "square.and.pencil", gm("/mail/u/{{u}}/#inbox?compose=new")),
-                    a("inbox", "Inbox", "tray", gm("/mail/u/{{u}}/#inbox")),
-                    a("unread", "Unread", "envelope.badge", gm("/mail/u/{{u}}/#search/is:unread")),
-                    a("search", "Search", "magnifyingglass", gm("/mail/u/{{u}}/#search")),
-                    a("starred", "Starred", "star", gm("/mail/u/{{u}}/#starred")),
-                    a("drafts", "Drafts", "doc.plaintext", gm("/mail/u/{{u}}/#drafts")),
-                    a("sent", "Sent", "paperplane", gm("/mail/u/{{u}}/#sent")),
-                    a("spam", "Spam", "exclamationmark.triangle", gm("/mail/u/{{u}}/#spam")),
-                    a("allmail", "All Mail", "archivebox", gm("/mail/u/{{u}}/#all")),
-                    a("scheduled", "Scheduled", "calendar.badge.clock", gm("/mail/u/{{u}}/#scheduled")),
-                    a("attachments", "Attachments", "paperclip", gm("/mail/u/{{u}}/#search/has:attachment")),
-                    a("contacts", "Contacts", "person.crop.circle", "https://contacts.google.com/"),
-                    a("promos", "Promotions", "tag", gm("/mail/u/{{u}}/#category/promotions")),
-                    a("settings", "Settings", "gearshape", gm("/mail/u/{{u}}/#settings/general")),
-                    a("trash", "Trash", "trash", gm("/mail/u/{{u}}/#trash")),
-                ]),
+            return [
+                v("gmail_small", "Unread & compose", .small, false, .gmail(.compact), []),
+                v("gmail_medium", "Inbox preview", .medium, false, .gmail(.inboxPreview), []),
+                v("gmail_large", "Full inbox", .large, false, .gmail(.interactive), []),
             ]
         case .drive:
             return [
@@ -597,3 +582,41 @@ private extension WidgetTemplates {
         WidgetAction(id: id, title: title, systemImage: image, urlPath: path)
     }
 }
+
+extension WidgetLayoutMode {
+    /// Panels that embed interactive web content need to become key so text fields and the web view receive input.
+    var needsKeyCapablePanel: Bool {
+        switch self {
+        case .interactiveMap: true
+        case .gmail(.interactive): true
+        default: false
+        }
+    }
+
+    /// Fixed sizes for tiered Gmail widgets (variant size already matches the tier).
+    func desktopPanelSizeOverride() -> CGSize? {
+        switch self {
+        case .gmail(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 220, height: 220)
+            case .inboxPreview: return CGSize(width: 420, height: 480)
+            case .interactive: return CGSize(width: 540, height: 580)
+            }
+        default:
+            return nil
+        }
+    }
+
+    func widgetPreviewCardSize() -> CGSize? {
+        switch self {
+        case .gmail(let mode):
+            switch mode {
+            case .compact: return CGSize(width: 200, height: 200)
+            case .inboxPreview: return CGSize(width: 380, height: 420)
+            case .interactive: return CGSize(width: 480, height: 440)
+            }
+        default:
+            return nil
+        }
+    }
+}