Ariver
2026-05-16 26e2f4de52c4aef06d1b834fc101d7dfac3b810c
Release TagLauncher 5.7.8 build 583
34 files modified
3 files added
1205 ■■■■ changed files
AppStore_Submission.md 23 ●●●● patch | view | raw | blame | history
AppStore_TODO_5.7.8_583.md 55 ●●●●● patch | view | raw | blame | history
Apptag/ApptagApp.swift 592 ●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 40 ●●●●● patch | view | raw | blame | history
Apptag/Info.plist 4 ●●●● patch | view | raw | blame | history
Apptag/Localization/ar-Najdi.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ar.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/cs.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/da.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/de.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/en.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/es.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/fr.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/id.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/it.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ja.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ko.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ms.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/nb.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/nl.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/nn.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/no.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/pl.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/pt-BR.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ro.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/ru.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/sr-Cyrl.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/sv.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/th.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/tr.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/uk.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/vi.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/zh-Hans.json 2 ●●● patch | view | raw | blame | history
Apptag/Localization/zh-Hant.json 2 ●●● patch | view | raw | blame | history
CHANGELOG.md 86 ●●●●● patch | view | raw | blame | history
plan-deepthink.md 262 ●●●●● patch | view | raw | blame | history
prd-importantback.md 85 ●●●●● patch | view | raw | blame | history
AppStore_Submission.md
@@ -1,6 +1,6 @@
# Apptag — Mac App Store 提审资料
> 版本: 5.7.5 | Bundle ID: com.apptag.launcher | 更新日期: 2026-05-14
> 版本: 5.7.8 | Bundle ID: com.apptag.launcher | 更新日期: 2026-05-15
---
@@ -11,8 +11,8 @@
| **App 名称** | Apptag |
| **副标题** | Tag-Based App Launcher |
| **Bundle ID** | com.apptag.launcher |
| **版本号** | 5.7.5 |
| **Build 号** | 575 |
| **版本号** | 5.7.8 |
| **Build 号** | 583 |
| **SKU** | apptag-mac-001 |
| **主要类别** | Utilities (工具) |
| **次要类别** | Productivity (效率) |
@@ -187,9 +187,20 @@
## 8. What's New (本次版本更新说明)
```
Apptag 5.7.5
Apptag 5.7.8
- Fixed the menu bar icon visibility issue and added a Show Menu Bar Icon preference
- Fixed duplicate overlay keyboard event monitors that could accumulate after repeatedly opening TagLauncher
- Removed obsolete tag reorder fallback handlers that could interfere with click handling after drag sorting
- Reduced unnecessary Dock refreshes when unrelated preferences change
- Made the menu bar icon mandatory and removed the Show Menu Bar Icon preference across all localizations
- Removed unused macOS app-menu items and added a localized Show App List command with symbol-only shortcut glyphs
- Replaced the default macOS Services/Hide menu groups and made the menu bar status item use a fixed visible icon slot
- Replaced the menu bar SF Symbol with a code-drawn template icon so the status item remains visible even if symbol rendering fails
- Improved the General preferences layout for long localized text by using two-column style choices and aligned setting rows
- Refined the app-list style chooser so Flat spans the first row and the remaining four styles form a balanced 2x2 grid
- Reduced the app-list style option height and added compact visual glyphs for flat, masonry, and grid layouts
- Added a symbol-only shortcut hint on the Language, Data, and About preference tabs for Option + Shift + Space
- Fixed the menu bar icon visibility issue
- Fixed Preferences placement when opened above TagLauncher from a fullscreen app Space
- Fixed tag navigation reorder mode so clicking elsewhere exits the shaking state
- Expanded localization support to 29 language variants
@@ -205,7 +216,7 @@
- Added Mac App Store sandbox entitlements and App Store build validation
- Hid Launch at Login controls in sandboxed App Store builds
- Removed deprecated app launch API usage
- Version updated to 5.7.5 (Build 575)
- Version kept at 5.7.8, Build updated to 583
```
---
AppStore_TODO_5.7.8_583.md
New file
@@ -0,0 +1,55 @@
# TagLauncher 5.7.8 (Build 583) App Store 提交 To-do List
> 这个本机 .app/.dmg 是本地构建产物。去 App Store 提交时,最好在那台登录开发者账号的电脑上,用 source/ 里的源码重新用正式证书构建、签名、上传。
## A. 换电脑前确认
- 确认要提交的版本号是 `5.7.8`,Build 是 `583`。
- 带走本文件夹里的 `AppStore_Submission.md`、`CHANGELOG.md`、`TagLauncher.dmg`、`TagLauncher.app`。
- 如果要在另一台电脑重新签名构建,也带走源码快照文件夹或整个项目目录。
- 测试时先退出旧版 TagLauncher,再启动新版,避免菜单栏状态项仍来自旧进程。
- 如果菜单栏图标看不到,先检查 Thaw / Bartender / Ice / Hidden Bar / Dozer 等菜单栏管理软件是否隐藏了 TagLauncher。
## B. 开发者电脑准备
- 登录正确的 Apple Developer 账号。
- 确认 Xcode 已安装,并安装 Command Line Tools。
- 在 Xcode 账号里确认有 Mac App Distribution / Apple Distribution 证书。
- 确认 App Store Connect 里 Bundle ID 是 `com.apptag.launcher`。
- 确认 App Store Connect 已创建或选择 Apptag 的 `5.7.8` 版本。
## C. 正式构建与签名
- 在开发者电脑上用开发者证书重新构建,不要直接提交本机 ad-hoc 签名包。
- 使用 App Store sandbox entitlements:`Apptag/TagLauncher.entitlements`。
- 构建完成后确认 About 页显示 `版本 5.7.8(构建 583)`。
- 确认 `LSMinimumSystemVersion` 是 `15.0`。
- 本地打开新构建的 App,快速检查:
  - `⌥⇧空格` 能呼出应用列表。
  - 设置窗口能在当前 TagLauncher 浮层上方居中显示。
  - 设置页语言切换正常。
  - 通用页排版在英文、法语、意大利语、中文下都不拥挤。
  - 数据页导入/导出分类与布局正常。
## D. App Store Connect 填写
- 复制 `AppStore_Submission.md` 里的 App 描述、关键词、隐私说明和审核备注。
- 隐私标签选择“不收集任何数据”。
- 最低系统版本填写 macOS `15.0`。
- What's New 使用 `AppStore_Submission.md` 第 8 节。
- 如果上传截图,优先展示:
  - 主应用列表视图。
  - 设置页语言切换。
  - 通用页布局设置。
  - 标签编辑/拖动排序。
  - 导入/导出分类与布局。
## E. 上传与提交审核
- 用 Xcode Organizer / Transporter 上传正式签名构建。
- 上传后在 App Store Connect 选择 Build `583`。
- 填完审核信息后提交审核。
- 提交后保留本提交包,方便审核反馈时对照版本。
Apptag/ApptagApp.swift
@@ -13,6 +13,10 @@
            PreferencesView()
        }
        .defaultSize(width: 880, height: 460)
        .commands {
            CommandGroup(replacing: .systemServices) { }
            CommandGroup(replacing: .appVisibility) { }
        }
    }
}
@@ -24,29 +28,37 @@
}
final class AppDelegate: NSObject, NSApplicationDelegate {
    private static let showMenuBarIconKey = "showMenuBarIcon"
    private static let showDockIconKey = "showDockIcon"
    private static let showAppListMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherShowAppListMenuItem")
    private static let showAppListShortcutGlyphs = "⌥⇧␣"
    private var statusItem: NSStatusItem?
    private var overlayWindow: NSWindow?
    private var overlayKeyMonitor: Any?
    private var settingsWindow: NSWindow?    // Track Settings window to keep it above overlay
    private var hotkeyRef: EventHotKeyRef?
    private var isInEditMode = false  // Suppress auto-dismiss during editing
    private var isConfiguringApplicationMenu = false
    private var lastShowDockIcon: Bool?
    static func refreshChromeSettings() {
        (NSApp.delegate as? AppDelegate)?.syncChromeSettings(force: true)
    }
    func applicationDidFinishLaunching(_ notification: Notification) {
        L10n.setup()
        setupDefaultPreferences()
        migrateDefaultGroupName()
        TagDatabase.seedDefaultTags()
        let showDock = UserDefaults.standard.bool(forKey: "showDockIcon")
        NSApp.setActivationPolicy(showDock ? .regular : .accessory)
        setupMenuBar()
        syncChromeSettings(force: true)
        registerHotkey()
        observeOtherWindows()
        observeSettingsClose()
        observeEditMode()
        observeApplicationMenuChanges()
        observeChromeSettings()
        observeLanguageChanges()
        setupLaunchAtLogin()
        configureApplicationMenuWhenAvailable()
    }
    /// Dock icon click → show overlay (same as menubar "Show TagLauncher")
@@ -73,21 +85,27 @@
        // User has set a custom name — keep it
    }
    private func setupDefaultPreferences() {
        if UserDefaults.standard.object(forKey: Self.showMenuBarIconKey) == nil {
            UserDefaults.standard.set(true, forKey: Self.showMenuBarIconKey)
        }
    }
    /// Observe Dock/menu-bar visibility changes so they take effect immediately.
    /// Observe Dock visibility changes so they take effect immediately.
    private func observeChromeSettings() {
        NotificationCenter.default.addObserver(
            forName: UserDefaults.didChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            let show = UserDefaults.standard.bool(forKey: "showDockIcon")
            NSApp.setActivationPolicy(show ? .regular : .accessory)
            self?.setupMenuBar()
            self?.syncChromeSettings()
        }
    }
    private func syncChromeSettings(force: Bool = false) {
        let showDock = UserDefaults.standard.bool(forKey: Self.showDockIconKey)
        let dockChanged = lastShowDockIcon != showDock
        if force || dockChanged {
            NSApp.setActivationPolicy(showDock ? .regular : .accessory)
            lastShowDockIcon = showDock
        }
        if force {
            setupMenuBar()
        }
    }
