# Home Tab Architecture

> **Note:** Do not add a file structure section to this document. File layout is navigable directly in the project and goes stale quickly.

## Pattern: MVVM + Coordinator + Domain Controllers

Each screen in Home Tab follows MVVM with a single shared Coordinator that handles navigation and page view lifecycle. Domain state lives in `HomeTabStore` and its sub-controllers, which are independent of the UI layer. `UIFactory` centralises all view and ViewModel construction.

```mermaid
graph TD
    Feature["HomeTabFeature\n(long-lived owner)"]
    Controller["HomeTabStore"]
    Documents["DocumentsStore"]
    Thumbnails["ThumbnailStore"]
    Tools["ToolsStore"]
    Factory["UIFactory"]
    Coord["HomeTabCoordinator"]
    NavState["HomeTabNavigationStateContainer"]
    BannerCtrl["HomeTabBannerController"]
    VC["HomeTabViewController\n(lifecycle root)"]
    VM["HomeTabViewModel"]
    Cache["pages cache\n[HomeTabPage → NSView]"]
    Hosting["NSHostingView"]
    PageView["PageView (SwiftUI)"]
    PageVM["PageViewModel"]

    Feature -->|strong| Controller
    Controller -->|strong| Documents
    Controller -->|strong| Tools
    Documents -->|strong| Thumbnails
    Thumbnails -->|weak delegate| Documents

    Feature -->|creates per call| Factory
    Feature -->|creates per call| BannerCtrl
    Factory -->|strong| Controller
    Factory -->|strong| BannerCtrl
    Feature -->|creates per call| Coord
    Coord -->|strong| Factory
    Coord -->|strong| NavState
    Coord -->|strong| Cache

    Factory -->|creates| VC
    Factory -->|creates| VM

    VC -->|strong| Coord
    VC -->|strong| VM

    VM -->|weak| Coord
    VM -->|strong| BannerCtrl
    VM -->|subscribes| NavState

    Cache -->|strong| Hosting
    Hosting -->|strong| PageView
    PageView -->|strong| PageVM

    PageVM -->|weak| Coord
    PageVM -->|strong| Controller
    PageVM -.->|subscribes (home page only)| BannerCtrl
```

## Ownership Rules

**`HomeTabViewController` is the lifecycle root** for a single tab instance. It holds strong references to both the coordinator and the view model. Domain state lives longer — it is owned by `HomeTabFeature`.

| Reference | Strength | Reason |
|---|---|---|
| `HomeTabFeature` → `HomeTabStore` | **strong** | Domain state outlives any individual VC instance. |
| `HomeTabFeature` → `UIFactory` | transient (per call) | Feature creates a factory per `makeHomeTabViewController()` call and hands it to the coordinator. |
| `HomeTabFeature` → `HomeTabBannerController` | transient (per call) | Same lifecycle as `UIFactory` — one banner controller per `makeHomeTabViewController()` call. |
| `HomeTabFeature` → `HomeTabFeatureGatewayProtocol` | **strong** | Feature holds the host-provided gateway for the lifetime of the feature. |
| `HomeTabCoordinator` → `HomeTabFeatureGatewayProtocol` | **weak** | Coordinator forwards out-of-framework actions; the gateway is owned by the host, not the coordinator. |
| `HomeTabCoordinator` → `UIFactory` | **strong** | Coordinator delegates all creation to the factory for the duration of its lifetime. |
| `UIFactory` → `HomeTabStore` | **strong** | Factory injects the controller into every ViewModel it creates. |
| `UIFactory` → `HomeTabBannerController` | **strong** | Factory injects the banner controller into ViewModels that need it. |
| `HomeTabCoordinator` → `HomeTabNavigationStateContainer` | **strong** | Coordinator owns the navigation state and drives page transitions. |
| `HomeTabViewController` → `HomeTabCoordinator` | **strong** | Retention anchor. Coordinator lives as long as the VC. |
| `HomeTabViewController` → `HomeTabViewModel` | **strong** | VC owns its model. |
| `HomeTabViewModel` → `HomeTabCoordinator` | **weak** | VM calls `setCurrentPage` on the coordinator, but must not extend its lifetime. |
| `HomeTabViewModel` → `HomeTabBannerController` | **strong** | VM proxies `setBannerView(_:)` from the public VC API to the controller. |
| `HomeTabViewModel` → `HomeTabNavigationStateContainer` | subscription (`AnyCancellable`) | VM observes page changes; subscription is cancelled when VM is deallocated. |
| `HomeTabCoordinator` → page `NSView` cache | **strong** | Coordinator is responsible for page view lifecycle. |
| `PageViewModel` → `HomeTabCoordinator` | **weak** | Coordinator owns the page view (via cache) which owns the ViewModel — strong would be a retain cycle. |
| `PageViewModel` → `HomeTabStore` | **strong** | ViewModels read and mutate domain state. `HomeTabStore` is not owned by the VC, so no cycle. |
| `HomePageViewModel` → `HomeTabBannerController` | **strong** + subscription (`AnyCancellable`) | Home page reads `presentedBannerView` and subscribes for change notifications to refresh its SwiftUI body. |

