# AGENTS > **New agent: read this first.** > > **What this app is**: Tungsten Edge 钨极 is a window-oriented bottom taskbar for macOS, designed to replace the system Dock. Multi-window apps normally split into separate window chips, with deliberate app-level entries for Finder, messaging apps, kept apps (user-chosen「在程序坞中保留」), and compatibility fallbacks. It also includes a drawer for stashed apps and a pinned-folder zone. Minimum deployment target: macOS 12. > > Product state and decisions live in the owner's Obsidian vault: > `/Users/caye/Documents/Obsidian Vault/Projects/macos-dock-cc-v2/`, entry note (homepage) `00 当前进度.md` — the checkpoint map with clickable todo nodes; todo cards live in `03 待办与想法/待办/`. > > This file is only for engineering guardrails that should not be rediscovered or reverted. Active repo-local references live in `Docs/`; historical notes live in `Docs/Archive/`. ## Source Of Truth - Product state / decisions: Obsidian entry note. - Engineering hard constraints: this file. - Platform quirks: `Docs/05-known-platform-quirks.md`. - Rollback ledger (executable revert commands + verification state): `Docs/23-rollback-ledger.md`; keep it updated alongside the Obsidian checkpoint map. - Trust model history: `Docs/Archive/Engineering/19-taskbar-trust-incident.md`, `Docs/Archive/Engineering/20-inventory-first-taskbar-trust.md`. - Focus debug history: `Docs/22-window-focus-flicker-debugging.md`. - Idle-period background polling (window lift / frontmost / badge) — measured frequencies, CPU shares, hard constraints, and the reviewed-and-corrected throttling designs: `Docs/26-idle-performance-polling.md`. The frontmost-poll and window-lift throttling were **rejected by the owner** (2026-07-31, perceptible latency); that file is a design archive, not a backlog. - Finder P0 records: `Docs/Archive/Engineering/17-finder-p0-implementation.md`, `Docs/Archive/Engineering/18-real-sample-finder-findings.md`. - The project is released under **GPL-3.0-or-later** (`LICENSE`, standard GPLv3 text). Packaging must copy `LICENSE` into **both** `.app/Contents/Resources/` and the DMG root; the bundle copy has to happen **before** `codesign` (adding files to a signed bundle breaks the signature). This is a license-conveyance requirement, not cosmetics — do not drop it when rewriting `Scripts/package_release.sh`. `Resources/Info.plist` carries the matching `NSHumanReadableCopyright`. - Public packaging is currently **ad-hoc signed and unnotarized** (`Scripts/package_release.sh`). A completed Developer ID signing + notarization pipeline and the preview-build script are still parked on the abandoned dev line `codex/release-v0.6.6` — see the "封存在废弃开发线上的资产" section of `Docs/23-rollback-ledger.md` before rebuilding either from scratch. The companion Accessibility onboarding is **no longer parked**: it was rebuilt on mainline under new owner constraints (2026-07-29) and is *not* a copy of `26371b8` — see "Accessibility Permission Onboarding And Recovery" below. That branch still must not be deleted (it uniquely keeps `0b74fa6` / `5e2e82e` reachable). ## Taskbar Trust And Placement - This app is an inventory-first bottom taskbar for real user-operable windows. Do not return to broad bottom-up CG/AX admission. - Minimize, hide, and temporary CG disappearance do not release a strip slot. Only true close releases it. - Do not reintroduce held-slot TTL or "expire then return to tail" as the default placement rule. - Filter system internals, widgets, app extensions, transparent/fake surfaces before any keep-slot or disappeared retention. - **Process death is decided only by `ProcessLiveness.isAlive` (POSIX `kill(pid, 0)`)**, never by `NSRunningApplication(processIdentifier:) == nil` — that API resolves through LaunchServices and transiently returns nil for live processes. A false positive removes the whole `AppEntry` **and every seat under it** through the bulk path in `reconcile()`, which bypasses the per-seat loop's grace periods, phantom protection, and release logging; `scanNonAdmittedApps()` then re-admits the same pid with fresh seat tokens, so `StripOrderStore`'s 5s absence grace renders old and new tokens together — one window, two chips. The check was already fixed once in the old pipeline (`5f57a75`) and lost in the `ef50008` rewrite — keep it in the shared type (`Platform/AppTracking/ProcessLiveness.swift`), not inlined. Only `kill` return 0 = alive; `errno == ESRCH` = dead; `EPERM` and all other errno = alive (conservative). Never fall back to `NSRunningApplication`, `NSWorkspace`, or other LaunchServices APIs for liveness. - The `reconcile()` dead-PID fallback batch-deletion path must record `seatReleased(reason: .processGone)` for each seat it drops. That path produces no other trace, and "seats created with no matching release" is what made the duplicate-chip root cause expensive to find. It builds an `InventorySeatReleaseSnapshot` from the `AppEntry`'s `windowOrder` and records one `seatReleased` payload per seat **before** removing the entry; fields not sampled in that path (AX/CG/context) are nil/omitted, never faked as 0/false. Identity is `pid + seatToken`. - `AppTracker` is the sole window-inventory authority: it seeds from `NSWorkspace` regular apps, reads `AXWindows` per app, and owns seat state. The leftover old-pipeline types are not instantiated by the app — do not wire them back in (replacement history: `Docs/Archive/Engineering/24-guardrail-provenance.md` §3). - Inventory reads keep the 100ms per-app AX messaging timeout (seed probes and `scanNonAdmittedApps`). Slow/hung apps are skipped at seed and picked up by the post-launch scan rounds; CG full-list presence — not AX absence — decides whether a window still exists. - `seedRunningApps` subscribes workspace notifications **before** seeding so launch/exit events during seed are not lost. Seed probes use `inventoryWindows(forPID:messagingTimeout:)` with env `DOCK_SEED_AX_TIMEOUT_MS` (0 = legacy no-timeout, other = ms override). Probed windows are admitted directly via `reconcileSeats(preloadedEligible:)` without a second untimed AX read. Keep kill switch `DOCK_SEED_AX_TIMEOUT_MS=0`. - `start()` schedules four rounds of `scanNonAdmittedApps()` at 0.5/1/2/4s post-launch to catch apps that were slow to respond during seed. Scan admission is the pure, unit-tested `ScanAdmissionDecision`, and three rules are load-bearing: (a) admit only when the **eligible-filtered** set is non-empty — a non-empty raw `AXWindows` list is not evidence, and a false admission is *permanent* because `reconcile()` only removes dead processes while a live app with zero seats keeps projecting its `app-*` fallback chip forever (real case: Tailscale, 2 raw AX windows / 0 eligible); (b) the timed probe's windows go straight into `reconcileSeats(preloadedEligible:)` — never a second untimed AX read on the main thread; (c) process state *and* process generation are re-checked in the main-thread callback, where generation is `pid + POSIX start time (ProcessLiveness.startTime) + bundleID`, never `NSRunningApplication.launchDate` (it can be nil, and both fallbacks — admit anyway, or reject forever — are wrong). Keep the filter inside `ScanAdmissionDecision.prepare` and consume the `Prepared.eligible` it returns: a decision type that takes a pre-computed count cannot catch the "passed `snaps.count` again" regression. - CG signals (full window list, on-screen set) may prove, retain, or veto seats but must never create them; seats are created only from eligible AX windows. - `DOCK_INVENTORY_FIRST_ENABLED` / `DOCK_AX_ADMISSION_MODE` are defunct — do not promise or rely on them. Working kill switches: `DOCK_SEED_AX_TIMEOUT_MS=0`, `DOCK_SKYLIGHT_FOCUS=0`. - Long-gap duplicate prevention is **history, not current code** — do not cite it as an existing behavior. Seat continuity today follows the active cgID; when that cgID leaves AX, a same-frame window may take over the seat only when exactly one existing seat claims that frame (`seatsAtFrame(seatKey) == 1`) — candidate uniqueness is *not* checked. `TabFoldDecision` / shadow-tab pool / phantom healing only handle **minimized native-tab splitting** (`TabFoldDecision.swift:55` returns `.newSeat` for every unclaimed `min=false` window); they are not general duplicate prevention. Restoring the title + nearby-frame merge needs an owner decision plus a pure unit-tested decision type — never an inline heuristic — and must reckon with "two overlapping independent windows legitimately get separate seats", where a wrong merge loses a chip (worse than an extra one). Background, and why no incident so far justifies it: `Docs/Archive/Engineering/24-guardrail-provenance.md` §1. ## App Rules - Finder always keeps a persistent strip slot. `seedRunningApps` adds Finder even when all Finder windows are closed. - Closed-window Finder uses `app-com.apple.finder`; clicking it opens the home directory. Never plan hide/minimize for this persistent `app-*` chip. - Process-death reconcile must not remove Finder's app entry. Finder process existence alone does not imply a concrete Finder window. - Concrete Finder folder windows remain window-level items when title/frame are available. If a specific Finder target cannot be captured, do not fall back to whole-app activation. - Finder minimize feedback accepts either `minimized` or temporary `disappeared`. - Finder window content preview must not rely on AX folder-path attributes. Treat AX document/URL as opportunistic only; current reliable path is AppleEvents enumeration matched by Finder window title + frame, and ambiguous/no matches must fail. - Finder AppleEvents parsing and title+frame matching live in `FinderAppleEventMatcher` with unit coverage. `FinderWindowContentsReader` should stay the I/O layer for AX lookup, Automation permission, and AppleScript execution. - Feishu window-level handling is opportunistic. If samples are weak, titles generic, or frontmost AX unreliable, fall back to one stable app-level item. - Messaging-zone **auto-registration requires capability, not just resemblance** (owner 2026-07-21): `MessagingAppStore.autoRegister` admits an app only when it matches the whitelist / social-networking category **and** currently has a title-identifiable main window (pure, unit-tested `MessagingZoneAdmission`; `AppDelegate` feeds it by combining `runtime.$snapshot` into the auto-register sink — the process store alone has no titles). Rationale: the zone renders one chip *as* the app's main window card, so an app whose main window can never be identified gets a launcher chip that only wakes and never collapses, and its window also renders in the live zone (chip appears twice). Apple 信息 (`com.apple.MobileSMS`) is the permanent case — a Mac Catalyst app whose window title is a UIScene session id (e.g. `10691420171100425`, subrole `AXDialog`), so `titleMatchesAppName` can never succeed; terminals like Ghostty trip the same gate via the social category. Registration is **once-and-for-all**: the persisted list is itself the "has qualified" record and members are never re-tested — re-testing every round would drop 微信 out of the zone whenever its main window is closed (only 「笔记」 left) and re-add it on reopen, flapping the zone and breaking the owner's daily "main window closed → click zone icon to wake" flow. Manual `mark` deliberately bypasses the gate. Already-persisted members are **not** retro-cleaned (cannot reconstruct why a member joined; retro-eviction risks destroying user config) — an existing 信息 membership is cleared by unchecking its 标记为消息应用 item. - Do **not** "fix" an unidentifiable main window by falling back to "the app has exactly one window → that is the main window". Tried 2026-07-21 and reverted: 微信 with its main window closed leaves exactly one window (「笔记」), which the fallback absorbs into the zone chip, so that chip stops reopening 微信's main window — it breaks the owner's daily flow. The capability gate above is the accepted answer. - Messaging identity is a **zone label only** (owner 2026-07-18 unified model): it decides an app shows in the messaging zone; post-exit visibility is decided by kept, unified with every other app. Messaging and kept coexist — first mark (manual `markMessaging` or auto `autoRegisterMessaging`) seeds kept so the default look is unchanged, but the user may un-check keep to let a messaging app vanish on exit and reappear when it runs. `AppMembershipProjection.visibleMessagingIDs = (messaging − drawer) ∩ (running ∪ kept)` (`partitioned()` uses it; the old "persistent, no running/snapshot gate" rule is gone). **Drawer still wins** (priority `drawer > messaging > live`): a messaging app stashed in the drawer hides from the zone (shows in the drawer); dragging it back restores the zone. mark / unmark / auto-register **never change drawer placement** — position changes only by drag. The `.messagingApp` no-main-window branch handles running-no-window (`isRunning:true`, tap → `reopenMainWindow`) and not-running (`isRunning:false`, `onLaunch: runtime.beginLaunch`, never `reopenMainWindow`); its menu carries 在程序坞中保留 alongside the checked 标记为消息应用 item. ## Window Identity And Actions - Native tabs use a **single-seat** model: one physical window = one seat = one chip. Background native tabs are not separate strip items. - Seat identity is `tabgrp--s` from a monotonic counter. Never derive stable identity from `cgWindowID`. - `WindowRecord.id` may be `cgw-`, but chip identity is `groupID = seat.token`; action target is the current active cgID. - Tab switch may adopt a new active cgID into the same seat only when exactly one seat claims the same frame. - Tear-out keeps the old-frame seat for the new active tab; the moved old active cgID becomes a fresh seat. - Minimized multi-tab windows may expose all tabs as eligible AX windows. Fold decision lives in pure `TabFoldDecision` (unit-tested), four levels: seat membership history (`formerCgIDs`), then shadow-tab pool, then exact frame, then `min=true` + off-screen + same size. Do not make geometry or the placed seat's min flag a hard precondition again. - `formerCgIDs` membership is **session-local** (wiped on every dock restart) — never rely on it alone for fold correctness; the shadow pool is the restart-safe layer. Hygiene is load-bearing against cgID reuse: record on tab-switch adoption only (tear-out expulsion must NOT record), purge on window destroy (`purgeFromSeatHistories`) and by intersection with the CG full list on every reconcile. - Shadow-tab pool (`AppEntry.shadowTabCgIDs`): ids present in the CG layer-0 list for the pid but absent from AXWindows — the unique signature of order-out background tabs (real windows stay in AXWindows whether visible, minimized, hidden, or on another Space; verified live against ChatGPT/Finder/Ghostty 2026-07-13). Verdicts must use the **previous** round's pool (the minimize burst floods AX in the current round); ids leave the pool on true close or on appearing in AX with `min=false` (never on `min=true` appearance); pool update is skipped when AX returned zero windows while CG still has some (hung app); pool folding needs ≥1 placed seat and a `min=true` candidate (tear-out landing must still split a card). - Phantom-seat healing (`PhantomSeatDecision`, unit-tested): a seat may be released from the min/hidden retention branch only when ALL five gates pass — never seen `min=false` in AX this session (`everSeenVisible`, protects Safari-style windows that leave AX when minimized), AX-absent ≥ `phantomReapGrace` (10s), cgID still in CG, the AX read saw windows, and ≥1 sibling seat currently AX-present (a lone seat never heals). Do not loosen these gates; healing exists because seed-time minimized tab groups can split (no history, no pool) and the phantoms are otherwise held forever by min retention. - The `[tabfold]` split-point and `[tabheal]` prints are permanent anomaly-path diagnostics (zero output on normal paths). Do not remove them as "诊断遗留" — the 871305d cleanup left the 2026-07-13 recurrence with no forensic evidence. - Persistent window-inventory diagnostics are compiled in but default **off**. Enable persistently with `defaults write com.caye.macosdockcc.v2 InventoryLog -bool YES`; `DOCK_INVENTORY_LOG=1/0` overrides UserDefaults. Logs stay local at `~/Library/Logs/com.caye.macosdockcc.v2/window-inventory.jsonl`. Seat lineage must use stable `pid + seatToken`, never active cgID. `absenceEpisodeID` is diagnostic-only and must begin/clear exactly with `minAbsentSince`; `phantomHeld` deduplicates by `pid + seatToken + absenceEpisodeID`, writing again only when hold reasons change or a new episode begins. - A strip chip id is a stable identity token, not necessarily actionable. All strip show/hide/minimize/toggle calls must use `item.actionWindowID`. - `StripItem.pid`, `StripItem.cgWindowID`, and `StripItem.bounds` are current representative live facts for action/preview targeting only. Never use them as chip identity or persistent order keys. - Drawer actions are app-centric and must not use strip chip ids for window-level toggle. ## Focus And Action Planning - Minimizing the frontmost focused window A1 of multi-window app A should return focus to the previous other app B, not sibling A2. - Only fire that background activation path when the target is the frontmost app's focused AX window. Right-click-minimizing a non-focused sibling must not steal focus. - `postSkyLightWindowFocus` is the shared focus core. Its private byte layout is load-bearing; do not simplify or gate fallback on nonzero private return codes. - Early focus applies only to `.activateWindow`, cross-app, visible active/inactive windows, using snapshot `record.cgWindowID` before handle capture. - Minimized restore is restore-then-switch, never switch-early. Exclude `.minimized` from early focus; after restore, immediately call `postSkyLightWindowFocus` with the snapshot wid, then set `kAXMainAttribute=true`. - Action-decision paths must not use `NSWorkspace.frontmostApplication`; read `NSRunningApplication(processIdentifier:)?.isActive` fresh. - Keep kill switch `DOCK_SKYLIGHT_FOCUS=0`. - Optimistic state predicts **status only** for show/hide style actions and clears on snapshot confirmation or timeout. Do not re-add predicted `isAppFrontmost`. - The chip tap pulse is view-local acknowledgment only. It must not feed planner state or any frontmost decision. - `IntentFeedbackState.update` must write **only when the phase actually changes**. `reconcile` re-runs its verdict on already-settled entries every round, so an unconditional write re-stamps `updatedAt` to now forever — the entry never reaches its 1.5s retention and the dictionary never empties. The old always-on 0.5s timer hid this completely; with the timer running on demand (`FeedbackTickPolicy`, unit-tested) it means the timer can never stop and the whole optimization silently does nothing. But freeze **same-phase re-stamping only — never skip all terminal entries**: `AccessibilitySource.minimize` reads `kAXMinimizedAttribute` back immediately after pressing the button, and minimize is animated, so a successful action is routinely recorded as failure and must be upgraded by a later snapshot. The `failure → success` path is load-bearing (`FeedbackTickPolicyTests` locks both halves). Related fact, so nobody re-derives it: `feedbackEntriesByWindowID`'s only consumer is the unwired `ContentView` debug console — the strip never reads it, which is why its unconditional 2Hz publish was pure waste (idle CPU 2.60% → 1.40% once it stopped invalidating the whole taskbar twice a second). ## Window Lift Avoidance (最大化避让) - Maximized-window avoidance lifts the frontmost visibleFrame-filling window's bottom edge above the taskbar (clearance 2pt). Pure decisions live in `WindowLiftAvoidance` (unit-tested), I/O in `WindowLiftAvoidanceController`; AX geometry writes exist **only** in `AXWindowReader.setSize/setFrame` with settable checks, post-write verification, and rollback — do not simplify or add a second write path. - Gating is 常驻 + visible (`!edgeAutoHideEnabled && visibilityState.isVisible`) and must not be loosened. Kill switches: `DOCK_WINDOW_LIFT=0` (feature off), `DOCK_WINDOW_LIFT_ANIM=0` (instant-write fallback), `DOCK_WINDOW_LIFT_TRACE=1` (session diagnostics). - `reduce` stays pure; time enters only via event `at` parameters. The time-scale rule — maximized reappearing within `appReassertWindow` of settle = app reassert (one relift then abandon), later = a fresh user session — is the cure for the L↔M zoom-memory deadlock (our lift poisons the system zoom's remembered frame; the window then toggles native↔target and never produces an external frame). Do not remove it; `abandonedAt` must never be refreshed by observations or abandoned never expires. - Stall ≠ failure in the write loop: a readback equal to the pre-write frame means the window has not caught up (1x external displays apply late; the first eased frame is ~2pt, right at the verification tolerance) — skip the frame and continue. Intermediate frames use integral sizes and no auto-rollback; the final write retries a bounded number of times. Removing this re-breaks non-Retina displays deterministically. - Standoff protection: one relift per session, standoff rounds capped, capped sessions decay to a slow retry cadence (never a permanent lock), and rounds heal after the lift stays stable past the reassert window. - Session key `pid + cgWindowID` is per-episode only (cgWindowID reuse); keep the CG full-list prune. Context switch (screen change / leaving 常驻) restores still-lifted unmodified windows to their native frame. - Accepted boundaries: inactive with menu-bar auto-hide (window fills screen.frame → classified fullscreen → bar hidden); only the active app's frontmost window lifts; the first zoom click on a lifted window may bounce to full maximize once (system zoom memory, cannot intercept). ## Drag, Drawer, And Ordering - Do not use SwiftUI `.onDrag` / `NSItemProvider` or AppKit `beginDraggingSession` for local strip, drawer, or folder chip drags. The visual carrier is owned by `DragController`. - Real file drag-out/in is exempt: file grid cells may use system drag payloads, and SwiftUI file `.onDrop` destinations are allowed. - Strip reorder uses one `"strip"` coordinate space for chip frames, cursor location, and floating copy. - Chip frames are read via `.background` GeometryReader, not `.overlay`. Keep `grabOffset`, mouse-up fallback, and vanished-window cleanup. - `DragController` remains the single authority for monitors, carrier `NSPanel`, coordinate conversion, drop decisions, and idempotent teardown. - Carrier position and cross-panel hit testing use `NSEvent.mouseLocation` screen coordinates. - Timers used during drag must be added to `.common` run-loop mode. - Drawer is app-centric: one bundleID = one `LauncherChip`. Drawer click is app-level frontmost->hide, otherwise unhide/open; not-running->launch. - Drawer visibility is the pure `AppMembershipProjection.visibleDrawerIDs` decision: drawer placement filtered by running **or** kept **or** messaging membership. An unchecked ordinary drawer app remains in placement and order while stopped, but is hidden until it runs again. - Drawer two zones are partitioned by process state from `RunningApplicationStore`: upper zone = running visible entries (bright + white dot); lower zone = non-running kept or messaging entries (gray, no dot). `isLaunchingWithoutWindow` gating still applies. - `DrawerOrderStore` is the persistent ordering layer keyed by bundleID and synced over the full `DrawerStore` placement set, including currently hidden members. Reorder follows `MessagingAppStore.reorder`: move visible ids in the full array so hidden members keep their relative order. - Drawer reorder is same-zone only. Cross-divider drops are meaningless. Capsule preview must use the same visible-drawer projection followed by `DrawerOrderStore` order; never render raw `DrawerStore` order directly. - `DragPayload` uses strip id = stable chip token, drawer id = bundleID, folder id = folder path, messaging id = bundleID. - Messaging-zone chips are draggable: in-zone reorder persists to `MessagingAppStore.bundleIDs` (`reorder` operates on the full array so hidden members keep relative positions). Frames report into the separate `MessagingChipFramePreferenceKey` — never merge messaging ids into `chipFrames`. - Messaging reorder (like drawer reorder) is driven by `onChange(globalLocation)` (`updateMessagingReorder`), never by the chip's own `DragGesture.onChanged` — SwiftUI cancels the gesture after the first reorder moves the chip. - `.messaging` drop zones equal `.strip` (capsule + open drawer body). The strip itself is never a `.messaging` drop target; releases on shelf/folder zone/live zone/desktop are no-ops. Spring-load and `isOverStashZone` accept `.messaging` alongside `.strip`. ## Strip And Drawer Conversion - `canStash` rejects only missing bundleID and `com.apple.finder`. App-level fallback chips can be stashed. - Strip-to-drawer drop zone is visible capsule content plus small tolerance, and drawer content only while open. Do not use full shadow frame as the hit zone. - Strip-into-open-drawer converts on enter, reverts on exit, and commits only on release inside. Keep enter/exit hysteresis; do not restore placeholder cells or resize-per-hover insertion. - Drawer placement and 「在程序坞中保留」 are orthogonal. Dragging is the only placement change; the checked menu item only toggles `KeptAppStore` and must never move an app between the strip and drawer. 收进抽屉 / 移出抽屉 must not reappear in any menu. - Cross-panel conversion state is the single `DragController.conversion: CrossPanelConversion?` enum (stripToDrawer / drawerToStrip / messagingToDrawer / drawerToMessaging) — original payload + rollback snapshot, no parallel boolean flags. Mutation order in every convert/revert: set `conversion` and flip/restore `draggingPayload` **before** touching stores, so member-vanish watchers exempt by payload source (no cancel race). - Drag conversions are **symmetric placement transactions** and preserve kept state: strip→drawer adds drawer placement on convert and removes it on revert; capsule drop adds placement; drawer→strip removes placement on enter/release and restores it on revert. Messaging→drawer and drawer→messaging likewise mutate drawer placement only, leaving messaging identity untouched. `cancelDrag` must rollback any uncommitted transaction via the same revert paths before teardown. - Drawer-to-strip modes come from pure `DragConversionPlan.drawerDragOutMode` (unit-tested; messaging check must precede the real-window check): Finder / not-running messaging → reject; running messaging → releaseToMessaging; running with real windows → unstash (existing precise landing); not-running/no-real-window → keepPlacement. `keepPlacement` means stage the stable app-level fallback / kept-placeholder id at the chosen strip position; it preserves the existing kept flag and never enables keep. Reject is the only branch that does nothing — a messaging member never takes the fallback unstash on strip drop (`DragConversionPlan.endAction` gates it). - releaseToMessaging triggers on entering the **messaging-zone range** (union of messaging chip frames + 8pt enter / 24pt exit hysteresis; 56pt strip-head fallback when the zone is empty), not on leaving the drawer body. Leaving the range or entering a drop zone reverts to the drawer. - One drawer icon represents the app's whole window-chip block; land all chips contiguously in current display order. `keepPlacement` uses a single-element block `["app-\(bundleID)"]` when no window cards exist. - Drawer-to-strip landing goes through staged placement consumed inside `StripOrderStore.sync(current:appKeyOf:)`. The sync fallback resolves kept placeholder ids when no window cards match the bundleID. - Freeze strip width during converted cross-panel drags and release the clamp only on commit or revert. ## Kept Apps - A kept app does **not** absorb live windows: while running with real windows it shows ordinary window chips; only when exited (or running with no real window) does it collapse to a single app-level icon that stays in place (gray + click-to-relaunch when exited). The icon lives in the live zone and can be freely dragged/reordered like window chips. - Finder must never enter kept state. Reject `com.apple.finder` both when loading `KeptAppStore` and when adding through any menu/action path. - Kept state is an exit-retention flag, not placement. It may coexist with drawer placement **and with messaging identity** (owner 2026-07-18 unified model), and `AppMembershipController.setKept(_:enabled:)` mutates only `KeptAppStore` with no messaging guard. Messaging entries **do** expose the keep checkbox now; kept alone (not messaging) decides post-exit visibility in every zone. First messaging registration (manual or auto) seeds kept once; a later user un-check must not be reopened by rescans. Finder is still rejected from kept / drawer / messaging. Startup repair is `reconcileInvalidMemberships()` — it clears only Finder's illegal drawer/messaging memberships, no kept/messaging reconciliation. - `KeptAppStore` migration is keyed only by existence of `keptAppBundleIDsV3`: fresh installs must write an empty V3 array, and an existing empty V3 array means migration already ran. First migration seeds from `keptAppBundleIDsV2` when that key exists (its order first, messaging appended); otherwise it folds `keptAppBundleIDs` + `pinnedAppBundleIDs` + `drawerBundleIDs` + messaging. Messaging names come from `authoritativeMessagingNames`: if `messagingBundleIDsV2` exists — even as an explicit empty array — read only it, else fall back to `messagingBundleIDs`; **never a union**. Finder is rejected. Frozen read-only so a code rollback reads the exact pre-upgrade list: `keptAppBundleIDsV2`, `keptAppBundleIDs`, `pinnedAppBundleIDs`, `messagingBundleIDs`, `messagingOptOutBundleIDs`. Live writable, *not* legacy keys: `keptAppBundleIDsV3`; `messagingBundleIDsV2` and `messagingOptOutBundleIDsV2` (owned by `MessagingAppStore` — `KeptAppStore` never touches either opt-out key); `drawerBundleIDs` (owned by `DrawerStore`, read by `KeptAppStore` only as a migration seed). Key matrix mirrors `Docs/23-rollback-ledger.md`. - `.keptApp` projection has two sources, both rendered as `LauncherChip` with `RunningApplicationStore` running dot/gray/hidden state: (a) unrunning kept apps → placeholder injection by `DockStripView` (id `"app-\(bid)"`); (b) running kept apps whose only snapshot entry is `isAppLevelFallback` → that entry is re-typed to `.keptApp` in the strip projection (id unchanged from the snapshot's `app-*` fallback token). The id `"app-\(bid)"` matches `AppTracker.rebuildSnapshot()`'s no-window fallback token — this is the position-retention lifeline. - Clicking a running kept app with no real non-fallback window must unhide/reopen it. Running kept apps with real windows use app-level frontmost->hide and background->show behavior. - Kept-app actions do not write window-level optimistic state or predicted frontmost state. Any immediate acknowledgment stays view-local, and app-active decisions read a fresh `NSRunningApplication`. - Position retention: on app exit, window-card ids enter the 5s grace period in `StripOrderStore`; the `app-*` placeholder appears the same frame. `StripOrdering.reconcile` inserts the placeholder after the app's rightmost window card using sticky appKey memory. Sticky appKey is pruned to `current ∪ liveOrder` keys after each sync. `persistableLiveOrder` saves `tabgrp-*` + kept `app-*` only; `kern.boottime` guard discards the entire order on machine restart. Cold-start placeholders land at the live zone head. - Absent rank-anchor (影子滑动 fix): `StripOrderStore.reconciled` (render path) and `sync` (side-effect path) **must share the same absent rank-anchor computation** (`absentRankAnchors`) — the render frame runs *before* `sync` stamps `absentSince`, so if only `sync` anchors, the new `app-*`/`tabgrp-*` tail-inserts on the first frame and the spring drags it back into place (a shadow slide). Load-bearing rules: (a) the anchor universe is `liveOrder`, **not** `absentSince.keys` — a just-vanished id whose `absentSince` is still `nil` counts as elapsed=0, i.e. in-grace; (b) already-stamped ids anchor only while inside the 5s grace, expired ones must not; (c) `reconciled` stays **pure** — it reads `liveOrder`/`absentSince`/now but only `sync` may write `absentSince`; (d) anchors position new ids next to same-app siblings then are filtered out of the visible output (users see only real `current`). `app-*` placeholder appKeys are backfilled from the id (`app-com.foo` → `com.foo`) so exit (`tabgrp-*`→`app-*`) and reopen (`app-*`→`tabgrp-*`) first frames both find the sibling. Regression coverage: `KeptAppPositionTests` (pre-sync 首帧 group) + `MessagingWindowOrderingTests`. - Messaging pop-out windows land at the **live-zone head** (owner 2026-07-12 #4): `StripOrdering.reconcile` takes `headPreferredKeys` — a new (unremembered) window with no live-zone sibling whose appKey is in that set inserts at head (after existing head-preferred windows, keeping their relative order) instead of the tail. `StripOrderStore.reconciled` and `sync` **must be fed the identical set** (`Set(messagingStore.bundleIDs)`) at both DockStripView call sites — a mismatch makes the window head on first frame then jump on sync. Only affects new ids; a dragged/remembered messaging window keeps its position. - Kept app chips participate in the live-window drag/reorder and drawer-conversion paths. Drag conversions are symmetric transactions (see Strip And Drawer Conversion above). ## Pinned Folders And External File Drop - Strip layout is `[messaging][divider][shelf + pinned folders][divider][live windows]` (kept-app placeholders live in the live zone, not the messaging zone); empty zones drop adjacent dividers. The shelf no longer guarantees a non-empty folder zone — `AppSettingsStore.showShelf` (menu 「显示中转站」, default on) can hide it, and with no pinned folders the whole zone plus its divider disappears. That state has **no external drop target and no 「添加文件夹…」 entry point at all** (both live only on shelf/folder chip context menus); the way back is the menu checkbox. Accepted boundary, owner 2026-07-30 — do not add a compensating entry point without an owner decision. - Folder chips drag via `DragController` source `.folder`; keep it isolated from strip/drawer stash semantics. - Fixed-folder primary click behavior must route through `FolderInteraction.primaryAction`; do not scatter left-click policy across views. Current default is preview toggle, with Finder open available from the menu. - Folder reorder and popup anchoring use `folderChipFrames`. Never merge folder ids into `ChipFramePreferenceKey`/`chipFrames`. - Per-folder sort persists in `PinnedFolderStore.sortOrders`; covers follow the current sort's first **file**. - Fixed folder chips render as a flat single small cover with the folder name always visible below it. Do not restore hover-only names, 36/24 hover resizing, or stacked-paper layers. - Folder-chip hover feedback (whole-chip scale-up anchored to the bottom) and the drop-target highlight are non-layout visual overlays only (`scaleEffect`): they must never change the chip's layout size or the reported drop-hit frame. The resident name row stays truncated; the full name comes from the `.help` tooltip, never by widening the chip or unclipping that row. - Finder windows do not expose folder paths reliably through AX. Do not retry Finder-window-chip-to-pinned-folder via AX without an owner decision. - `PinnedFolderCoverStore` must keep background enumeration and generation checks so stale async thumbnails cannot overwrite fresh covers. - `FolderCover.isThumbnail` decides rendering: thumbnails get square-crop + border; icons render fit. - `DirectoryWatcher.stop()` is idempotent; the fd closes only in the dispatch source cancel handler. - The strip `.onDrop` for external files must stay on the same view level that declares the `"strip"` coordinate space, before shadow padding. - External drop routing stays in unit-tested `StripDropRouting.route`: shelf hit -> stash, pinned-folder chip horizontal-band hit -> move into that folder, chip gaps / folder-zone tail slack -> pin, else reject. `shelfFrame` is `CGRect?` and the caller must pass `showShelf ? frame : nil` — **never rely on the preference key clearing the stale frame**, because `ShelfFramePreferenceKey.reduce` deliberately ignores `.zero`, so a hidden shelf's old rect would keep answering `.stash`. `nil` shelf derives the zone's left edge from the first folder frame minus `headSlack` (8pt); that slack is the **only** way to reach `.pin(insertIndex: 0)`, since the first chip's whole band resolves to `.moveInto` first. `.zero` still means "frame not measured yet" and rejects everything. - `DockStripView.folderZoneMaxX` is optional and `nil` must **not** be treated as "no folder zone" — a folder drag implies the zone exists, so `nil` only means the frames are not measured yet. Classifying it as live zone makes an in-place release delete the pin and open Finder. Skip installing `FolderChipDropGeometry` instead; `DragController` already falls back to `.folderZone` (a no-op) for nil geometry. - Only directories can pin; files dropped in chip gaps / folder-zone tail slack are a silent no-op. Re-dropping an already-pinned folder in a gap repositions it; dropping it on a folder chip moves it into that folder. - Moving external files into a pinned folder never overwrites an existing destination. Move only when both volume identifiers are known and equal; otherwise copy through a hidden temporary item, preserve the source, and remove the temporary item on failure. - External drop hover cleanup must not rely only on `performDrop` / `dropExited` or `folderPaths` changes. Keep `dropEntered` gating plus `.common` Timer watchdog for missing terminal callbacks and post-drop hover flicker. - Middle-click / Force Click content-preview monitors must observe and return the original `NSEvent`. They must not consume left clicks, break folder drag, or feed planner/frontmost state. ## Shelf And Folder Popup - Shelf stores references only, newest first. Never move/copy files implicitly. `ShelfStore.prune()` runs when opening the shelf popup. - Shelf chip is a fixed head of the pinned-folder zone: click + drop target only, never draggable and never a `DragController` source. It renders only while `showShelf` is on. Toggling it changes the strip's content width, so `PanelCoordinator` must subscribe `$showShelf` and `relayout` **on the next main-loop turn** (SwiftUI has to finish layout before `fittingSize` reports the new width) — otherwise the panel, capsule and an open drawer all stay at the old geometry. The same subscription closes the shelf popup when it is the open one; hover cleanup belongs to `DockStripView.onChange(showShelf)` (`externalDropHoverEnded`), and `animatedEntryIDs` must drop only `"shelf"`, never the whole set, or every folder chip replays its entrance animation on re-enable. - Folder and shelf share one popup panel through `PanelCoordinator.PopupContent`; preserve the one-popup-at-a-time invariant. - Popup lifecycle stays in `PanelCoordinator`: plain `NSView` container, pinned `NSHostingView`, alpha fade, local/global left-mouse monitors. - `dismissFolderPopupIfOutside` must exclude the anchor chip rect with tolerance so clicking the same chip does not close-then-reopen. - Popup closes on dock target-frame change, screen-parameter change, hover screen switch, fullscreen, or panel hide. Do not chase an animating anchor. - Popup layout mirrors native Stacks: no header row; Finder open is the grid tail cell; drill-in uses a floating back chip. - Popup width is derived from `FolderPopupStyle`: column count = clamp(cell count, 3, 8). It is not measured feedback. - Grid-cell menus are hand-built via `FileItemMenuBuilder`; no Quick Look, Get Info, or rename in the nonactivating panel. - First frame must be complete before `orderFront`: preload folder contents, warm visible icons, use synchronous `fittingSize`, and avoid first-population insertion animation. - Switching folders while popup is open is an in-place content/frame switch, not orderOut-then-reopen. - Popup open/close animation uses `PopoverAnimation`; taskbar/drawer layout animation stays on `DrawerAnimation`. - Edge auto-hide is inhibited while the popup is open via `EdgeAutoHideInhibitor.folderPopupOpen`. ## Menus, Panels, And Screens - Strip and drawer chip menus are hand-built AppKit `NSMenu`, not SwiftUI `.contextMenu`. - No menu anywhere exposes drawer placement actions (`收进抽屉` / `移出抽屉`); stashing and unstashing are drag-only. Every eligible non-Finder, non-messaging app menu shows `在程序坞中保留` as a native checked / unchecked `NSMenuItem`, regardless of running state or drawer placement; toggling it changes kept state only. - `MenuHostNSView` claims only right-click / Control-click and returns `nil` from `hitTest` otherwise. - Force Quit is a native alternate item after Quit, gated out for this app itself. - `LauncherChip` menu running-state follows the passed-in `isRunning` (the displayed zone), never an independent `NSWorkspace` process query. A launch-zone icon whose process is still alive (window-closed / background) must not surface 显示/隐藏/退出. The pure decision is `LauncherMenuPlan.itemKinds` (unit-tested); `buildLauncherMenu` only renders it and queries `NSWorkspace` solely to obtain the app object for an action it already decided to show. Membership items come from the pure `AppMembershipMenuPlan` (unit-tested) via the shared `LauncherMembershipItem.items` factory — the four menu paths (strip window chip / kept icon / messaging chip / drawer icon) all consume it so the matrix stays consistent: strip-plain = 在程序坞中保留 + 标记为消息应用(未勾选); strip/drawer messaging = 在程序坞中保留 + 标记为消息应用(已勾选); drawer-plain = 在程序坞中保留 only; Finder = none. Both titles stay constant and use native `NSMenuItem.state`; Keep always precedes Messaging. Checked Messaging still calls `unmarkMessaging`, preserving its asymmetric kept/drawer semantics. - 退出 App (and its Option-alternate 强制退出) is **always the last item** in every chip menu; membership items always precede it. Both `LauncherMenuPlan.itemKinds` and the two `DockStripView.buildChipMenu` branches (app-level fallback / concrete window) must keep that order — a kept app whose windows are all closed used to render 退出 third-from-last. `LauncherMenuPlanTests.testQuitIsAlwaysLastWhenPresent` locks it. The separator before 退出 is added by the quit branch itself, guarded against double lines. - Finder menu items apply to both persistent Finder chip and concrete Finder windows. - SwiftUI shadow margin is `shadowPadding = 20pt`; floating panel shadows must fit within it. It is the one metric that **never scales with `DockSize`** — the shadow tokens are frozen (dark already overruns the budget by 3pt), so scaling the margin would move an already-signed-off look. - Coordinate math on `dockFrame` / `capsuleFrame` subtracts `shadowPadding` to reach visual content, and `fittingSize.width` subtracts `2 * shadowPadding`. - Relayout is target-frame-driven. Do not position one panel from another panel's live `.frame` during animation. - `PanelCoordinator` is the sole frame owner for every panel it creates. Never install an `NSHostingView` as one of those panels' direct `contentView`: use `ManualPanelHost` or the existing equivalent plain-`NSView` container, retain the hosted content separately when `fittingSize` is needed, and clear retained hosts during suspension. A direct hosting content view calls `updateAnimatedWindowSize`; competing with `PanelCoordinator.setFrame` caused the 2026-06-22 drawer dip and the 2026-07-31 launch-time Update Constraints recursion / SIGABRT. - Dock startup is one first-frame transaction: create dock and capsule hidden, synchronously lay out and measure the retained dock host, compute and apply both target frames, then order them front together. Later content-driven `fittingSize` reads still wait for SwiftUI's next main-loop turn; the `$dockSize` subscription must drop its initial value because setup has already consumed the persisted tier. - Placement panels use `NonConstrainingPanel`, not plain `NSPanel`. - Bottom-anchored panels use `screen.frame` for bottom Y and horizontal clamp; reserve `visibleFrame` for drawer top/menu-bar height cap. - Hover screen switching uses dwell, not instant edge-trigger. - Fullscreen hide hides capsule and closes drawer. `FullscreenWindowClassifier.isFullscreen` remains the single AX predicate, gated to real `AXWindow` roles. ## Light / Dark Appearance Until 2026-07-30 the whole visual layer was hand-tuned for dark only — zero appearance branching anywhere, every foreground a literal `.white.opacity(…)` and every shadow a literal `.black.opacity(…)`. Light mode was broken in ways that read as "质感差" but were partly plain unreadability (white labels and white running dots on a light plate). Real user report: GitHub issue #3 item 3. - All appearance-dependent values live in **`Core/Support/DockThemeTokens.swift`** (pure, no SwiftUI — numbers only, so equality is exact) with the `Color`/`NSVisualEffectView.Material`/modifier bridge in **`App/Scenes/DockTheme.swift`**. Do not reintroduce inline `.white.opacity(…)` / `.black.opacity(…)` / `.shadow(color:…)` in any view; add a token instead. - **The `dark` column is frozen** — it is byte-for-byte the pre-2026-07-30 literals, and `DockThemeTests` asserts every one of the ~38 fields. Tune `light` only. Changing `dark` is an owner-level product decision; if a test goes red, that is the guardrail working, not a stale expectation to overwrite. - Views read `@Environment(\.colorScheme)` and resolve via `DockThemeTokens.resolve(_:)`. No EnvironmentKey, no `NSApp.effectiveAppearance` KVO — plain `colorScheme` propagates into the borderless `.nonactivatingPanel` hosting views and switches live (verified 2026-07-30: appearance flip restyles a running instance with no relaunch). - `DockVisualEffectView.updateNSView` **must actually apply the material**. SwiftUI only re-runs update on appearance change, never re-creates the NSView — the old empty `updateNSView` would silently freeze the material. - Turning a tint off uses **the same base at 0 opacity** (`DockTint.color(active:)` / `dockGlow`), never `Color.clear`: these sites animate, and interpolating from `.clear` toward a white rim shows a gray edge mid-transition. - `DockTheme.swift` must stay in **both** targets. `WindowTitleTooltip.swift` is compiled into the test target (for `WindowTitleTooltipTests`), so app-target-only membership makes the test build fail with a bogus `macos_dock_cc_v2Tests.DockShadow` vs `macos_dock_cc_v2.DockShadow` mismatch. - Panel rim is a top→bottom gradient (`panelRimTop` → `panelRimBottom`), which is what the deleted dead constants `Style.borderTopOpacity/borderBottomOpacity` were originally for. Dark sets both ends equal, so the gradient degenerates to the historical uniform hairline. - Two "looks like a color but isn't" traps, neither of which may be tokenized: the scroll edge-fade `.black`/`.clear` in `DockStripView` is a **mask alpha channel**, and `NSColor(white: 1.0, alpha: 0.0)` in `PanelCoordinator` / `DragController` is **fully transparent**, not a white background. - Known open issue, deliberately not fixed: the **dark** strip/capsule shadow is `radius 15 + y 8 = 23 > shadowPadding 20`, so it is hard-clipped 3pt at the panel's transparent edge, and the heavy shadow also bleeds *through* the translucent plate and muddies the bar's bottom edge (measured: bottom-of-plate luminance 141 dark-shadow vs 155 with the light token). Fixing it changes dark's look, which the frozen-dark rule forbids without an owner decision. `DockThemeTests.testDarkStripShadowStillExceedsBudgetKnownIssue` pins the current state. Light's shadows are all inside the budget. - `ContentView.swift` stays dark-only on purpose — it is the debug console, reachable only through the unwired `DebugConsoleView`. - **macOS 26 Liquid Glass was measured on this app, 2026-07-30 — do not re-run the spike from scratch** (`App/Scenes/DockGlassBackdrop.swift`, opt-in `DOCK_LIQUID_GLASS=1`, default off, strip backdrop only). Results against a black/white stripe target behind the bar: (a) `glassEffect` **does** work in our `.borderless + .nonactivatingPanel + .floating + isOpaque=false` panel and **does** sample behind-window content — far more transmissive than `.popover`, the backdrop reads clearly through the bar; (b) it adds a genuine specular top edge; (c) **there is no measurable geometric refraction** — a vertical hard edge 24pt inside the left rim displaces 0.00 / +0.12 / −0.12 px (control, glass off: ±1.4–2.8 px of low-contrast noise), and the rounded corner at 4× shows a smooth blur gradient with no bending. So Liquid Glass here buys transparency + adaptive tint + edge highlight, **not** lensing of the backdrop. Idle CPU is comparable. Owner rejected it 2026-07-30 as too transparent; the switch stays default-off as the record. Untested: hit-testing with glass on, second display, appearance switch with glass on, cost while a window moves behind the bar. - We still cannot hand-roll geometric refraction: `.behindWindow` blending happens in the WindowServer, so this process never sees the backdrop pixels. Screen-capture + Metal is refused on purpose (extra Screen Recording permission, frame lag, battery) for an always-on-top strip. - **Backdrop saturation, however, IS ours** — measured 2026-07-30 against a colored stripe target: SwiftUI `.saturation()` on the visual-effect view filters the *composited* result (material + already-blurred backdrop), so colors get richer **and the blur survives** (in-strip saturation 0.221 → 0.534 at `saturation(3.0)`, off-strip control unchanged at 0.398). Lives in `panelBackdropSaturation`; `1.0` means the modifier is not attached at all. - **`.opacity()` on the backdrop is a dead end — do not retry it as a transparency knob.** It thins the material itself and reveals the *unblurred* desktop: the in-strip 10%→90% edge transition collapses from 115px to 0.9px, i.e. as sharp as outside the bar. That is a hole in the glass, not more transparent glass. **Transparency comes only from the material choice.** Measured light-mode transmission (white-side minus black-side luminance, chip-free top band): `hudWindow`/`fullScreenUI` 108 > `popover` 80 (current) > `menu` 50 ≈ `headerView` 46 ≈ `titlebar` 45 ≫ `underWindowBackground`/`toolTip`/`sidebar`/`selection` ~20 — those last four do not support behind-window blending and get silently downgraded by AppKit, so they are not candidates. Iterate with `DOCK_PANEL_MATERIAL=` (no rebuild needed; unknown names fall back to the token value and log the resolved one). - Panel thickness cues (`panelInnerHighlight` / `panelInnerShadow`) are drawn **above the material and below the content**, clipped to the panel shape, `allowsHitTesting(false)`. Dark keeps them at zero and `drawsPanelThickness` is false, so the layer never enters the view tree — same reason a saturation of `1.0` skips its modifier: a `.blur(radius: 0)` or `.saturation(1.0)` can still force offscreen rendering, which would break the dark pixel-freeze. - **Default rendering must equal the look the owner has signed off on; an unaccepted effect ships opt-in.** Learned the hard way 2026-07-30: the Liquid Glass spike was correctly default-off behind `DOCK_LIQUID_GLASS`, but backdrop saturation and the thickness layer landed default-*on* the same day. Same category, two standards — so the owner's bar silently drifted away from the version he had approved and he was the one who noticed. All three now gate through `DockEffectSwitches` (`DOCK_PANEL_MATERIAL=`, `DOCK_PANEL_SATURATION=`, `DOCK_PANEL_THICKNESS=1`); the tuned numbers stay in `DockThemeTokens.light` as **candidates** so there is still only one table to edit. `DockThemeTests.testAllEffectsAreOffByDefault` locks the rule. - **Dimming an icon is three channels, not one** (`DockIconDim`: opacity / grayscale / brightness; states via `DockIconDimState`, resolved by `theme.iconDim(_:)` — no bare numbers in views). Plain `.opacity()` fades an icon toward the *plate* colour: over the dark plate that reads as "dimmed" and is fine, over the light plate it goes milky — the owner reported exactly that as 「灰蒙蒙」 (2026-07-30), worst on icons with dark backgrounds, which collapse into a gray square. Light therefore dims mostly by **desaturating + slightly darkening**, and the two states sit deliberately **close** to each other — "is it running" is already carried by the running dot under the icon, so the icon must not also collapse to a gray silhouette (owner reverted a first pass that made the exited state fully gray, 2026-07-30); exited is only slightly grayer than hidden. Dark keeps the frozen plain-opacity values (`DockIconDim.legacyHidden` / `.legacyNotRunning`). `dockIconDim`'s **two branches are load-bearing**: when `usesColorFilters == false` it attaches only `.opacity`, byte-identical to the pre-token code — an identity `.grayscale(0)` / `.brightness(0)` can still force offscreen rendering and break the dark pixel-freeze (same reason as the thickness layer and the saturation modifier). Owner accepted the light values 2026-07-30, so unlike the three effects above this one is **default-on and has no switch**. - Pixel-A/B method for "did the look change" — three things must be held still or the diff is meaningless (all three learned by getting burned on 2026-07-30): 1. **Backdrop**: put a self-drawn opaque target window behind the strip. Otherwise the translucent material samples whatever moved behind it and the diff reads ~89% changed for reasons unrelated to the code (hit twice). 2. **Pointer**: park the cursor off the strip. A hovered chip grows a subtitle and changes pill size, which **reflows the whole row** and shifts everything to its right — read as 4.8% changed. 3. **App state**: live badges and window open/close drift between captures. The last residue in the `ed31ff2` comparison was literally one WeChat unread badge arriving mid-run. With all three controlled, the chrome diff is exactly **0/255**. Build the reference commit in a throwaway `git worktree` with its own `derivedDataPath` so the working tree is untouched. ## Taskbar Size Tiers - Taskbar size is four tiers (`DockSize`: 小 44 / 中 52 / 大 60 / 特大 68pt panel height, menu 「任务条大小 ▸」). Tiers are defined **by panel height, with `scale = height / 52` derived from it** — never the reverse, or a tier lands on 44.2pt and every corner radius, icon and hairline falls on a half pixel. `medium` must stay byte-identical to the pre-2026-07-30 literals (`PanelGeometryTests` asserts the whole struct); the raw values `small/medium/large/extraLarge` are persisted in UserDefaults, so renaming one silently resets every existing user to medium. - `DockSize.metrics` is the single source for panel geometry — AppKit (`PanelCoordinator`) and SwiftUI both read it, never their own copy. `windowHeight` is written as `panelHeight + 2 * shadowPadding`, not a literal: that relation used to live only in a comment and is exactly what a height change forgets. `PanelCoordinator`'s `panelHeight` / `windowHeight` / `capsuleWidth` are therefore **instance** properties; only `shadowPadding` stays static. - **Three long-lived `NSHostingView` roots must each observe the same `AppSettingsStore`**: the strip, the capsule (`DrawerCapsuleButton`), and the drag carrier (`carrierFactory`'s capture list). A plain Environment value does not refresh a root SwiftUI never rebuilds, and injecting only the strip leaves the capsule and any already-created carrier at the old tier. - Every strip render path takes an explicit `scale`: `ChipView`, `LauncherChip`, `ShelfChip`, `PinnedFolderChip`, chip spacing, content inset, edge fade, divider **height** (its 1pt width is a hairline and stays), corner radius, and `StripDropRouting`'s `headSlack`. The capsule's 3×3 preview must scale glyph, icon, spacing **and** padding together: `3×9 + 2×4 + 2×6 = 47pt` already exceeds the 44pt small-tier capsule. - Corner radius is scaled for the strip and capsule only. `DockShape.panelCornerRadius` is shared with the drawer and both popups, so it must not be scaled globally. - `WindowTitleTextMetrics.maximumWidth(for:)` feeds **both** the rendered title frame and the truncation test. Two independent 140pt literals is what previously let the chip clip a title without ever showing a tooltip. - Changing tiers is a **transaction**, not a content change (`beginDockSizeChange`): bump the generation, cancel the drag, dismiss tooltip/popup, **close the drawer** (its `maxContentHeight` is passed once when it opens, so moving only the outer frame clips the content), then commit once on the next main-loop turn with `animated: false`. The generation gate exists because `cancelDrag()` makes `subscribeConvertRelease` queue an *animated* relayout — without it the bar animates to the new tier and then jumps. - Maximized-window avoidance needs **no** extra work here: `taskbarTop` is derived from `panelHeight` inside the Equatable `WindowLiftAvoidanceContext`, so a tier change flows through `reconcileContext`'s existing restore/clear path. Do not add an AX write path for it. - Out of scope by decision (owner 2026-07-30): the drawer's contents (still `0.7`), the folder popup and the shelf popup do **not** scale; they are separate surfaces and only get re-anchored. ## Settings And Compatibility - Do not reintroduce the multi-display strategy menu; behavior is fixed to dwell hover-switch. - Tungsten Edge slider controls wake delay, not hide delay. `不唤醒` still allows hide but disables bottom-edge wake. - Hidden-state bottom-edge detection must keep probing while the dock panel is hidden. - The bottom-edge wake hot zone (`hoverHotZone`, spans the full screen width) and the idle-hide "still interactive" check (dock/capsule panel rect, centered and narrower) are different-sized regions. Resting the mouse in the hot zone but outside the panel rect must count as "still interactive" (`EdgeAutoHideRuntimeRules.bottomHotZoneSuppressesIdleHide`), otherwise wake and idle-hide re-arm each other every cycle and the dock flickers forever (GitHub issue #2, fixed 2026-07-17). This suppression must stay gated to finite wake delays (0.1–3.0s) only — never apply it at `neverWakeDelay` (999, auto-hide without wake) or `neverHideDelay` (-1, always visible), or it silently changes those modes' behavior. Reproduction/regression diagnostic: `DOCK_EDGEHOVER_TRACE=1` prints one `[edgehover] SHOW/HIDE` line per actual visibility flip (mouse position, hot-zone/panel-rect flags, current delay) — default off, plain `print()` not `Logger` (some environments can't read the unified log back). - The dynamic Tungsten Edge hide/show command toggles `edgeAutoHideDelay` between `neverHideDelay` and persisted `lastEnabledEdgeAutoHideDelay`; it reuses the wake-delay slider pipeline, never a separate visibility state. Remembered values must never be `-1`; seeding/sanitizing lives in `AppSettingsStore` (unit-tested, fallback 0.1). Its global shortcut is ⌥⇧⌘D: system Dock's ⌥⌘D plus Shift. It avoids Control+Option (VoiceOver) and pure Option / Option+Shift (macOS 15 FB15168205), and releases the old ⌥⌘E Safari/Finder conflicts. - The native-Dock group is **one command + one slider**, and their two write paths must never be merged (owner 2026-07-30, reversing the 2026-07-29 complete-hide decision — 「不唤醒」 now lives at the slider's right end instead of inside the command). `setAutohideEnabled(_:)` backs the menu command and writes **only** `autohide` — it is strictly ⌥⌘D-equivalent and must not touch `autohide-delay`; `apply(delay:)` backs the slider and writes the whole tier (`-1` → `autohide=false` only; otherwise `autohide=true` + `autohide-delay`, never-wake landing on 999). Restoring `841fe45^`'s delay-returning `nativeToggleTarget(...remembered:)` for the command silently re-merges them. State is decided from the LIVE system value (`currentAutohideState`; `CFPreferencesAppSynchronize` before every read), falling back to the store mirror only when unreadable. `menuWillOpen` may reconcile the mirror to system truth but must never apply settings back. Menu order inside the group is command → slider → `打开系统 Dock 设置…` → separator; that last item only uses `NSWorkspace.open` (deep link, then Dock.prefPane fallback), never the write/sandbox gate or an `/usr/bin/open` subprocess. - Every native-Dock write ends in `killall Dock`, so the slider **commits on release, never per tick**, and three trigger sources (mouse-up, keyboard/VoiceOver debounce, `menuDidClose` flush) must consume one pending atomically through `PreferenceSliderCommitTracker` — two consumers = two Dock restarts. Keyboard and VoiceOver adjustments never reach `mouseDown`, so a mouse-only commit silently drops them. The native slider's draft stays **inside the view**: `AppSettingsStore.setNativeDockAutoHideDelay` persists active *and* remembered on every call, so wiring it to `onDelayChange` would write half-dragged values that the system never received. The command title likewise must not follow the draft — it describes system truth, which has not changed until the write lands. - After any native-Dock write, re-read system truth and resolve the mirror by four quadrants (`AutoHideToggleMenuModel.resolvedStoreDelay`): readable → use the system value regardless of write success (a multi-step sequence may have partly applied); unreadable → keep `target` if the write succeeded, fall back to `previous` only if it failed. **Do not roll back on a successful write** — that leaves the UI showing the opposite of the setting that just took effect. Only a failed write raises an alert. - The three `com.tungsten.edge.nativeDock.restoreDelay.*` keys from `d08e8d6` are **frozen: never read, never written, never deleted**. They may hold a precise pre-hide delay (e.g. `0.75`, which the 0.1-step slider cannot even express); deleting them is a lossy migration and breaks the `git revert d08e8d6` data boundary. Their names stay documented in `NativeDockPreferencesService.swift` only to prevent key collisions. - Global hot key rules (`GlobalHotKeyMonitor`, Carbon `RegisterEventHotKey`; exclusivity passes through the backend protocol so tests assert it): lifecycle is main-thread only; the callback may only toggle settings — it must not export debug snapshots or synchronously read cross-app AX/CG inventories (why, and the unverified incident behind it: `Docs/Archive/Engineering/24-guardrail-provenance.md` §4). Menu `keyEquivalent` hints: ⌥⇧⌘D shows only when Carbon registration succeeded (failure logs once, no dialog, no in-session retry; a deliberate `stop()` re-arms `start()` for a real re-registration). `GlobalHotKeyShortcut.nativeDockAutoHide` (⌥⌘D) exists **for menu display and key-matching only and must never be registered** — macOS owns it, so registering would steal it; its hint therefore shows unconditionally. Because macOS still handles ⌥⌘D during menu tracking, the native-Dock menu action must skip that exact keyDown (`AutoHideToggleMenuModel.shouldSkipNativeDockMenuAction`), or system and menu each fire once and the Dock toggles twice. With the menu closed, ⌥⇧⌘D runs through Carbon and ⌥⌘D through macOS; while the menu is open, the Carbon shortcut remains subject to the existing menu-tracking boundary, so use the Tungsten Edge menu item directly. - Minimum deployment target is **macOS 12**. Guard newer APIs with availability checks and Monterey-compatible fallbacks. - Old single-value `onChange` deprecation warnings are expected back-deployment noise. ## Running Instance Identity Everything in this section exists because of two real misdiagnoses (2026-07-21 "two identical taskbars → a fix that was never actually verified got judged broken and reverted"; 2026-07-26 "minimize feel is off → suspected a version regression, actually a Debug build"). Root cause was a single fact, fixed 2026-07-29. - **`SMAppService.mainApp.register()` registers whichever bundle is running at that moment** (`App/Composition/LaunchAtLoginService.swift:80`). Ticking 「登录时启动」 while a build-directory app is running pins the login item to `build/DerivedData/.../Debug/` — from then on every boot launches the **dev build**, whose contents get overwritten by the next `build_and_run.sh` and deleted by a clean build. **Never tick it on a dev build.** If `sfltool dumpbtm` shows the entry's URL is not `/Applications/`, the only correct repair is: untick in the running dev instance → quit every instance → launch the `/Applications` copy → tick again there. A fresh registration may sit at `Disposition: disabled` (`requiresApproval`) until the user approves it in 「系统设置 → 通用 → 登录项与扩展」 — that is not a failure. - **`AppDelegate.terminateOtherInstances()` is not redundant code.** Two bundles with the same bundle id (installed copy vs build directory) run side by side happily, producing two identical taskbars with no way to tell which one a test just exercised. Keep **later-launch-takes-over** — the reverse (new instance quits itself) would block the dev verification build and contradicts `build_and_run.sh`'s existing pkill. Terminate politely first so the old instance's `stopAndRestore` returns lifted windows to their native frames; only `forceTerminate` after the 3s grace. It logs the losing instance's bundle path — keep that, it is the only forensic trace if this recurs. - **Never use `open -n` in `Scripts/build_and_run.sh`.** That flag's entire purpose is forcing an additional instance; the script was manufacturing the very duplicates it was meant to avoid. Wait for the old process to actually exit, then plain `open`. - **The version line's provenance suffix (`BuildProvenance`, unit-tested) is not decoration.** The mainline version is not bumped after a release, so a dev build and the user's installed copy print an identical version string; the suffix is the only way to tell them apart by eye. Debug wins over location — a Debug build copied into `/Applications` is still a Debug build, and its feel differs (no compiler optimization). `installedPrefix` keeps its trailing slash so `/ApplicationsOld/…` is not mistaken for installed. - Only one copy of the app may live in `/Applications`. A second bundle with the same id (a `*-backup` left beside it) makes LaunchServices' bundle-id resolution nondeterministic. - **Every `NSAlert` raised from the status menu must go through `StatusMenuController.runModalInForeground`**, never a bare `runModal()`. An `.accessory` app that does not activate itself first gets its alert stacked *behind* the frontmost app's windows — the user clicks the menu item and sees nothing happen, indistinguishable from a broken feature. A real user reported exactly this as 「检查更新失效」 (2026-07-29); all four alerts in that file were affected, including the otherwise-invisible 「系统 Dock 设置失败」 path. `AppDelegate` already activates before each of its own modals — the status menu was the outlier. - **The owner's daily-use build must be a Release build signed with the fixed local certificate `macos-dock-cc Local Code Signing`, installed at `/Applications` via `Scripts/install_local_release.sh`.** Not ad-hoc, not Debug. Why: ad-hoc grants die on every rebuild while the toggle still looks on, the fixed certificate survives rebuilds and version bumps, and Debug builds have measurably worse minimize feel (once misdiagnosed as a version regression). The signing-identity mechanics and the 2026-07-29 measurements live in `Docs/05-known-platform-quirks.md`. Dev builds and the installed app **share one bundle id and therefore fight over one TCC grant** — authorizing one revokes the other in practice, so do not keep both authorized. - **`lsappinfo list | grep "pid = "` reporting `type="UIElement"` is the reliable external signal that the app reached its normal accessory run state.** The reverse inference is *not* valid: `Foreground` means "not in that state yet", **not** "untrusted" — a trusted copy sitting in the transient-location guide or in permission-recovery deliberately stays `.regular`. A checked toggle in System Settings proves nothing either — it may belong to a stale entry. Also: the 「−」 button is disabled while the app is running; quit it first. ## Accessibility Permission Onboarding And Recovery - **Install location is classified before anything else in `applicationDidFinishLaunching`.** A transient copy (read-only volume / App Translocation) is a one-shot guide shell: it must not `terminateOtherInstances()` (mounting a DMG and double-clicking would kill the user's working `/Applications` instance), must not register the global hot key (it would steal ⌥⇧⌘D from the working instance), and must not call `AXIsProcessTrustedWithOptions` (that writes a TCC record pointing at a copy that disappears on unmount). - The transient test is **volume read-onlyness**, not a `/Volumes` path prefix — the old prefix rule would trap users who legitimately keep the app on an external disk. A read-write disk image is a deliberately accepted miss. Unreadable volume info means "allow": never lock a normally-installed user into the relocation page. - `PermissionOnboardingState.stalled` means only "waited long enough to offer troubleshooting" — **never** "the stale TCC entry is invalid". TCC is unreadable, so we can prove neither that the user flipped the switch nor that the old record mismatches. The delete-and-re-add copy stays conditional and **must not presuppose a signing method** (fixed-certificate grants survive version bumps). `.stalled` and `.recoveryStalled` copy must stay separate: initial grant continues in-process via `startApp()`, so it has neither "auto relaunch" nor lifted-window residue. - Runtime loss detection goes through the pure `PermissionLossDetector` (monotonic `systemUptime`, first `false` → `.uncertain`, two sustained `false` ≥ 5s → `.lost`, an early-arriving sample never resets the anchor). No inline single-sample verdicts. - **Window-lift snapshot protection is the lift controller's own job, not the watchdog's.** The watchdog samples every 5s; `WindowLiftAvoidanceController` polls every 0.2s, so `poll()` self-checks trust (silently, never with a prompt) and freezes on its own. Without that, the 0–5s gap plus one screen change lets `reconcileContext` clear the snapshot and then fail the restore. `enterFreeze` must also set `isEnabled = false` — an already-queued `poll()` reads only that field. While frozen, every session-clearing path (`clearManagedSession`, `finishOperation`, `reconcileContext`, `restoreSettledWindowsAndClearSessions`, scan callbacks) may drop task references only. - `pendingRestorations` is owned by the controller and an entry is removed only after it is handled; before restoring after a long freeze, re-verify process start time (pid reuse), CG full-list presence (cgWindowID reuse) and the current frame (user moved it → drop, never force). Unfreeze is **staged**: drain → bump `freezeGeneration` → converge stalled `.writing` sessions via `clearManagedSession(suppressUntilNative:)` → only then resume polling. A cancelled writer never restarts itself (`WindowLiftAvoidance.reduce` only bumps the generation for a `.writing` observation), so skipping the converge step leaves a window frozen forever. - `stop()`'s early guard must also open on outstanding work (`!states.isEmpty || !pendingRestorations.isEmpty`), or the post-freeze `restoreAndStopForPermissionRecovery()` no-ops and lifted windows never come back. - Panel teardown is `PanelCoordinator.suspendAndRelease()` — cancel the drag, close popups/drawer/tooltip, **replace each panel's `contentView`** so SwiftUI's `dismantleNSView` removes view-installed pressure/middle-click monitors, null **every** separately retained host — `dockContentHost` / `capsuleContentHost` / `drawerContentHost` / `folderPopupContentHost` — then let `AppDelegate` release the coordinator so `deinit` sweeps the rest. Do **not** hand-maintain a monitor/timer checklist. Suspension uses its own flag, never `visibilityState.hideReasons` (bottom-edge wake would pull the panels back). - Every async completion re-enters `PermissionRecoveryMachine` carrying its `episode`; mismatched episodes are dropped. `.terminating` preempts by cancelling `recoveryTask` **and** bumping the episode — setting a phase alone cannot stop a Task already inside `await` (`applicationShouldTerminate` returns `.terminateLater` while the run loop keeps going). `applicationShouldTerminate` must also skip the `stopAndRestore()` wait when untrusted. - Relaunch order is restore-windows → release hot key → `openApplication`. Releasing the hot key is mandatory: the new instance registers Carbon at launch and a failure there is logged once with no in-session retry. Retry must release it **again** (the failure branch re-registered it). `openApplication` failure must never terminate the old instance, and its completion arrives on a concurrent queue — hop back to `@MainActor` before touching state. Creating a second instance here is intentional and unrelated to the `build_and_run.sh` `open -n` ban. - Each waiting episode gets a **fresh** `AccessibilityPermissionModel`: `didRequestPrompt` / `didNotifyGranted` are lifetime one-shot gates, so a reused model would never prompt or call back a second time — and after `tccutil reset` the instance may no longer be listed at all, leaving the user nothing to tick. - Accepted boundary: on the "quit → delete stale entries → reopen manually" path the in-memory restore snapshot dies with the process, so lifted windows stay one taskbar-height short. The recovery copy says one re-maximize fixes it. Do not claim the lifted-window problem is fully solved. ## Collaboration Rule The owner directs product, does not read code, and does not read English comfortably. Reply in Chinese. - Explain behavior in plain Chinese first; add file/API details only when useful. - Frame choices as product behavior and trade-offs, not implementation trivia. - For coding tasks, read code first and follow existing repo patterns. - For "打检查点", create a local git commit unless told otherwise; do not push or create PRs unless asked. - For 收尾 / 整理文档, do not expand this file by default. Update `AGENTS.md` only for new hard engineering guardrails that would prevent code regressions. Product state, roadmap, release progress, decision history, and long handoff text belong in Obsidian; historical notes belong in `Docs/Archive/`.