@@ -98,6 +116,7 @@
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.setupMenuBar()
            self?.configureApplicationMenuWhenAvailable()
        }
    }
@@ -160,35 +179,28 @@
    // MARK: - Menu Bar
    private func setupMenuBar() {
        guard UserDefaults.standard.object(forKey: Self.showMenuBarIconKey) as? Bool ?? true else {
            removeMenuBarItem()
            return
        }
        if statusItem == nil {
            statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        }
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
        guard let statusItem else { return }
        statusItem.isVisible = true
        if let button = statusItem.button {
            let image = makeMenuBarIcon()
            button.image = image
            button.image = makeMenuBarIcon()
            button.imageScaling = .scaleProportionallyDown
            button.imagePosition = .imageOnly
            button.title = image == nil ? "T" : ""
            button.toolTip = "TagLauncher — Tag-based app launcher"
            button.action = #selector(toggleOverlay)
            button.target = self
        }
        let menu = NSMenu()
        menu.addItem(
            NSMenuItem(
                title: "\(tr("menu.show"))  ⇧⌥Space",
        let showItem = NSMenuItem(
            title: showAppListMenuTitle,
                action: #selector(toggleOverlay),
                keyEquivalent: ""
            )
        )
        showItem.target = self
        menu.addItem(showItem)
        menu.addItem(.separator())
        // Version display
@@ -236,31 +248,166 @@
        statusItem.menu = menu
    }
    private func makeMenuBarIcon() -> NSImage {
        let image = NSImage(size: NSSize(width: 18, height: 18))
        image.lockFocus()
        defer { image.unlockFocus() }
        NSColor.black.setFill()
        let tagBody = NSBezierPath()
        tagBody.move(to: NSPoint(x: 5.0, y: 2.2))
        tagBody.line(to: NSPoint(x: 15.6, y: 5.0))
        tagBody.line(to: NSPoint(x: 12.8, y: 15.8))
        tagBody.line(to: NSPoint(x: 2.2, y: 13.0))
        tagBody.close()
        tagBody.fill()
        NSGraphicsContext.current?.compositingOperation = .clear
        NSBezierPath(ovalIn: NSRect(x: 10.9, y: 11.2, width: 3.2, height: 3.2)).fill()
        NSGraphicsContext.current?.compositingOperation = .sourceOver
        image.isTemplate = true
        image.accessibilityDescription = "TagLauncher"
        return image
    }
    private var showAppListMenuTitle: String {
        "\(tr("menu.showAppList"))  \(Self.showAppListShortcutGlyphs)"
    }
    private func observeApplicationMenuChanges() {
        NotificationCenter.default.addObserver(
            forName: NSApplication.didBecomeActiveNotification,
            object: NSApp, queue: .main
        ) { [weak self] _ in
            self?.configureApplicationMenuWhenAvailable(retries: 4)
        }
        NotificationCenter.default.addObserver(
            forName: NSMenu.didAddItemNotification,
            object: nil, queue: .main
        ) { [weak self] notification in
            guard let self,
                  !self.isConfiguringApplicationMenu,
                  let menu = notification.object as? NSMenu,
                  menu === NSApp.mainMenu?.items.first?.submenu
            else { return }
            self.configureApplicationMenuWhenAvailable(retries: 2)
        }
    }
    private func configureApplicationMenuWhenAvailable(retries: Int = 20) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
            guard let self else { return }
            guard NSApp.mainMenu?.items.first?.submenu != nil else {
                if retries > 0 {
                    self.configureApplicationMenuWhenAvailable(retries: retries - 1)
                }
                return
            }
            self.configureApplicationMenu()
            if retries > 0 && self.applicationMenuNeedsCleanup() {
                self.configureApplicationMenuWhenAvailable(retries: retries - 1)
            }
        }
    }
    private func configureApplicationMenu() {
        guard !isConfiguringApplicationMenu else { return }
        guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return }
        isConfiguringApplicationMenu = true
        defer { isConfiguringApplicationMenu = false }
        removeUnusedDefaultItems(from: appMenu)
        upsertShowAppListItem(in: appMenu)
        normalizeSeparators(in: appMenu)
    }
    private func applicationMenuNeedsCleanup() -> Bool {
        guard let appMenu = NSApp.mainMenu?.items.first?.submenu else { return true }
        let hasUnusedDefaultItems = appMenu.items.contains(where: isUnusedDefaultApplicationMenuItem)
        let hasShowAppListItem = appMenu.items.contains {
            $0.identifier == Self.showAppListMenuItemIdentifier
        }
        return hasUnusedDefaultItems || !hasShowAppListItem
    }
    private func removeUnusedDefaultItems(from menu: NSMenu) {
        for item in menu.items.reversed() where isUnusedDefaultApplicationMenuItem(item) {
            menu.removeItem(item)
        }
    }
    private func isUnusedDefaultApplicationMenuItem(_ item: NSMenuItem) -> Bool {
        if let servicesMenu = NSApp.servicesMenu, item.submenu === servicesMenu {
            return true
        }
        return item.action == #selector(NSApplication.hide(_:))
            || item.action == #selector(NSApplication.hideOtherApplications(_:))
            || item.action == #selector(NSApplication.unhideAllApplications(_:))
    }
    private func upsertShowAppListItem(in menu: NSMenu) {
        if let existingItem = menu.items.first(where: { $0.identifier == Self.showAppListMenuItemIdentifier }) {
            configureShowAppListItem(existingItem)
            return
        }
        let item = NSMenuItem()
        item.identifier = Self.showAppListMenuItemIdentifier
        configureShowAppListItem(item)
        if let settingsIndex = menu.items.firstIndex(where: { isSettingsMenuItem($0) }) {
            menu.insertItem(item, at: settingsIndex + 1)
        } else if let firstSeparatorIndex = menu.items.firstIndex(where: { $0.isSeparatorItem }) {
            menu.insertItem(item, at: firstSeparatorIndex)
        } else {
            menu.addItem(item)
        }
    }
    private func configureShowAppListItem(_ item: NSMenuItem) {
        item.title = showAppListMenuTitle
        item.action = #selector(toggleOverlay)
        item.target = self
        item.keyEquivalent = ""
        item.keyEquivalentModifierMask = []
        item.isEnabled = true
    }
    private func isSettingsMenuItem(_ item: NSMenuItem) -> Bool {
        item.action == Selector(("showSettingsWindow:"))
            || item.action == #selector(openPreferences)
            || item.title == tr("menu.preferences")
    }
    private func normalizeSeparators(in menu: NSMenu) {
        var indexesToRemove: [Int] = []
        var previousWasSeparator = false
        for (index, item) in menu.items.enumerated() {
            guard item.isSeparatorItem else {
                previousWasSeparator = false
                continue
            }
            if index == 0 || index == menu.items.count - 1 || previousWasSeparator {
                indexesToRemove.append(index)
            }
            previousWasSeparator = true
        }
        for index in indexesToRemove.reversed() {
            menu.removeItem(at: index)
        }
    }
    private func removeMenuBarItem() {
        guard let statusItem else { return }
        NSStatusBar.system.removeStatusItem(statusItem)
        self.statusItem = nil
    }
    private func makeMenuBarIcon() -> NSImage? {
        let size = NSSize(width: 18, height: 18)
        let image = NSImage(size: size)
        image.lockFocus()
        NSColor.black.setFill()
        let tagPath = NSBezierPath()
        tagPath.move(to: NSPoint(x: 2.5, y: 10.5))
        tagPath.line(to: NSPoint(x: 8.5, y: 16.5))
        tagPath.line(to: NSPoint(x: 15.5, y: 16.5))
        tagPath.line(to: NSPoint(x: 15.5, y: 9.5))
        tagPath.line(to: NSPoint(x: 8.5, y: 2.5))
        tagPath.close()
        tagPath.appendOval(in: NSRect(x: 10.6, y: 12.2, width: 2.6, height: 2.6))
        tagPath.windingRule = .evenOdd
        tagPath.fill()
        image.unlockFocus()
        image.isTemplate = true
        image.accessibilityDescription = "TagLauncher"
        return image
    }
    // MARK: - Overlay Window