## Creation Flow

`HomeTabFeature` creates `HomeTabStore` once and keeps it alive. Each call to `makeHomeTabViewController()` creates a fresh `UIFactory` and coordinator pair backed by the shared controller:

```swift
// HomeTabFeature.makeHomeTabViewController()
let bannerController = HomeTabBannerController()
let factory = UIFactory(store: self.store, bannerController: bannerController)
let coordinator = HomeTabCoordinator(factory: factory, gateway: self.gateway)
return coordinator.start()

// HomeTabCoordinator.start()
factory.makeHomeTabViewController(coordinator: self, navigationState: self.navigationState)

// UIFactory.makeHomeTabViewController(coordinator:navigationState:)
let model = HomeTabViewModel(coordinator: coordinator, store: self.store, bannerController: self.bannerController, navigationState: navigationState)
return HomeTabViewController(coordinator: coordinator, model: model)
```

Navigation state is decoupled via `HomeTabNavigationStateContainer`: the coordinator drives it, and the view model subscribes to it — no direct back-reference from VM to coordinator is needed for state observation.

Page views are created lazily by the coordinator and cached:

```swift
// HomeTabCoordinator.reusePageView(for:)
factory.makePageView(for: page, coordinator: self)  // UIFactory injects controller into PageViewModel
```

## Domain Controllers

`HomeTabStore` is a lightweight container for the feature's sub-controllers:

| Controller | Responsibility |
|---|---|
| `DocumentsStore` | Source of truth for all documents. Owns `allDocuments` (each `DocumentItem` carries `thumbnail: NSImage`, initialised to a placeholder), `recentDocumentIDs` (`OrderedSet`), `favoriteDocumentIDs` (`OrderedSet`). Internally owns `ThumbnailStore` (acting as its delegate) and exposes `requestThumbnailIfNeeded(id:)` for on-demand thumbnail generation. Per-document actions surfaced from the item context menu: `setFavorite(id:isFavorite:)`, `removeFromRecents(id:)` (delegates to the data source), and `showInFinder(id:)` (reveals the file via `NSWorkspace`, no host round-trip needed). Exposes `clearAllRecents()` and `clearAllFavorites()` which delegate to the data source. Notifies observers on mutation and on thumbnail updates. |
| `ToolsStore` | Exposes the tool categories shown on the Tools page and forwards tool activation. Backed by a host-provided `HomeTabToolsDataSourceProtocol` (injected via the gateway). Tools are static — there is no observer pattern. |
| `PageCustomizationStore` | Framework-owned persistence (`UserDefaults`) for per-page section order + visibility. Callers operate on `PageCustomization` via `customization(for:availableItems:)` (load, validated against the page's current sections) and `setCustomization(_:for:)` (persist, clearing the override when back at default). The on-disk `Layout` of raw section-ID strings, and `validate`, are private encoding details. |

`DocumentsStore` uses a manual observer pattern (not Combine `@Published`) so that `@MainActor`-isolated mutations and notifications are explicit. Observers register via `addObserver(_:) -> AnyCancellable`. The weak-reference bookkeeping behind `addObserver`/`notifyObservers` is shared via `ObserverList<Observer>` (`Core/ObserverList.swift`), used by every controller in this table plus `HomeTabBannerController`, `RecentsLimitTipController`, and the host-app data sources — each controller only owns the notify call and its own `XObserver` protocol.

### Document identity (`DocumentID` and URL normalization)

A document is identified by its file URL, but the *same* file can arrive spelled two different ways: recents and the open document carry the unresolved system path (e.g. `/var/folders/…/Getting Started.pdf`), while a favorite restored from a security-scoped bookmark comes back with symlinks resolved (`/private/var/…`). On macOS `/var`, `/tmp`, and `/etc` are symlinks into `/private`, so a literal URL comparison treats the two spellings as different documents — which previously broke favorite sync for files under those paths (the bundled *Getting Started* guide, which is opened from `NSTemporaryDirectory()`, being the reliable repro).

`DocumentID` therefore compares and hashes on a **normalized** form rather than the raw URL:

- `URL.symlinkNormalizedURL` (`Utils/URL+SymlinkNormalized.swift`) returns `standardizedFileURL.resolvingSymlinksInPath()`. It is the single transform applied on every comparison surface so the spellings converge — apply it on one side only and they still won't match.
- `DocumentID` keeps its original `url` for operations that must round-trip it back to their source (notably removing a recent, which the host matches by the *unresolved* system URL), but derives a private `normalizedURL` once in `init` (via `symlinkNormalizedURL`) and uses that for `==`/`hash`. So two `DocumentID`s for the same file are equal even when their `url`s differ, and a `Set`/`OrderedSet` collapses them to one (fixing duplicate tiles and the recents-tile favorite star).
- On the host side, `HomeTabDocumentsDataSource` normalizes favorite URLs on write (`setDocumentFavorite`, `reloadFavorites`) so the stored set never holds duplicate spellings, and `HomeTabFeature.isFavoriteDocument(at:)` normalizes the query before the membership check.

**Caveat:** `resolvingSymlinksInPath()` only resolves the symlink for paths that **exist on disk**; for a missing file the two spellings do not converge. This is not a practical hole — every live comparison involves a file that exists (the document is open, bookmarks fail to resolve for missing files and are dropped, and recents are pre-filtered by existence) — but it is why the `DocumentIDTests` create real files on disk.

### HomeTabBannerController

`HomeTabBannerController` is a lightweight UI-layer controller (not part of `HomeTabStore`) that manages the banner `NSView` currently displayed on the Home page. It lives outside `HomeTabStore` because banners are UI-owned — they are injected by the host via `HomeTabViewController.setBannerView(_:)` and have no domain-level state.

- The host calls `HomeTabViewController.setBannerView(_:)`, which forwards to `HomeTabViewModel.setBannerView(_:)`, which calls `HomeTabBannerController.setBannerView(_:)`.
- `HomePageViewModel` observes the controller via `HomeTabBannerControllerObserver` and calls `objectWillChange.send()` on every change so SwiftUI picks up the new `presentedBannerView`.
- One `HomeTabBannerController` is created per `makeHomeTabViewController()` call, so each tab instance manages its banner independently.

### Thumbnail loading

`DocumentItem.thumbnail` is always a valid `NSImage`. Until real generation completes, it is the loading placeholder (`ImageResource.Thumbnails.loading`). Clients (e.g. `DocumentItemView.onAppear`) call `store.documents.requestThumbnailIfNeeded(id:)` to kick off generation; the call is a no-op if generation is already in progress or complete. `ThumbnailStore` (owned privately by `DocumentsStore`) handles caching and deduplication, and notifies `DocumentsStore` via `ThumbnailStoreDelegate` when a thumbnail is ready. `DocumentsStore` then updates `allDocuments` and calls `notifyObservers()`, delivering the result through the standard `documentsDidChange` callback.

`HomeTabThumbnailGeneratorProtocol` (public) is the injection point for thumbnail generation. It returns `HomeTabThumbnailGeneratorResult`, an enum with `.success(NSImage)`, `.broken`, and `.locked` cases. `ThumbnailStore` maps the result to a concrete `NSImage` before caching:

| Result | Stored / delivered image |
|---|---|
| `.success(image)` | the generated thumbnail |
| `.locked` | `ImageResource.Thumbnails.locked` |
| `.broken` | `ImageResource.Thumbnails.broken` |

The resolved image (including the placeholder paths) is cached — so a broken or locked file does not retry on every UI request.

The placeholder assets (`loading`/`locked`/`broken`) ship light and dark appearance variants. Because `DocumentItemView` renders the `NSImage` through SwiftUI's `Image(nsImage:)`, which caches the rasterization for the appearance active at first render, the image view is given an identity keyed on `@Environment(\.colorScheme)` (`.id(colorScheme)`). This forces a re-render when the theme is switched at runtime, so a placeholder always matches the current appearance.

The concrete generator lives in the **host** (`PDFExpertMac`), not the framework, and is provided through the gateway as `HomeTabFeatureGatewayProtocol.thumbnailGenerator` — the same injection pattern as `documentsDataSource` and `toolsDataSource`. `HomeTabStore.make` reads `gateway.thumbnailGenerator` and passes it down to `DocumentsStore` → `ThumbnailStore`. The framework only declares the protocol and result enum; it holds no concrete generator. The host's `HomeTabThumbnailGenerator` is backed by `RDPDFMacOSDocumentThumbnailGenerator` (RDPDFKit), mapping its `.ok`/`.locked`/`.broken`/`.invalidPath` outcomes onto the result enum.

## Roles

**`HomeTabFeature`**
- Long-lived owner of `HomeTabStore`.
- Factory for `HomeTabViewController` instances; each call returns a new independent VC backed by the shared controller.
- Also a factory for the standalone split-screen Home page via `makeSplitHomeViewController(delegate:)` (see *Split-screen Home page*).
- Holds the `HomeTabFeatureGatewayProtocol` and forwards it to each coordinator it creates.

**`HomeTabFeatureGatewayProtocol`**
- Bridge out of the framework for actions that must escalate to the host app (e.g. open Preferences, Help Center, open file picker, open dropped files).
- `openDroppedDocuments(at: [URL])` passes all dropped URLs in one call, matching the existing new-tab drop-zone pattern.
- Provides the host-implemented dependencies consumed by `HomeTabStore`: `documentsDataSource` (`HomeTabDocumentsDataSourceProtocol`), `toolsDataSource` (`HomeTabToolsDataSourceProtocol`), and `thumbnailGenerator` (`HomeTabThumbnailGeneratorProtocol` — see *Thumbnail loading*).
- Implemented by the host (`HomeTabFeatureGateway` in `PDFExpertMac`); held weakly by `HomeTabCoordinator`.

**`HomeTabDocumentsDataSourceProtocol`**
- Host-provided source of the recent and favorite documents (`recentDocumentURLs`, `favoriteDocumentURLs`) consumed by `DocumentsStore`.
- `removeRecentDocument(at:)` removes a single document from Recents (backed by `RecentPDFDocumentsController.forgetRecentPDFDocument(at:)` in the host), distinct from `clearAllRecentDocuments()` which clears them all.
- `dateOpened(for:) -> Date?` returns the date a document was last opened, used by `DocumentItem.dateOpened`. The app opens documents through its own flow rather than LaunchServices, so Spotlight's `kMDItemLastUsedDate` is never updated; instead `RecentPDFDocumentsController` owns the whole mechanism — it records each open in a private extended attribute from `noteNewRecentPDFDocument` and exposes `lastOpenedDate(for:)` to read it back, falling back to Spotlight's `kMDItemLastUsedDate` for documents last opened before the app started recording its own date (or opened by another app). `HomeTabDocumentsDataSource.dateOpened(for:)` just delegates to that. Returns `nil` when neither source has a date (the date label is then hidden).
- Implemented by `HomeTabDocumentsDataSource` in `PDFExpertMac`.

**`HomeTabToolsDataSourceProtocol`**
- Host-provided source of the tools surface inside Home Tab:
  - `toolCategories` — full list shown on the Tools page, grouped into `HomeTabToolCategory` (`HomeTabTool` carries `id`, `name`, `isNew`, `icon`, and its own `action` closure).
  - `personalizedTools` — a curated single-row list shown in the "Tools for You" section on the Home page (currently a static set; expected to be derived from user activity in a future iteration).
- Each `HomeTabTool` owns its action — there is no separate `activateTool` call; views invoke `tool.action()` directly on tap.
- Implemented by `HomeTabToolsDataSource` in `PDFExpertMac`, which maps each tool id to its `QuickAction` (reusing the Tools Tab actions) and runs it via `QuickActionsRunner`. Availability gating (DMG-only, macOS-version, feature flags) is applied per tool in both lists.

**`UIFactory`**
- Centralises all view and ViewModel construction.
- Holds `HomeTabStore` and `HomeTabBannerController`, injecting each into the ViewModels that need them.
- Created fresh per `makeHomeTabViewController()` call; lifetime is tied to the coordinator.

**`HomeTabCoordinator`**
- Caches page `NSView`s (one per `HomeTabPage`, created lazily via `UIFactory`).
- Owns `HomeTabNavigationStateContainer` and drives page transitions via `setCurrentPage(_:)`.
- Delegates all construction to `UIFactory`; contains no `HomeTabStore` reference itself.
- Exposes `openMenuBarSettings()` — opens System Settings to the Menu Bar & Dock panel via a URL scheme; called by `RecentsPageViewModel` from the recents-limit tip banner.

**`HomeTabNavigationStateContainer`**
- Source of truth for the currently active page.
- Decouples the coordinator (writer) from the view model (reader) without a direct reference between them.

**`HomeTabViewModel`**
- Observes `HomeTabNavigationStateContainer` to keep `currentPage` in sync with SwiftUI.
- Forwards `setSelectedPage` calls to the coordinator (weak ref).
- Proxies `setBannerView(_:)` calls from `HomeTabViewController` to `HomeTabBannerController`.

**`HomeTabViewController`**
- Hosts the SwiftUI root view (`HomeTabRootView`) inside `FileDropHostingView`.
- `FileDropHostingView` is a thin `NSView` wrapper that registers for `fileURL` drag types. On drag enter/exit it reports the drag via `onDraggingChanged` (the tab forwards it to `HomeTabViewModel.setIsDraggingInProgress`); on drop it passes the file URLs to `onDropFiles` (the tab forwards them to `HomeTabViewModel.openDroppedDocuments(at:)`). An optional `shouldAcceptDrop` predicate lets the owner reject a drag by its file URLs — a rejected drag shows no overlay and delivers no drop; the verdict is computed once on drag enter and cached for the duration of the drag. The standalone tab sets no predicate, so it accepts any number of files.
- `HomeTabRootView` composes the page container and the sidebar in a `ZStack` — the sidebar is a full-height, edge-to-edge `.thinMaterial` panel pinned to the leading edge, overlaid on top of the page content rather than sitting in an `HStack` next to it. This lets page content extend full-width under the sidebar while the page-level layout reserves space for it (see *Page Layout*).
- Shows a `DropOverlayView` overlay (dashed border + label) when `isDraggingInProgress` is true, covering the entire tab content area.
- Single strong owner of both coordinator and view model for the tab's lifetime.
- Exposes `setBannerView(_:)` as the public host API for injecting a banner `NSView` into the Home page. Forwards to `HomeTabViewModel`, which forwards to `HomeTabBannerController`.

**`PageViewModel` (e.g. `RecentsPageViewModel`)**
- Owns the UI state for a single page.
- Holds `HomeTabStore` strongly to read/mutate domain state.
- Calls coordinator weakly to trigger navigation to another page.
- `HomePageViewModel` is the exception: instead of holding the coordinator, it forwards user actions (open file picker, open document, "View All") through a `HomePageViewModelDelegate` (weak). `HomeTabCoordinator` conforms to this delegate for the full-tab path; `SplitHomePageController` conforms to it for the split-screen path. `HomePageViewModel` also carries an `isStandalonePage` flag — when `true` (the Home page is shown on its own, e.g. a split-screen pane) the "View All" buttons are hidden and the page is laid out without sidebar space.
- `HomePageViewModel` additionally holds `HomeTabBannerController` strongly and observes it via `HomeTabBannerControllerObserver` to surface `presentedBannerView` to SwiftUI.
- `RecentsPageViewModel` persists the "recents limit tip dismissed" flag via `@UserDefault("homeTab.recentsLimitTipDismissed")`; it calls `coordinator?.openMenuBarSettings()` when the user taps "Open Settings" in the tip.

## Page Layout

All pages share a single layout container — `HomeTabPageView` (`Pages/Common/HomeTabPageView.swift`) — so they line up consistently against the sidebar. Pages do **not** declare their own top-level `ScrollView`; they wrap their body content in `HomeTabPageView { … }` and let it handle scrolling and gradient fades, then apply `.pageSection()` to each top-level section so it takes the shared content width.

```swift
var body: some View {
    HomeTabPageView {
        VStack(alignment: .leading, spacing: …) {
            self.header
                .pageSection()
            self.someSection
                .pageSection()
        }
    } emptyState: {
        if self.model.items.isEmpty {
            self.emptyState
        }
    }
}
```

`HomeTabPageView` is a small struct (not itself a `View`) that exposes `callAsFunction(_:emptyState:)` so the trailing-closure call site reads like a regular SwiftUI container. It provides:

- The content is always wrapped in a vertical `ScrollView`. The optional `emptyState:` builder (defaults to `EmptyView`) is laid out as a *non-scrolling overlay* on top of it, inset by the sidebar width. Pages that can render an empty state (Recents/Favorites) build it conditionally inside that closure (`if model.items.isEmpty { … }`), which keeps the page's view identity stable so empty ↔ non-empty transitions can animate.
- The content stack gets an **ideal width of 880pt** (`HomeTabPageView.pageWidth`), `padding(.vertical, 40)`, and is centered horizontally — but this is only an *ideal* width, not a hard frame. Each top-level section must opt in to the fixed column width itself via the `View.pageSection(additionalWidth:)` modifier (declared next to `HomeTabPageView`), which pins it to `pageWidth + additionalWidth`, centered. A section without `.pageSection()` sizes to its own content instead of lining up with the rest of the page.
- A reserved leading inset equal to `sidebarWidth` (216pt, `HomeTabPageView.sidebarWidth`) so content never collides with the sidebar. Skipped when `isStandalonePage` is `true` (split screen, where no sidebar is shown), in which case content is centered evenly across the full width.
- On macOS 14+, an `overlay` using a custom `Layout` (`PageOverlayLayout`) places two gradient spacers (`.rdElevatedL0Color → .clear`) directly over the leading/trailing edges — computed from `sidebarWidth`/`pageWidth` against the overlay's own bounds, independent of the content stack's layout. These fade content under the sidebar on the left and against the trailing edge on the right. Horizontal section scrollers (e.g. Home Page Recents/Favorites) use `.scrollClipDisabled()` so their items glide *under* these gradients rather than getting hard-clipped. Below macOS 14 there is no gradient overlay.

Pages should generally apply small per-element horizontal padding (e.g. `.padding(.horizontal, 10)` on titles and section headers) rather than padding the whole content stack, since `.pageSection()` already fixes the outer bounds.

### Horizontal scroll controls (Recents/Favorites)

The Home page's `DocumentsSectionView` rows add hover-revealed arrow buttons on top of the existing `ScrollView(.horizontal)` — all gated to **macOS 14+** (where that scroll view exists; macOS 12–13 keeps the static stack with no controls). The pieces live in `Pages/Common/HorizontalScrollControls.swift`:

- **`HorizontalScrollState`** (`ObservableObject`) drives the *backing* `NSScrollView`. SwiftUI has no public content-offset API for a horizontal `ScrollView` before macOS 15, so the state captures the AppKit scroll view and moves its clip view directly (`setBoundsOrigin` + `reflectScrolledClipView`). It publishes `canScrollLeft/Right`, recomputed from both the clip view's `boundsDidChangeNotification` (scroll position) and the document view's `frameDidChangeNotification` (content size — so items added after the scroll view exists re-enable the trailing arrow), and snaps arrow scrolls to the item grid.
- **`HorizontalScrollReader`** is a zero-size `NSViewRepresentable` placed *inside* the scroll content (as a `.background` on the items stack) so `enclosingScrollView` resolves to the horizontal scroll view — **not** the outer vertical page scroll view. It hands that scroll view to the state in `viewDidMoveToWindow`, but only while it is on screen (`window != nil`) — during a SwiftUI rebuild (e.g. clear-all → re-add) an off-screen reader being torn down could otherwise overwrite the binding with its stale, zero-width scroll view.
- **`ScrollArrowButton`** is the leading/trailing chevron (`chevron.compact.left/right`) with its own `ScrollArrowButtonStyle` — a `.thickMaterial` capsule with a hover overlay and a press dim.
- **`.horizontalScrollControls(isVisible:state:itemPitch:)`** overlays the arrows, shown only when `isVisible` (the caller passes `false == documents.isEmpty`), the row is hovered (via `onHoverWhenWindowActive`), *and* scrollable in that direction. The overlay widens its own frame via `.pageSection(additionalWidth: 36)` so the buttons can overhang 18pt past the document row on each side. Clicking an arrow steps one item, snapped to the grid; `itemPitch` is `itemWidth + columnSpacing` (148). There is intentionally **no drag-to-scroll gesture** — it was removed because a drag-to-scroll keeps the cursor over the pressed item, so macOS would deliver a spurious tap and open the document on release.

## Project File (`HomeTab.xcodeproj`)

`HomeTab.xcodeproj` uses **synchronized file groups** (`PBXFileSystemSynchronizedRootGroup`) for the `HomeTab` and `HomeTabTests` source folders. Source files are included by their presence on disk — there are **no per-file references in `project.pbxproj`**. So **adding or deleting a `.swift` file needs no project-file edit**: create/`git rm` it on disk and it is picked up automatically. (Contrast `xcconfig`-level changes and target/framework *linkage*, which still live in `project.pbxproj` and must be edited there — see [xcode-project-rules.md](../../../Documentation/Agents/xcode-project-rules.md).)

## Split-screen Home page

In the host app's in-app split view, the "open PDF document" pane shows the Home page (no sidebar, no page navigation) instead of the file-browser pane. This is a deliberately minimal embedding — only the Home page, none of the tab/navigation infrastructure.

- `HomeTabFeature.makeSplitHomeViewController(delegate:)` builds a `SplitHomePageController` (an `NSViewController` that hosts `HomePageView` via `NSHostingView`). It reuses the feature's shared `HomeTabStore`, so recents/favorites/tools data is identical to the full Home tab.
- `SplitHomePageController` is the lifecycle root: it owns the `HomePageViewModel` strongly and is set as its `HomePageViewModelDelegate`. The view model is built with `isStandalonePage: true`, so "View All" buttons are hidden and the page is laid out without sidebar space.
- User actions are forwarded out to the host via `HomeTabSplitDelegate` (public):
  - `homeTabSplitDidSelectDocument(at:)` — the host opens the document in the pane and restores the split to 1:1.
  - `homeTabSplitOpenFilePicker()` — the host shows the open panel and routes the chosen URL the same way.
  - `homeTabSplitOpenDroppedDocuments(at:)` — the host opens a dropped document in the pane.
- Like the full tab, the controller wraps its `NSHostingView` in a `FileDropHostingView`. Because a split pane can host only a single Home-opened document, it sets `shouldAcceptDrop` to accept exactly one file: multi-file drags show no drop overlay and are ignored on drop.
- This path is gated behind `HomeTabFeature.isEnabled` in the host; when the feature is off, the original file-browser pane is used unchanged.

## Adding a New Page

1. Add a case to `HomeTabPage`.
2. Add the display name to `HomeTabSidebarView.pageTitle(for:)`.
3. Create `Pages/<Name>/<Name>PageView.swift` with a SwiftUI `View` named `<Name>PageView` and `Pages/<Name>/<Name>PageViewModel.swift` with an `ObservableObject` named `<Name>PageViewModel`. The ViewModel takes `store: HomeTabStore` (stored `private let`) and `coordinator: HomeTabCoordinator?` (stored `private weak`).
4. Wrap the page's body in `HomeTabPageView { … }` (with an `emptyState:` trailing closure if the page has one) and apply `.pageSection()` to each top-level section (see *Page Layout*). Do not declare a `ScrollView` or width frame on the page itself.
5. Add a `case` to `UIFactory.makePageView(for:coordinator:)` that instantiates the ViewModel and wraps the view in `NSHostingView`.

## Page Customization (section reorder + show/hide)

The Home and Tools pages let users reorder — and, on Home, show/hide — their sections through a shared "Customize" popover. The pieces:

- **`PageCustomizationItem`** (`Pages/Common/PageCustomization/`) — a protocol (`customizationID` + `title` + `icon` + `canBeHidden`) that a page's own section model conforms to: `HomeSection` and `HomeTabToolCategory` conform directly, so title/icon are decorative additions that stay off the data models. Identity is `customizationID` (not `id`) so a type that's already `Identifiable` with a differently-typed id can still conform. `canBeHidden` defaults to `true`; `HomeSection.dropArea` returns `false`, so the Drop Area can be reordered among the visible sections but never dragged into the hidden group.
- **`PageCustomization<Item>`** — a value type holding the ordered `visible` / `hidden` items + `defaultOrder`, with `move` / `reset` / `isCustomized` and the "≥1 visible" invariant.
- **`PageCustomizationPopoverViewModel<Item>`** — owns the working `PageCustomization` (source of truth while the popover is open) plus the live drag state, and runs the analytic reorder logic + geometry. Seeded once from the page VM at init (the popover is recreated on every open, so it re-seeds each time) and publishes the new arrangement back through a closure on commit.
- **`PageCustomizationPopover<Item>`** — the shared 260pt popover. It owns the view model as `@StateObject` and is a thin rendering + gesture-wiring layer. Reorder is a single analytic `DragGesture`: rows are offset visually and the model is committed only on drop (`.onMove`/`.draggable` aren't used). The view model's `allowsHiding` is derived from the items — the hidden group exists only when at least one section has `canBeHidden == true`, so Tools (all categories non-hideable) is reorder-only with no group passed in. Its `canDrag(_:)` gates which rows respond to the gesture: the sole remaining visible section is immobile (it can't be reordered or hidden) and renders without its drag handle, while hidden sections always stay draggable so they can be brought back.
- **`PageCustomizationMenu<Item>`** — the page header control the pages actually place: it owns the popover presentation state and the customization model, renders a `CustomizeGlassButton`, and attaches the `PageCustomizationPopover`. The glass button itself (`CustomizeGlassButton`, in `Pages/Common/GlassButtons.swift`) is popover-agnostic — it just toggles its Customize ↔ Close label off a bound `isPresented` — so the popover wiring stays in the customization layer, not in the shared button file.
- **`PageCustomizationStore`** (`Core/`) — persistence; see *Domain Controllers*.

The store is the single layer that maps `Item ↔ SectionID`, generically over any `PageCustomizationItem`:

- `customization(for:availableItems:)` builds the arrangement from the persisted layout, or the default order when nothing is stored. The stored layout is validated against `availableItems` (the page's current sections in default order); on any mismatch (unknown / added / duplicate id, or empty visible) it wipes the store and returns the default — section changes between app versions are rare enough that a reset is acceptable.
- `setCustomization(_:for:)` persists, clearing the override when the arrangement is back at default, then **broadcasts to observers**.

Each page view model just calls these from `init` and on change (e.g. `store.customization.customization(for: .home, availableItems: HomeSection.allCases)`), passing its own sections — no per-page mapping code, no extensions.

**Cross-window sync.** `HomeTabStore` (and so `PageCustomizationStore`) is a single shared instance across all windows — `HomeTabFeature` is an app-level singleton, while each window gets its own `UIFactory`/coordinator/page view models. So a reorder committed in one window must reach the others. This works the same way recents/favorites/tools already do: `PageCustomizationStore` exposes `addObserver(_:) -> AnyCancellable` and the `PageCustomizationObserver` protocol; `setCustomization` calls `notifyObservers(for:)`. `HomePageViewModel` and `ToolsPageViewModel` observe it and reload their `@Published customization` for the matching `Page` on change. The committing window takes the same path (its own `updateCustomization` only *persists*; the broadcast is what updates `customization`), so there is a single data flow for all windows. An open popover is unaffected — it seeds its working copy once and dismisses when its window resigns key.

## Tests

The framework's unit tests live in their **own target, `HomeTabTests`** (sources under `Features/HomeTab/HomeTabTests/`) — a separate bundle, not part of the host app's `PDF Expert DMG Tests`. Current classes: `PageCustomizationModelTests`, `SectionCustomizationTests`, `ThumbnailStoreTests`.

**Run just these tests via the dedicated `HomeTabTests` scheme.** It is a *shared* scheme whose Test action is scoped to the `HomeTabTests` target — selecting it and pressing ⌘U (or, via the Xcode MCP, `RunSomeTests` with `targetName: "HomeTabTests"`) runs only these tests, no need to fish them out of the full plan.

Two non-obvious things about that scheme:

- **The scheme file is stored in `HomeTab.xcodeproj`** (`HomeTab.xcodeproj/xcshareddata/xcschemes/HomeTabTests.xcscheme`), because that project owns the `HomeTabTests` target. It is a *shared* scheme so it travels in git.
- **It is selected and run from the app project, `PDFExpertMac.xcodeproj`, not from `HomeTab.xcodeproj` opened on its own.** The framework pulls in s7 dependencies (RDPDFKit, RDDesignLibrary, …) that only resolve inside the app project (which references `HomeTab.xcodeproj` as a subproject) — opening `HomeTab.xcodeproj` standalone can't build them. With the app project open, the shared `HomeTabTests` scheme appears in the scheme list and runs with dependencies resolved.

`HomeTabTests` is also referenced by the app-level test plans (`xctestplan/PDF Expert Dmg.xctestplan` and `…AppStore.xctestplan`), so CI runs it as part of the full `PDF Expert Dmg` / `PDF Expert AppStore` runs too.