@@ -285,17 +432,28 @@
        overlayWindow?.setFrame(screen.frame, display: true)
        overlayWindow?.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
        // Local key monitor: catch Escape while overlay is up
        NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
        installOverlayKeyMonitor()
        overlayWindow?.makeKeyAndOrderFront(nil)
        overlayWindow?.orderFrontRegardless()
    }
    private func installOverlayKeyMonitor() {
        guard overlayKeyMonitor == nil else { return }
        overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
            if event.keyCode == 53 { // Escape
                self?.hideOverlay()
                return nil
            }
            return event
        }
    }
        overlayWindow?.makeKeyAndOrderFront(nil)
        overlayWindow?.orderFrontRegardless()
    private func removeOverlayKeyMonitor() {
        if let monitor = overlayKeyMonitor {
            NSEvent.removeMonitor(monitor)
            overlayKeyMonitor = nil
        }
    }
    private func makeOverlayWindow(on screen: NSScreen) -> NSWindow {
@@ -333,7 +491,11 @@
    private func hideOverlay(force: Bool = false) {
        guard force || !isInEditMode else { return }
        if let settingsWindow, settingsWindow.parent == overlayWindow {
            detachSettingsWindow(settingsWindow)
        }
        overlayWindow?.orderOut(nil)
        removeOverlayKeyMonitor()
        if force {
            overlayWindow = nil
        }
@@ -419,8 +581,8 @@
    /// Settings must always appear centered over the current overlay view and float above it.
    private func prepareSettingsWindow(_ window: NSWindow) {
        window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow")
        let settingsSize = NSSize(width: 880, height: 460)
        window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow")
        window.minSize = settingsSize
        window.maxSize = settingsSize
        if abs(window.frame.width - settingsSize.width) > 0.5 || abs(window.frame.height - settingsSize.height) > 0.5 {
@@ -432,15 +594,18 @@
        if let overlayWindow, overlayWindow.isVisible {
            center(window, over: overlayWindow.frame)
            window.level = NSWindow.Level(rawValue: overlayWindow.level.rawValue + 1)
            attachSettingsWindow(window, to: overlayWindow)
            window.level = overlayWindow.level
        } else if let screen = screenUnderMouse() {
            center(window, over: screen.visibleFrame)
            window.level = .floating
            detachSettingsWindow(window)
        }
        window.collectionBehavior.formUnion([.canJoinAllSpaces, .fullScreenAuxiliary, .moveToActiveSpace])
        var behavior = window.collectionBehavior
        behavior.remove(.canJoinAllSpaces)
        behavior.formUnion([.fullScreenAuxiliary, .moveToActiveSpace])
        window.collectionBehavior = behavior
        window.makeKeyAndOrderFront(nil)
        window.orderFrontRegardless()
        settingsWindow = window
@@ -618,8 +783,9 @@
struct PreferencesView: View {
    private let settingsWindowWidth: CGFloat = 880
    private let settingsContentWidth: CGFloat = 820
    private let settingsLabelWidth: CGFloat = 160
    private let widePickerWidth: CGFloat = 650
    private let generalContentWidth: CGFloat = 720
    private let generalLabelWidth: CGFloat = 190
    private let generalControlWidth: CGFloat = 500
    private let compactPickerWidth: CGFloat = 320
    @AppStorage("tagFontSize") private var tagFontSize: Double = 18
@@ -628,7 +794,6 @@
    @AppStorage("defaultGroupName") private var defaultGroupName = "Other"
    @AppStorage("displayMode") private var displayMode = "flat"
    @AppStorage("hideAppNames") private var hideAppNames = false
    @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
    @AppStorage("showDockIcon") private var showDockIcon = false
    @AppStorage("launchAtLogin") private var launchAtLogin = true
    @State private var selectedLanguage = L10n.currentCode
@@ -702,6 +867,52 @@
            GridItem(.flexible(minimum: 220), spacing: 18, alignment: .top),
        ]
    }
    private var displayModeColumns: [GridItem] {
        [
            GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top),
            GridItem(.flexible(minimum: 220), spacing: 10, alignment: .top),
        ]
    }
    private var displayModeOptions: [(id: String, title: String)] {
        [
            ("flat", tr("settings.flat")),
            ("container", tr("settings.container")),
            ("coloredContainer", tr("settings.coloredContainer")),
            ("gridContainer", tr("settings.gridContainer")),
            ("coloredGridContainer", tr("settings.coloredGridContainer")),
        ]
    }
    private var containerDisplayModeOptions: [(id: String, title: String)] {
        Array(displayModeOptions.dropFirst())
    }
    private func generalSettingRow<Control: View>(
        _ label: String,
        description: String,
        @ViewBuilder control: () -> Control
    ) -> some View {
        HStack(alignment: .top, spacing: 16) {
            Text(label)
                .font(.system(size: 14, weight: .semibold))
                .multilineTextAlignment(.trailing)
                .lineLimit(2)
                .minimumScaleFactor(0.86)
                .frame(width: generalLabelWidth, alignment: .trailing)
                .padding(.top, 5)
            VStack(alignment: .leading, spacing: 6) {
                control()
                    .frame(width: generalControlWidth, alignment: .leading)
                Text(description)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.leading)
                    .fixedSize(horizontal: false, vertical: true)
                    .frame(width: generalControlWidth, alignment: .leading)
            }
        }
        .frame(width: generalContentWidth, alignment: .leading)
    }
    var body: some View {
        ZStack {
@@ -741,6 +952,12 @@
                        }
                        .padding(.vertical, 4)
                    }
                    .frame(maxHeight: 250)
                    Spacer(minLength: 0)
                    ShortcutHintView()
                        .frame(maxWidth: .infinity, alignment: .center)
                        .padding(.bottom, 2)
                }
                .frame(maxWidth: settingsContentWidth, alignment: .leading)
                .tabItem { Label(tr("settings.language"), systemImage: "globe") }
@@ -760,8 +977,10 @@
                                }
                            }
                    }
                    Toggle(tr("settings.showMenuBarIcon"), isOn: $showMenuBarIcon)
                    Toggle(tr("settings.showInDock"), isOn: $showDockIcon)
                        .onChange(of: showDockIcon) { _, _ in
                            AppDelegate.refreshChromeSettings()
                        }
                    Toggle(tr("settings.hideAppNames"), isOn: $hideAppNames)
                }
                .frame(maxWidth: .infinity, alignment: .center)
@@ -770,31 +989,33 @@
                Divider()
                    .padding(.bottom, 16)
                // Two-column layout: each row label (right-aligned) + controls (left-aligned)
                // All pickers and descriptions share the same left edge
                VStack(spacing: 16) {
                    HStack(alignment: .top, spacing: 16) {
                        Text(tr("settings.appListStyle"))
                            .frame(width: settingsLabelWidth, alignment: .trailing)
                        VStack(alignment: .leading, spacing: 4) {
                            Picker("", selection: $displayMode) {
                                Text(tr("settings.flat")).tag("flat")
                                Text(tr("settings.container")).tag("container")
                                Text(tr("settings.coloredContainer")).tag("coloredContainer")
                                Text(tr("settings.gridContainer")).tag("gridContainer")
                                Text(tr("settings.coloredGridContainer")).tag("coloredGridContainer")
                VStack(spacing: 18) {
                    generalSettingRow(tr("settings.appListStyle"), description: tr("settings.flatDesc")) {
                        VStack(spacing: 8) {
                            DisplayModeOptionButton(
                                mode: "flat",
                                title: tr("settings.flat"),
                                isSelected: displayMode == "flat"
                            ) {
                                displayMode = "flat"
                            }
                            .pickerStyle(.segmented)
                            .frame(width: widePickerWidth, alignment: .leading)
                            Text(tr("settings.flatDesc"))
                                .font(.caption).foregroundStyle(.secondary)
                                .fixedSize(horizontal: false, vertical: true)
                            LazyVGrid(columns: displayModeColumns, alignment: .leading, spacing: 8) {
                                ForEach(containerDisplayModeOptions, id: \.id) { option in
                                    DisplayModeOptionButton(
                                        mode: option.id,
                                        title: option.title,
                                        isSelected: displayMode == option.id
                                    ) {
                                        displayMode = option.id
                        }
                    }
                    HStack(alignment: .top, spacing: 16) {
                        Text(tr("settings.tagPosition"))
                            .frame(width: settingsLabelWidth, alignment: .trailing)
                        VStack(alignment: .leading, spacing: 4) {
                            }
                        }
                        .frame(width: generalControlWidth, alignment: .leading)
                    }
                    generalSettingRow(tr("settings.tagPosition"), description: tr("settings.tagPosDesc")) {
                            Picker("", selection: $tagPosition) {
                                Text(tr("settings.left")).tag("left")
                                Text(tr("settings.right")).tag("right")
@@ -802,15 +1023,9 @@
                            }
                            .pickerStyle(.segmented)
                            .frame(width: compactPickerWidth, alignment: .leading)
                            Text(tr("settings.tagPosDesc"))
                                .font(.caption).foregroundStyle(.secondary)
                                .fixedSize(horizontal: false, vertical: true)
                        }
                    }
                    HStack(alignment: .top, spacing: 16) {
                        Text(tr("settings.tagFontSize"))
                            .frame(width: settingsLabelWidth, alignment: .trailing)
                        VStack(alignment: .leading, spacing: 4) {
                    generalSettingRow(tr("settings.tagFontSize"), description: tr("settings.tagFontDesc")) {
                            Picker("", selection: $tagFontSize) {
                                ForEach([16.0, 18.0, 20.0, 22.0, 24.0, 26.0], id: \.self) { size in
                                    Text("\(Int(size))").tag(size)
@@ -818,15 +1033,9 @@
                            }
                            .pickerStyle(.segmented)
                            .frame(width: compactPickerWidth, alignment: .leading)
                            Text(tr("settings.tagFontDesc"))
                                .font(.caption).foregroundStyle(.secondary)
                                .fixedSize(horizontal: false, vertical: true)
                        }
                    }
                    HStack(alignment: .top, spacing: 16) {
                        Text(tr("settings.iconSize"))
                            .frame(width: settingsLabelWidth, alignment: .trailing)
                        VStack(alignment: .leading, spacing: 4) {
                    generalSettingRow(tr("settings.iconSize"), description: tr("settings.iconSizeDesc")) {
                            Picker("", selection: $iconSize) {
                                ForEach([40.0, 48.0, 56.0, 64.0, 72.0, 80.0], id: \.self) { size in
                                    Text("\(Int(size))").tag(size)
@@ -834,14 +1043,10 @@
                            }
                            .pickerStyle(.segmented)
                            .frame(width: compactPickerWidth, alignment: .leading)
                            Text(tr("settings.iconSizeDesc"))
                                .font(.caption).foregroundStyle(.secondary)
                                .fixedSize(horizontal: false, vertical: true)
                        }
                    }
                }
            }
            .frame(maxWidth: settingsContentWidth, alignment: .center)
            .frame(maxWidth: generalContentWidth, alignment: .center)
            .padding()
            .tabItem { Label(tr("settings.general"), systemImage: "gearshape") }
@@ -858,8 +1063,12 @@
            .onAppear { scanApps() }
            // Tab 4: Data
            Form {
                Section {
            VStack(spacing: 0) {
                Spacer(minLength: 96)
                VStack(alignment: .leading, spacing: 8) {
                    Text(tr("settings.backup"))
                        .font(.headline)
                    HStack(spacing: 12) {
                        Button(tr("settings.export")) { exportTags() }
                            .buttonStyle(.bordered)
@@ -869,16 +1078,21 @@
                    Text(tr("settings.backupDesc"))
                        .font(.caption)
                        .foregroundStyle(.secondary)
                } header: {
                    Text(tr("settings.backup"))
                }
                .frame(width: 420, alignment: .leading)
                Spacer(minLength: 42)
                ShortcutHintView()
                    .frame(maxWidth: .infinity, alignment: .center)
                Spacer(minLength: 18)
            }
            .tabItem { Label(tr("settings.data"), systemImage: "externaldrive.fill") }
            .padding()
            // Tab 5: About
            Form {
                Section {
            VStack(spacing: 0) {
                Spacer(minLength: 72)
                    HStack {
                        if let icon = NSImage(named: NSImage.applicationIconName) {
                            Image(nsImage: icon)
@@ -907,8 +1121,12 @@
                                .foregroundStyle(.secondary)
                        }
                    }
                    .padding(.leading, 100)
                }
                .frame(width: 560, alignment: .leading)
                Spacer(minLength: 30)
                ShortcutHintView()
                    .frame(maxWidth: .infinity, alignment: .center)
                Spacer(minLength: 18)
            }
            .tabItem { Label(tr("settings.about"), systemImage: "info.circle") }
            .padding()
@@ -947,3 +1165,153 @@
        }
    }
}
private struct DisplayModeOptionButton: View {
    let mode: String
    let title: String
    let isSelected: Bool
    let action: () -> Void
    var body: some View {
        Button(action: action) {
            HStack(spacing: 7) {
                DisplayModeGlyph(mode: mode, isSelected: isSelected)
                    .frame(width: 24, height: 18)
                Text(title)
                    .font(.system(size: 13, weight: .semibold))
                    .multilineTextAlignment(.center)
                    .lineLimit(2)
                    .minimumScaleFactor(0.82)
                    .foregroundStyle(isSelected ? Color.white : Color.primary)
                    .frame(maxWidth: .infinity)
            }
            .frame(maxWidth: .infinity, minHeight: 34)
            .padding(.horizontal, 9)
            .padding(.vertical, 2)
            .background(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .fill(isSelected ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.18), lineWidth: 1)
            )
            .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
        }
        .buttonStyle(.plain)
        .accessibilityLabel(title)
    }
}
private struct DisplayModeGlyph: View {
    let mode: String
    let isSelected: Bool
    var body: some View {
        switch mode {
        case "flat":
            HStack(spacing: 2) {
                cell(index: 0, width: 5, height: 5, colored: false)
                cell(index: 1, width: 5, height: 5, colored: false)
                cell(index: 2, width: 5, height: 5, colored: false)
            }
        case "gridContainer", "coloredGridContainer":
            let colored = mode == "coloredGridContainer"
            VStack(spacing: 2) {
                HStack(spacing: 2) {
                    cell(index: 0, width: 7, height: 6, colored: colored)
                    cell(index: 1, width: 7, height: 6, colored: colored)
                }
                HStack(spacing: 2) {
                    cell(index: 2, width: 7, height: 6, colored: colored)
                    cell(index: 3, width: 7, height: 6, colored: colored)
                }
            }
        default:
            let colored = mode == "coloredContainer"
            HStack(alignment: .bottom, spacing: 2) {
                cell(index: 0, width: 5, height: 8, colored: colored)
                cell(index: 1, width: 5, height: 14, colored: colored)
                cell(index: 2, width: 5, height: 10, colored: colored)
            }
        }
    }
    private func cell(index: Int, width: CGFloat, height: CGFloat, colored: Bool) -> some View {
        let fill = fillColor(index: index, colored: colored)
        let stroke = isSelected ? Color.white.opacity(0.95) : Color.secondary.opacity(0.35)
        return RoundedRectangle(cornerRadius: 1.8, style: .continuous)
            .fill(fill)
            .overlay(
                RoundedRectangle(cornerRadius: 1.8, style: .continuous)
                    .stroke(stroke, lineWidth: 0.8)
            )
            .frame(width: width, height: height)
    }
    private func fillColor(index: Int, colored: Bool) -> Color {
        if isSelected {
            return Color.white.opacity(colored ? 0.9 : 0.18)
        }
        guard colored else {
            return Color.secondary.opacity(0.12)
        }
        let palette: [Color] = [.green, .purple, .blue, .orange]
        return palette[index % palette.count].opacity(0.78)
    }
}
private struct ShortcutHintView: View {
    var body: some View {
        HStack(spacing: 12) {
            ShortcutKeycap(symbol: "⌥")
            Text("+")
                .font(.system(size: 22, weight: .semibold))
                .foregroundStyle(.tertiary)
            ShortcutKeycap(symbol: "⇧")
            Text("+")
                .font(.system(size: 22, weight: .semibold))
                .foregroundStyle(.tertiary)
            SpacebarKeycap()
        }
        .accessibilityLabel("⌥ ⇧ Space")
        .allowsHitTesting(false)
    }
}
private struct ShortcutKeycap: View {
    let symbol: String
    var body: some View {
        Text(symbol)
            .font(.system(size: 36, weight: .medium))
            .foregroundStyle(.secondary)
            .frame(width: 74, height: 58)
            .background(KeycapBackground())
    }
}
private struct SpacebarKeycap: View {
    var body: some View {
        ZStack {
            KeycapBackground()
            Capsule()
                .fill(Color.secondary.opacity(0.38))
                .frame(width: 72, height: 3)
                .offset(y: 13)
        }
        .frame(width: 190, height: 58)
    }
}
private struct KeycapBackground: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 10, style: .continuous)
            .fill(Color(nsColor: .controlBackgroundColor))
            .overlay(
                RoundedRectangle(cornerRadius: 10, style: .continuous)
                    .stroke(Color.primary.opacity(0.12), lineWidth: 1)
            )
            .shadow(color: .black.opacity(0.10), radius: 8, x: 0, y: 3)
    }
}
Apptag/ContentView.swift
@@ -237,7 +237,6 @@
    @State private var tagReorderFrames: [String: CGRect] = [:]
    @State private var tagNavDragModeActive = false
    @State private var tagNavDragItem: String? = nil
    @State private var tagNavDismissMonitor: Any? = nil
    @State private var tagNavReorderFrames: [String: CGRect] = [:]
    @State private var hoveredContainer: String? = nil  // colored container lift
    // Fixed interaction for "Colorless Container": hover fills persistently; click clears.
@@ -348,12 +347,6 @@
                userInfo: ["active": active]
            )
        }
        .onChange(of: tagNavDragModeActive) { _, active in
            updateTagNavDismissMonitor(active: active)
        }
        .onDisappear {
            removeTagNavDismissMonitor()
        }
    }
    /// Set edit phase with synchronous notification BEFORE state change.
@@ -382,16 +375,6 @@
                sideLayout
            } else {
                topLayout
            }
            if tagNavDragModeActive && tagNavDragItem == nil {
                Color.clear
                    .contentShape(Rectangle())
                    .ignoresSafeArea()
                    .onTapGesture {
                        endTagNavReorder()
                    }
                    .zIndex(1)
            }
            Button {
@@ -1271,8 +1254,6 @@
            .onChanged { value in
                guard canReorderTag(tagName) else { return }
                switch value {
                case .first(true):
                    beginTagNavReorder(tagName)
                case .second(true, let drag?):
                    if tagNavDragItem == nil {
                        beginTagNavReorder(tagName)
@@ -1305,27 +1286,6 @@
        }
        tagNavDragModeActive = false
        tagNavDragItem = nil
        removeTagNavDismissMonitor()
    }
    private func updateTagNavDismissMonitor(active: Bool) {
        if active {
            guard tagNavDismissMonitor == nil else { return }
            tagNavDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in
                guard tagNavDragModeActive, editPhase == .none else { return event }
                endTagNavReorder()
                return nil
            }
        } else {
            removeTagNavDismissMonitor()
        }
    }
    private func removeTagNavDismissMonitor() {
        if let monitor = tagNavDismissMonitor {
            NSEvent.removeMonitor(monitor)
            tagNavDismissMonitor = nil
        }
    }
    private func reorderTagNavItem(at location: CGPoint) {
Apptag/Info.plist
@@ -19,9 +19,9 @@
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>5.7.5</string>
    <string>5.7.8</string>
    <key>CFBundleVersion</key>
    <string>575</string>
    <string>583</string>
    <key>LSMinimumSystemVersion</key>
    <string>15.0</string>
    <key>NSHighResolutionCapable</key>
Apptag/Localization/ar-Najdi.json
@@ -1,5 +1,6 @@
{
  "menu.show": "ورّ TagLauncher",
  "menu.showAppList": "ورّ قائمة التطبيقات",
  "menu.preferences": "التفضيلات…",
  "menu.quit": "خروج",
  "menu.language": "اللغة",
@@ -13,7 +14,6 @@
  "settings.about": "عن التطبيق",
  "settings.launchAtLogin": "يشتغل مع الدخول",
  "settings.showInDock": "إظهار في Dock",
  "settings.showMenuBarIcon": "إظهار أيقونة شريط القوائم",
  "settings.appListStyle": "شكل قائمة التطبيقات:",
  "settings.flat": "مسطح",
  "settings.container": "حاوية بدون لون",
Apptag/Localization/ar.json
@@ -1,5 +1,6 @@
{
  "menu.show": "إظهار TagLauncher",
  "menu.showAppList": "إظهار قائمة التطبيقات",
  "menu.preferences": "التفضيلات…",
  "menu.quit": "إنهاء",
  "menu.language": "اللغة",
@@ -13,7 +14,6 @@
  "settings.about": "حول",
  "settings.launchAtLogin": "التشغيل عند تسجيل الدخول",
  "settings.showInDock": "إظهار في Dock",
  "settings.showMenuBarIcon": "إظهار أيقونة شريط القوائم",
  "settings.appListStyle": "نمط قائمة التطبيقات:",
  "settings.flat": "مسطح",
  "settings.container": "حاوية بلا لون",
Apptag/Localization/cs.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Zobrazit TagLauncher",
  "menu.showAppList": "Zobrazit seznam aplikací",
  "menu.preferences": "Předvolby…",
  "menu.quit": "Ukončit",
  "menu.language": "Jazyk",
@@ -13,7 +14,6 @@
  "settings.about": "O aplikaci",
  "settings.launchAtLogin": "Spustit při přihlášení",
  "settings.showInDock": "Zobrazit v Docku",
  "settings.showMenuBarIcon": "Zobrazit ikonu v řádku nabídek",
  "settings.appListStyle": "Styl seznamu aplikací:",
  "settings.flat": "Plochý",
  "settings.container": "Bezbarvý kontejner",
Apptag/Localization/da.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Vis TagLauncher",
  "menu.showAppList": "Vis appliste",
  "menu.preferences": "Indstillinger…",
  "menu.quit": "Slut",
  "menu.language": "Sprog",
@@ -13,7 +14,6 @@
  "settings.about": "Om",
  "settings.launchAtLogin": "Start ved login",
  "settings.showInDock": "Vis i Dock",
  "settings.showMenuBarIcon": "Vis ikon i menulinjen",
  "settings.appListStyle": "Applistestil:",
  "settings.flat": "Flad",
  "settings.container": "Farveløs beholder",
Apptag/Localization/de.json
@@ -1,5 +1,6 @@
{
  "menu.show": "TagLauncher anzeigen",
  "menu.showAppList": "App-Liste anzeigen",
  "menu.preferences": "Einstellungen…",
  "menu.quit": "Beenden",
  "menu.language": "Sprache",
@@ -13,7 +14,6 @@
  "settings.about": "Über",
  "settings.launchAtLogin": "Beim Anmelden starten",
  "settings.showInDock": "Im Dock anzeigen",
  "settings.showMenuBarIcon": "Menüleistensymbol anzeigen",
  "settings.appListStyle": "App-Listenstil:",
  "settings.flat": "Flach",
  "settings.container": "Farbloser Container",
Apptag/Localization/en.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Show TagLauncher",
  "menu.showAppList": "Show App List",
  "menu.preferences": "Preferences…",
  "menu.quit": "Quit",
  "menu.language": "Language",
@@ -13,7 +14,6 @@
  "settings.about": "About",
  "settings.launchAtLogin": "Launch at login",
  "settings.showInDock": "Show in Dock",
  "settings.showMenuBarIcon": "Show menu bar icon",
  "settings.appListStyle": "App list style:",
  "settings.flat": "Flat",
  "settings.container": "Colorless Container",
Apptag/Localization/es.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Mostrar TagLauncher",
  "menu.showAppList": "Mostrar lista de apps",
  "menu.preferences": "Preferencias…",
  "menu.quit": "Salir",
  "menu.language": "Idioma",
@@ -13,7 +14,6 @@
  "settings.about": "Acerca de",
  "settings.launchAtLogin": "Iniciar al iniciar sesión",
  "settings.showInDock": "Mostrar en el Dock",
  "settings.showMenuBarIcon": "Mostrar icono en la barra de menús",
  "settings.appListStyle": "Estilo de lista de apps:",
  "settings.flat": "Plano",
  "settings.container": "Contenedor sin color",
Apptag/Localization/fr.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Afficher TagLauncher",
  "menu.showAppList": "Afficher la liste des apps",
  "menu.preferences": "Préférences…",
  "menu.quit": "Quitter",
  "menu.language": "Langue",
@@ -13,7 +14,6 @@
  "settings.about": "À propos",
  "settings.launchAtLogin": "Lancer à l'ouverture de session",
  "settings.showInDock": "Afficher dans le Dock",
  "settings.showMenuBarIcon": "Afficher l’icône de la barre des menus",
  "settings.appListStyle": "Style de liste d'applications :",
  "settings.flat": "Liste",
  "settings.container": "Conteneur sans couleur",
Apptag/Localization/id.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Tampilkan TagLauncher",
  "menu.showAppList": "Tampilkan daftar app",
  "menu.preferences": "Preferensi…",
  "menu.quit": "Keluar",
  "menu.language": "Bahasa",
@@ -13,7 +14,6 @@
  "settings.about": "Tentang",
  "settings.launchAtLogin": "Mulai saat masuk",
  "settings.showInDock": "Tampilkan di Dock",
  "settings.showMenuBarIcon": "Tampilkan ikon bilah menu",
  "settings.appListStyle": "Gaya daftar app:",
  "settings.flat": "Datar",
  "settings.container": "Kontainer tanpa warna",
Apptag/Localization/it.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Mostra TagLauncher",
  "menu.showAppList": "Mostra elenco app",
  "menu.preferences": "Preferenze…",
  "menu.quit": "Esci",
  "menu.language": "Lingua",
@@ -13,7 +14,6 @@
  "settings.about": "Informazioni",
  "settings.launchAtLogin": "Avvia al login",
  "settings.showInDock": "Mostra nel Dock",
  "settings.showMenuBarIcon": "Mostra icona nella barra dei menu",
  "settings.appListStyle": "Stile elenco app:",
  "settings.flat": "Elenco",
  "settings.container": "Contenitore senza colore",
Apptag/Localization/ja.json
@@ -1,5 +1,6 @@
{
  "menu.show": "TagLauncherを表示",
  "menu.showAppList": "アプリ一覧を表示",
  "menu.preferences": "設定…",
  "menu.quit": "終了",
  "menu.language": "言語",
@@ -13,7 +14,6 @@
  "settings.about": "概要",
  "settings.launchAtLogin": "ログイン時に起動",
  "settings.showInDock": "Dockに表示",
  "settings.showMenuBarIcon": "メニューバーアイコンを表示",
  "settings.appListStyle": "アプリ一覧スタイル:",
  "settings.flat": "フラット",
  "settings.container": "無色コンテナ",
Apptag/Localization/ko.json
@@ -1,5 +1,6 @@
{
  "menu.show": "TagLauncher 보기",
  "menu.showAppList": "앱 목록 보기",
  "menu.preferences": "환경설정…",
  "menu.quit": "종료",
  "menu.language": "언어",
@@ -13,7 +14,6 @@
  "settings.about": "정보",
  "settings.launchAtLogin": "로그인 시 시작",
  "settings.showInDock": "Dock에 표시",
  "settings.showMenuBarIcon": "메뉴 막대 아이콘 표시",
  "settings.appListStyle": "앱 목록 스타일:",
  "settings.flat": "플랫",
  "settings.container": "무색 컨테이너",
Apptag/Localization/ms.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Tunjukkan TagLauncher",
  "menu.showAppList": "Tunjukkan senarai app",
  "menu.preferences": "Keutamaan…",
  "menu.quit": "Keluar",
  "menu.language": "Bahasa",
@@ -13,7 +14,6 @@
  "settings.about": "Tentang",
  "settings.launchAtLogin": "Mula semasa log masuk",
  "settings.showInDock": "Tunjukkan dalam Dock",
  "settings.showMenuBarIcon": "Tunjukkan ikon bar menu",
  "settings.appListStyle": "Gaya senarai app:",
  "settings.flat": "Datar",
  "settings.container": "Kontainer tanpa warna",
Apptag/Localization/nb.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Vis TagLauncher",
  "menu.showAppList": "Vis appliste",
  "menu.preferences": "Innstillinger…",
  "menu.quit": "Avslutt",
  "menu.language": "Språk",
@@ -13,7 +14,6 @@
  "settings.about": "Om",
  "settings.launchAtLogin": "Start ved innlogging",
  "settings.showInDock": "Vis i Dock",
  "settings.showMenuBarIcon": "Vis menylinjeikon",
  "settings.appListStyle": "App-listestil:",
  "settings.flat": "Flat",
  "settings.container": "Fargeløs beholder",
Apptag/Localization/nl.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Toon TagLauncher",
  "menu.showAppList": "Toon applijst",
  "menu.preferences": "Voorkeuren…",
  "menu.quit": "Stop",
  "menu.language": "Taal",
@@ -13,7 +14,6 @@
  "settings.about": "Over",
  "settings.launchAtLogin": "Start bij inloggen",
  "settings.showInDock": "Toon in Dock",
  "settings.showMenuBarIcon": "Toon menubalkicoon",
  "settings.appListStyle": "App-lijststijl:",
  "settings.flat": "Plat",
  "settings.container": "Kleurloze container",
Apptag/Localization/nn.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Vis TagLauncher",
  "menu.showAppList": "Vis appliste",
  "menu.preferences": "Innstillingar…",
  "menu.quit": "Avslutt",
  "menu.language": "Språk",
@@ -13,7 +14,6 @@
  "settings.about": "Om",
  "settings.launchAtLogin": "Start ved innlogging",
  "settings.showInDock": "Vis i Dock",
  "settings.showMenuBarIcon": "Vis menylinjeikon",
  "settings.appListStyle": "App-listestil:",
  "settings.flat": "Flat",
  "settings.container": "Fargeløs beholder",
Apptag/Localization/no.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Vis TagLauncher",
  "menu.showAppList": "Vis appliste",
  "menu.preferences": "Innstillinger…",
  "menu.quit": "Avslutt",
  "menu.language": "Språk",
@@ -13,7 +14,6 @@
  "settings.about": "Om",
  "settings.launchAtLogin": "Start ved innlogging",
  "settings.showInDock": "Vis i Dock",
  "settings.showMenuBarIcon": "Vis menylinjeikon",
  "settings.appListStyle": "App-listestil:",
  "settings.flat": "Flat",
  "settings.container": "Fargeløs beholder",
Apptag/Localization/pl.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Pokaż TagLauncher",
  "menu.showAppList": "Pokaż listę aplikacji",
  "menu.preferences": "Preferencje…",
  "menu.quit": "Zakończ",
  "menu.language": "Język",
@@ -13,7 +14,6 @@
  "settings.about": "O aplikacji",
  "settings.launchAtLogin": "Uruchamiaj przy logowaniu",
  "settings.showInDock": "Pokaż w Docku",
  "settings.showMenuBarIcon": "Pokaż ikonę na pasku menu",
  "settings.appListStyle": "Styl listy aplikacji:",
  "settings.flat": "Płaski",
  "settings.container": "Bezbarwny kontener",
Apptag/Localization/pt-BR.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Mostrar TagLauncher",
  "menu.showAppList": "Mostrar lista de apps",
  "menu.preferences": "Preferências…",
  "menu.quit": "Sair",
  "menu.language": "Idioma",
@@ -13,7 +14,6 @@
  "settings.about": "Sobre",
  "settings.launchAtLogin": "Iniciar ao fazer login",
  "settings.showInDock": "Mostrar no Dock",
  "settings.showMenuBarIcon": "Mostrar ícone na barra de menus",
  "settings.appListStyle": "Estilo da lista de apps:",
  "settings.flat": "Plano",
  "settings.container": "Contêiner sem cor",
Apptag/Localization/ro.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Afișează TagLauncher",
  "menu.showAppList": "Afișează lista aplicațiilor",
  "menu.preferences": "Preferințe…",
  "menu.quit": "Ieșire",
  "menu.language": "Limbă",
@@ -13,7 +14,6 @@
  "settings.about": "Despre",
  "settings.launchAtLogin": "Pornește la autentificare",
  "settings.showInDock": "Afișează în Dock",
  "settings.showMenuBarIcon": "Afișează pictograma în bara de meniu",
  "settings.appListStyle": "Stil listă aplicații:",
  "settings.flat": "Plat",
  "settings.container": "Container fără culoare",
Apptag/Localization/ru.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Показать TagLauncher",
  "menu.showAppList": "Показать список приложений",
  "menu.preferences": "Настройки…",
  "menu.quit": "Выйти",
  "menu.language": "Язык",
@@ -13,7 +14,6 @@
  "settings.about": "О программе",
  "settings.launchAtLogin": "Запускать при входе",
  "settings.showInDock": "Показывать в Dock",
  "settings.showMenuBarIcon": "Показывать значок в строке меню",
  "settings.appListStyle": "Стиль списка приложений:",
  "settings.flat": "Плоский",
  "settings.container": "Бесцветный контейнер",
Apptag/Localization/sr-Cyrl.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Прикажи TagLauncher",
  "menu.showAppList": "Прикажи листу апликација",
  "menu.preferences": "Подешавања…",
  "menu.quit": "Изађи",
  "menu.language": "Језик",
@@ -13,7 +14,6 @@
  "settings.about": "О програму",
  "settings.launchAtLogin": "Покрени при пријављивању",
  "settings.showInDock": "Прикажи у Dock-у",
  "settings.showMenuBarIcon": "Прикажи икону у траци менија",
  "settings.appListStyle": "Стил листе апликација:",
  "settings.flat": "Равно",
  "settings.container": "Безбојни контејнер",
Apptag/Localization/sv.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Visa TagLauncher",
  "menu.showAppList": "Visa applista",
  "menu.preferences": "Inställningar…",
  "menu.quit": "Avsluta",
  "menu.language": "Språk",
@@ -13,7 +14,6 @@
  "settings.about": "Om",
  "settings.launchAtLogin": "Starta vid inloggning",
  "settings.showInDock": "Visa i Dock",
  "settings.showMenuBarIcon": "Visa ikon i menyraden",
  "settings.appListStyle": "Applistestil:",
  "settings.flat": "Platt",
  "settings.container": "Färglös behållare",
Apptag/Localization/th.json
@@ -1,5 +1,6 @@
{
  "menu.show": "แสดง TagLauncher",
  "menu.showAppList": "แสดงรายการแอป",
  "menu.preferences": "การตั้งค่า…",
  "menu.quit": "ออก",
  "menu.language": "ภาษา",
@@ -13,7 +14,6 @@
  "settings.about": "เกี่ยวกับ",
  "settings.launchAtLogin": "เปิดเมื่อเข้าสู่ระบบ",
  "settings.showInDock": "แสดงใน Dock",
  "settings.showMenuBarIcon": "แสดงไอคอนในแถบเมนู",
  "settings.appListStyle": "รูปแบบรายการแอป:",
  "settings.flat": "แบน",
  "settings.container": "คอนเทนเนอร์ไม่มีสี",
Apptag/Localization/tr.json
@@ -1,5 +1,6 @@
{
  "menu.show": "TagLauncher’ı Göster",
  "menu.showAppList": "Uygulama listesini göster",
  "menu.preferences": "Tercihler…",
  "menu.quit": "Çık",
  "menu.language": "Dil",
@@ -13,7 +14,6 @@
  "settings.about": "Hakkında",
  "settings.launchAtLogin": "Girişte başlat",
  "settings.showInDock": "Dock’ta göster",
  "settings.showMenuBarIcon": "Menü çubuğu simgesini göster",
  "settings.appListStyle": "Uygulama listesi stili:",
  "settings.flat": "Düz",
  "settings.container": "Renksiz Kapsayıcı",
Apptag/Localization/uk.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Показати TagLauncher",
  "menu.showAppList": "Показати список програм",
  "menu.preferences": "Параметри…",
  "menu.quit": "Вийти",
  "menu.language": "Мова",
@@ -13,7 +14,6 @@
  "settings.about": "Про програму",
  "settings.launchAtLogin": "Запускати під час входу",
  "settings.showInDock": "Показувати в Dock",
  "settings.showMenuBarIcon": "Показувати значок у рядку меню",
  "settings.appListStyle": "Стиль списку застосунків:",
  "settings.flat": "Плоский",
  "settings.container": "Безбарвний контейнер",
Apptag/Localization/vi.json
@@ -1,5 +1,6 @@
{
  "menu.show": "Hiển thị TagLauncher",
  "menu.showAppList": "Hiển thị danh sách ứng dụng",
  "menu.preferences": "Tùy chọn…",
  "menu.quit": "Thoát",
  "menu.language": "Ngôn ngữ",
@@ -13,7 +14,6 @@
  "settings.about": "Giới thiệu",
  "settings.launchAtLogin": "Khởi động khi đăng nhập",
  "settings.showInDock": "Hiển thị trong Dock",
  "settings.showMenuBarIcon": "Hiển thị biểu tượng trên thanh menu",
  "settings.appListStyle": "Kiểu danh sách ứng dụng:",
  "settings.flat": "Phẳng",
  "settings.container": "Vùng chứa không màu",
Apptag/Localization/zh-Hans.json
@@ -1,5 +1,6 @@
{
  "menu.show": "显示 TagLauncher",
  "menu.showAppList": "显示应用列表",
  "menu.preferences": "偏好设置…",
  "menu.quit": "退出",
  "menu.language": "语言",
@@ -13,7 +14,6 @@
  "settings.about": "关于",
  "settings.launchAtLogin": "登录时启动",
  "settings.showInDock": "在 Dock 中显示",
  "settings.showMenuBarIcon": "显示菜单栏图标",
  "settings.appListStyle": "应用列表样式:",
  "settings.flat": "平铺",
  "settings.container": "无色容器",
Apptag/Localization/zh-Hant.json
@@ -1,5 +1,6 @@
{
  "menu.show": "顯示 TagLauncher",
  "menu.showAppList": "顯示應用列表",
  "menu.preferences": "偏好設定…",
  "menu.quit": "退出",
  "menu.language": "語言",
@@ -13,7 +14,6 @@
  "settings.about": "關於",
  "settings.launchAtLogin": "登入時啟動",
  "settings.showInDock": "在 Dock 中顯示",
  "settings.showMenuBarIcon": "顯示選單列圖示",
  "settings.appListStyle": "應用程式列表樣式:",
  "settings.flat": "平鋪",
  "settings.container": "無色容器",
CHANGELOG.md
@@ -1,5 +1,91 @@
# Apptag Changelog
## [5.7.8] — 2026-05-15
- 修复 TagLauncher 浮层每次打开都会重复添加 Escape 键本地监听的问题,关闭浮层时会同步释放监听器,避免事件监听累积
- 清理普通应用列表中标签拖动排序的旧兜底逻辑,移除可能吞掉下一次点击的鼠标监听器和不可达透明点击层
- 菜单栏图标改为强制显示,移除设置页里的「显示菜单栏图标」开关,并从全部 29 个语言包同步删除该设置文案
- 清理 macOS 应用主菜单中的 Services / Hide / Hide Others / Show All 默认项,新增「显示应用列表」菜单项
- 顶部应用菜单和状态栏菜单的「显示应用列表」均使用全语言本地化文案,并用 `⌥⇧␣` 图形符号展示快捷键
- 用 SwiftUI `CommandGroup` 从源头替换 macOS 默认 Services/Hide 菜单组,并增加延迟重试清理,避免系统菜单重建后恢复旧菜单项
- 菜单栏状态项改为固定方形长度并强制 `isVisible`,降低图标被系统计算成不可见宽度的风险
- 优化 Dock 显示设置刷新逻辑:只有 `showDockIcon` 变化时才更新系统 activation policy,避免无关设置变更触发全局刷新
- 菜单栏图标改为代码自绘 template 图标,不再依赖 SF Symbol 加载,避免系统符号渲染失败导致图标不可见
- 优化「通用」设置页排版:保留顶部三个开关横排,将应用列表样式改为两列选择块,并统一后续设置行的标签列、控件列和说明文字左边缘
- 微调「应用列表样式」选择区:Flat 单独占满第一行,其余四个样式按两列两行排列,形成更稳定的视觉宽度关系
- 收窄「应用列表样式」选项块高度,并为平铺、瀑布流容器、等高网格增加小图示,帮助用户快速区分布局类型
- 在设置页的「语言」「数据」「关于」三个空白较多的页签增加 `⌥ + ⇧ + 空格键` 图形化快捷键提示,避免影响「通用」和「标签」页的主要设置/编辑区域
- 版本号保持 `5.7.8`,Build 更新为 `583`
- 同步更新 `AppStore_Submission.md` 的版本信息和 What's New
## [5.7.5] — 2026-05-14
- 新增「显示菜单栏图标」设置项,默认开启,并放在「在 Dock 中显示」之前
- 修复菜单栏小图标不显示或被系统符号渲染不稳定的问题,改为自绘 template 状态栏图标
- 修复全屏应用 Space 中打开设置时,设置窗口跑到其他窗口/屏幕的问题:设置窗口作为当前 TagLauncher overlay 的子窗口置顶显示
- 优化设置窗口关闭后 overlay 的焦点恢复,减少设置窗口和主浮层之间的层级错乱
- 为普通应用列表中的标签导航增加拖动排序退出兜底,尝试解决拖动后标签持续抖动的问题
- 版本号更新为 `5.7.5`,Build 更新为 `575`
## [5.7.0] — 2026-05-13
- 扩展语言支持到 29 个语言版本,新增法语、德语、巴西葡萄牙语、泰语、越南语、阿拉伯语、土耳其语、印尼语、捷克语、丹麦语、荷兰语、挪威语、马来语、波兰语、罗马尼亚语、瑞典语等
- 中文保持简体/繁体支持,不增加广东话版本
- 设置页「语言」页签改为两列纵向排版,适配语言数量增加后的空间压力
- 优化语言自动匹配逻辑,支持更多系统语言代码映射
- 修复状态栏菜单项在语言切换后的刷新逻辑,避免重复创建状态栏图标
- 固定设置窗口尺寸为 880×460,减少不同页签之间的高度和宽度跳动
- 版本号更新为 `5.7.0`,Build 更新为 `570`
## [5.6.0] — 2026-05-13
- 修复编辑模式下左侧标签拖动排序失效的问题,改用自定义手势和几何位置检测实现实时排序
- 标签拖动排序增加动态动画反馈,拖动时左侧标签顺序和右侧对应标签容器同步变化
- 普通应用列表视图中的顶部/左侧/右侧标签导航支持长按拖动排序
- 标签排序逻辑覆盖全部 5 种应用列表样式,保证标签导航顺序和应用分组布局一致
- 修复导入/导出分类与布局功能,失败时显示明确错误提示
- App Store 沙盒构建下隐藏 Launch at Login 控件,避免沙盒环境中不可用功能误导用户
- 移除已弃用的应用启动 API,统一使用现代 `NSWorkspace.OpenConfiguration`
- 新增 App Store 沙盒 entitlements,支持 App Store 构建验证
- 版本号更新为 `5.6.0`,Build 更新为 `560`
## [5.5.0] — 2026-05-11
- 新增设置页第一个页签「语言」,原有「通用 / 标签 / 数据 / 关于」整体后移
- 支持在设置页直接切换语言,菜单和设置面板实时同步刷新
- 语言切换时增加 macOS 风格的全局刷新过渡图标,降低界面瞬间重建的突兀感
- 设置窗口默认宽度扩展到 880pt,并在当前 TagLauncher 应用列表视图中央浮动置顶
- 多屏使用时,设置窗口会优先居中显示在当前 TagLauncher overlay 所在屏幕
- 数据页按钮文案由「导出 tag / 导入 tag」改为「导出分类与布局 / 导入分类与布局」,并同步所有已支持语言
- 增加旧版非沙盒数据到 App Store 沙盒容器的数据迁移逻辑,降低更新后标签和排序丢失风险
- 新增 App Store 提审资料文档,记录版本信息、隐私说明、截图建议和审核备注
- 版本号更新为 `5.5.0`,Build 更新为 `550`
## [5.2.0] — 2026-05-11
- 重做 App 拖动预览实现,改为 CALayer / AppKit 预览层方式,提升拖动过程的稳定性和跟手感
- 优化拖动预览在 overlay 和全屏 Space 中的显示层级,减少拖动图标被遮挡或丢失的情况
- 保持拖动投放逻辑与分类容器绑定,继续支持移动/复制到目标标签
- 版本号更新为 `5.2.0`,Build 更新为 `520`
## [5.1.12] — 2026-05-11
- 新增 `AppDragCoordinator`,集中管理 App 图标拖动预览、投放目标注册和拖放完成回调
- 优化 App 图标网格项尺寸计算,减少隐藏/显示 App 名称时的布局抖动
- 新增 `TagGroupView`,拆分标签分组容器视图逻辑,降低主视图复杂度
- 优化标签数据库导入/导出和排序数据结构处理
- 调整图标资源生成流程,删除旧 `AppIcon.iconset`,使用压缩后的 `icon-icns.icns` 降低包体体积
- 补充应用描述相关本地化文案
- 版本号更新为 `5.1.12`,Build 更新为 `512`
## [4.0.7] — 2026-05-11
- 调整应用列表布局和 App 启动逻辑,修复部分网格容器视图下点击 App 后启动/关闭 overlay 不稳定的问题
- 更新 9 个既有语言的视图名称和说明文案
- 完善 `build.sh`,强化构建流程中的 bundle 结构、资源复制、权限设置、strip 和签名步骤
- 新增 `make_dmg.sh`,支持生成 DMG 发布包
- 版本号更新为 `4.0.7`,Build 更新为 `407`
## [4.0.0] — 2026-05-10
- 新增「无色网格容器」和「彩色网格容器」两种 App 列表视图样式
plan-deepthink.md
New file
@@ -0,0 +1,262 @@
> 2026.0511.18:02已创建:plan-deepthink.md
>
> 加入了一个小章节:
>
> macOS Backward Compatibility
>
> 内容包括:
>
> - 是否可能兼容 macOS 10.15 的结论
> - 当前主要阻碍
> - 编译探测结果
> - 推荐路线
> - 未来迁移任务清单
>
> 后续关于未来版本、长期功能、架构想法、兼容性计划,都可以继续追加到这个文件里。当前这个文件还未提交进 git。
# Apptag Future Development Notes
This document is the shared parking lot for future-facing ideas, deeper product thoughts, and technical plans that should not interrupt the current release flow.
When a new future idea comes up, add it here first so it can be revisited, compared, and prioritized later.
## macOS Backward Compatibility
### Question
Can Apptag eventually support older macOS versions, especially macOS 10.15 Catalina?
### Current Conclusion
Supporting macOS 10.15 is theoretically possible, but it is not a simple deployment-target change. The current codebase is built around newer SwiftUI and AppKit APIs, so macOS 10.15 support would require a dedicated compatibility pass and likely a separate branch.
For the near-term App Store release, keeping the current macOS 15.0 minimum is safer.
### Main Compatibility Blockers
- The build currently targets only Apple Silicon:
  - `arm64-apple-macosx15.0`
  - macOS 10.15 only runs on Intel Macs, so a 10.15-compatible release would need an `x86_64-apple-macosx10.15` build, ideally packaged as a universal app when also supporting Apple Silicon.
- The app currently uses the SwiftUI app lifecycle:
  - `@main struct TagLauncherApp: App`
  - `Settings { PreferencesView() }`
  - This lifecycle is macOS 11+, so macOS 10.15 would require returning to an AppKit lifecycle with `NSApplicationDelegate`, manually managed preferences windows, and `NSHostingView`.
- The UI uses many SwiftUI APIs that are not available on macOS 10.15, including:
  - `LazyVGrid`
  - `LazyVStack`
  - `@AppStorage`
  - `Label`
  - `ProgressView`
  - Newer `.onChange` signatures
  - `.foregroundStyle`
  - `.ultraThinMaterial` / `.ultraThickMaterial`
  - `.controlSize`
  - `symbolRenderingMode`
- Several system APIs would need fallbacks:
  - `NSImage(systemSymbolName:)` is macOS 11+.
  - `UniformTypeIdentifiers` / `UTType.plainText` should be replaced or wrapped for older systems.
  - `NSOpenPanel.allowedContentTypes = [.json]` should fall back to `allowedFileTypes = ["json"]`.
  - `NSScreen.safeAreaInsets` should be guarded or removed for 10.15, where notch handling is not relevant.
### Compile Probe Result
A direct compile probe with:
```bash
swiftc -o /private/tmp/TagLauncher-compat-check \
  -framework AppKit \
  -framework SwiftUI \
  -framework Carbon \
  -sdk "$(xcrun --sdk macosx --show-sdk-path)" \
  -target arm64-apple-macosx10.15 \
  Apptag/*.swift
```
failed immediately on macOS 14+ API usage:
```text
'onChange(of:initial:_:)' is only available in macOS 14.0 or newer
```
That is only the first visible blocker; more availability issues would appear after fixing it.
### Recommended Path
- Do not block the 5.5.0 App Store release on macOS 10.15 compatibility.
- If broader compatibility becomes a product goal, create a dedicated branch such as `compat/macos-10.15`.
- Consider an intermediate target first:
  - macOS 13 or 14: likely lower effort.
  - macOS 10.15: larger migration, because it requires lifecycle, layout, storage-binding, symbol, drag/drop, and file-panel compatibility work.
### Possible Migration Tasks
- Add universal build support:
  - `x86_64-apple-macosx10.15`
  - `arm64-apple-macosx11.0+`
  - merge with `lipo` or migrate to an Xcode project / Swift Package build flow.
- Replace SwiftUI app lifecycle with an AppKit-compatible entrypoint.
- Wrap or replace newer SwiftUI APIs with compatibility components.
- Replace SF Symbol dependencies with bundled vector or PNG assets for older systems.
- Add availability guards for file panels, screen safe areas, app launching, and drag/drop type identifiers.
- Maintain a compatibility test checklist on an actual macOS 10.15 Intel machine or VM.
## AI-Assisted App Categorization
### Product Goal
Help users create a useful TagLauncher category layout with very low effort. The user should be able to describe the desired tag names in natural language, then receive a proposed tag/category layout for the apps installed on their Mac.
### Version Plan
#### TagLauncher 6.0.0: Local Category Creator
Ship a local-only version first. This version does not use a server and does not perform intelligent app assignment.
Goal:
- let the user speak or type desired category/tag names
- parse the input into short tag chips
- create those tags locally
- append them to `tagOrder`
- leave app assignment to the existing manual/editing workflow
Why this belongs in 6.0.0:
- no backend dependency
- no LLM cost
- no upload/privacy review burden
- validates the entry point, wording, parsing, and user workflow before adding AI
- gives users immediate value even before intelligent classification exists
Suggested interaction:
1. User opens "Create Categories" from Preferences > Tags.
2. User types or dictates a messy list such as "工作 设计 写作 娱乐 系统工具".
3. The app parses it into chips.
4. The user can rename/delete/reorder chips.
5. The app creates missing tags locally and preserves existing tags.
6. The app shows a short success state and points the user back to edit mode for app assignment.
Implementation notes:
- Use macOS dictation into a regular text field; no custom audio handling in 6.0.0.
- Accept separators such as newline, comma, Chinese comma, semicolon, slash, bullet, and spaces for short CJK phrases.
- Trim whitespace and punctuation.
- Reject empty labels and duplicates.
- Warn when labels are too long.
- Do not delete or overwrite existing tags.
- Assign colors by cycling through `TagColor.allIndices`.
- Save through the existing `TagDatabase.save` path.
#### Later Version: AI Categorization Draft
After 6.0.0 proves the local category-creation flow, add the server-backed AI draft feature.
### Recommended AI Version
Do not make the first version fully automatic. Use a trust-building flow:
1. User opens an "AI Categorize" entry from Preferences > Tags or the overlay edit mode.
2. App shows a compact dialog with:
   - a text input for category names or natural-language intent
   - suggested starter chips such as Work, Design, Writing, Development, Study, Entertainment, Utilities
   - a clear privacy note explaining that app names, paths, bundle identifiers, and the user's requested category names will be uploaded only after confirmation
3. User confirms upload.
4. Server returns a draft JSON plan.
5. App displays a preview:
   - new tags
   - tag order
   - each app's proposed tag assignment
   - conflicts and uncertain apps
6. User can apply, edit, or cancel.
7. Applying the plan writes through `TagDatabase.save`, after creating a local backup snapshot so the change can be undone.
### Client Data To Send
Send only the minimum needed for classification:
- app display name
- bundle identifier
- install path category, for example `/Applications`, `/System/Applications`, `~/Applications`
- current tags, if any
- user-provided desired category names or intent
- current app language
Avoid uploading icons, full user paths beyond the app path already needed for local mapping, unrelated files, or usage history.
### Server Strategy
The backend does not need a heavy agent. It can be a small stateless API:
- `POST /v1/categorization-plans`
- validates input
- normalizes category names
- calls one lightweight LLM with a strict JSON schema
- validates the returned JSON
- returns a draft plan with confidence and reasons
Use a provider-adapter layer so the app is not locked to one model vendor. For the first version, the model should output only structured JSON; no tool use, no browsing, no remote code execution.
### Cost Strategy
This is a low-token, single-turn classification problem. A typical request can stay small by sending app names, bundle IDs, and 5-15 desired categories. The cost-effective path is:
- start with a cheap mini/flash/lite model
- use strict JSON schema output
- retry once with a stronger model only if validation fails or confidence is low
- cache common app classification hints server-side by bundle identifier
- optionally pre-classify well-known bundle IDs locally before calling the model
### Output Plan Shape
The server should return a draft, not a final overwrite:
```json
{
  "version": 1,
  "tags": [
    { "name": "Design", "color": 1 }
  ],
  "tagOrder": ["Design"],
  "assignments": [
    {
      "path": "/Applications/Figma.app",
      "appName": "Figma",
      "tags": ["Design"],
      "confidence": 0.95,
      "reason": "Design/prototyping tool"
    }
  ],
  "unassigned": [],
  "warnings": []
}
```
The local app converts this draft into the existing `TagDatabase.Store` format:
- `tags`: tag name to color
- `tagOrder`: ordered category list
- `appTags`: app path to tag-name array
### Interaction Notes
- Best entry point: Preferences > Tags, plus a small "AI Categorize" button in overlay edit mode.
- Keep the first dialog small and discoverable, not a big chatbot.
- Let users paste messy text; parse it into chips before upload.
- Limit category names to short labels and warn on overly long names.
- Never silently replace the user's existing layout. Show a before/after preview.
- Add a one-click undo after applying.
### Privacy And App Store Notes
- Explicit upload confirmation is required.
- The privacy copy should say exactly what is uploaded.
- Add a local-only fallback: if the user declines upload, the app can still create empty tags from the typed list.
- Update App Store privacy answers if the server feature ships.
### Future Enhancements
- Built-in local rules for common apps by bundle identifier.
- User style presets: by work type, by life area, by frequency, by project, by app category.
- Voice input using macOS dictation into the same text field, so the app does not need to handle audio processing in v1.
- A later on-device model experiment if privacy becomes a key selling point, but this should not block the server-based MVP.
prd-importantback.md
New file
@@ -0,0 +1,85 @@
# Important Design Notes
This document records important product/design implementation ideas that should be easy to recover later.
## View 2: Colorless Container
The colorless container view groups apps by tag into independent frosted-material cards.
Layout logic:
- Use a masonry/waterfall layout.
- Calculate how many columns fit in the available overlay width.
- Estimate each tag card's height from its app count, current icon size, and card width.
- Place each next tag group into the currently shortest column.
- Each card uses a transparent background over `.ultraThinMaterial`, with only a subtle border by default.
- When the user hovers or clicks a tag/card, the corresponding card is filled with that tag's color at low opacity, creating a temporary focus state.
- Tag navigation hover/click scrolls to the matching card and also activates the same color fill.
Design intent:
- Keep the whole view visually light and non-color-dominant.
- Use color only as an interaction/focus signal.
- Let groups with different app counts occupy natural heights, so dense groups do not force empty space in neighboring groups.
## View 4: Colorless Grid
The colorless grid view also groups apps by tag into frosted-material cards, but aligns cards into row-based grid tracks instead of waterfall columns.
Layout logic:
- Choose a preferred number of grid tracks per row based on available width:
  - 3 tracks on wide screens
  - 2 tracks on medium screens
  - 1 track on narrow screens
- For each row, test span patterns:
  - 3-track row: `1+1+1`, `1+2`, `2+1`, or `3`
  - 2-track row: `1+1` or `2`
  - 1-track row: `1`
- Pick the lowest-cost pattern by estimating how many icon rows each card needs.
- Cards in the same row share the same fixed icon-row height, so their bottoms align.
- Inside each card, apps are manually split into rows and empty cells are filled with clear placeholders to preserve grid alignment.
- Like colorless container view, the card is transparent over `.ultraThinMaterial` by default and only receives low-opacity tag color on hover/click focus.
Design intent:
- Preserve the clarity of grouped cards while making rows visually orderly.
- Avoid the uneven bottom edges of masonry when the user wants a stricter grid feeling.
- Keep the "colorless by default, colored only on focus" interaction model consistent with View 2.
## View 3: Colored Container
The colored container view uses the same masonry/waterfall layout as View 2, but each tag card is always tinted with its tag color.
Interaction logic:
- The card background is always filled with the tag color at low opacity over `.ultraThinMaterial`.
- Hovering a card raises visual emphasis with shadow and a slight scale-up.
- Unlike the colorless container view, clicking the card does not toggle a persistent fill state because the card is already colored.
- Tag navigation hover/click still scrolls to the matching card, but the key visual distinction comes from the card's permanent tag color.
- App drag/drop behavior remains the same: dropping an app onto a card moves or copies it into that tag group.
Design intent:
- Make tag grouping immediately visible without requiring hover.
- Use color as a persistent category marker.
- Keep the masonry layout's compactness while making group identity stronger than View 2.
## View 5: Colored Grid
The colored grid view uses the same row-aligned grid layout as View 4, but each tag card is always tinted with its tag color.
Interaction logic:
- Cards are arranged with the View 4 grid-row algorithm: adaptive track count, span pattern selection, and equal fixed icon-row height per row.
- Each card background is always filled with the tag color at low opacity over `.ultraThinMaterial`.
- Hovering a card adds shadow and a slight scale-up to show focus.
- Clicking does not toggle a fill state because the colored background is permanent.
- Empty cells are still inserted inside each card to keep icon rows aligned.
- App drag/drop behavior remains the same as other container views.
Design intent:
- Combine the stronger category recognition of colored cards with the orderly rhythm of the grid layout.
- Make dense app libraries easier to scan by preserving row alignment.
- Use hover only for focus emphasis, not for revealing color.