# 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 `setBanner(_:)` 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 `HomeTabBanner` currently displayed on the Home page. It lives outside `HomeTabStore` because banners are UI-owned — they are injected by the host via `HomeTabViewController.setBanner(_:)` and have no domain-level state.

- The host calls `HomeTabViewController.setBanner(_:)`, which forwards to `HomeTabViewModel.setBanner(_:)`, which calls `HomeTabBannerController.setBanner(_:)`.
- `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 `setBanner(_:)` 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 `setBanner(_:)` as the public host API for injecting a `HomeTabBanner` 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

The window the tab lives in has a **minimum content width of 600pt** (`TabWindowController.preferredMinContentSize`, host side). It used to be 1176 — wider than half of a 13–14" display, so macOS silently refused Move & Resize halves/quarters and Split View whenever the Home tab was enabled. Everything in this section follows from that: at 600pt the page has to *narrow* rather than clip, so every section in it must be able to shrink. The 600 is design-given and deliberately **not** derived from the sidebar + column arithmetic — deriving it would let a future sidebar-width change silently move the window minimum.

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 a *non-scrolling overlay* on top of it, inset by the sidebar and held inside the same content column, so its centred text wraps in a narrow window instead of running out to the page edges. 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.
- A **responsive content column**, `min(pageWidth, available − 2 × 32)`. `pageWidth` (880pt, `HomeTabPageView.pageWidth`) is a *maximum*, not a hard frame: the content stack is padded by the minimum gutter on each side and keeps an ideal width of `pageWidth`, and each top-level section opts into the column via `View.pageSection()` (declared next to `HomeTabPageView`), which caps it at `pageWidth` and centers it. The column width is **never measured** — no `GeometryReader`, no breakpoint constant. A section without `.pageSection()` sizes to its own content instead of lining up with the rest of the page.
  - Consequence: a section must be able to *expand* to the width it is handed and to *shrink* below `pageWidth`. A section built from fixed-width children (e.g. a static row of six 140pt cards) overflows the column and drags the whole content stack off-centre, so such rows must scroll or reflow — see *Horizontal rows*. For the same reason a page's grid (`ToolsPageView`, `DocumentsCollectionView`) declares a **single `.adaptive` column pinned to the card width** instead of a fixed column count: it rewraps to fewer columns as the column narrows and never slices a card.
  - **The 32pt minimum gutter and 288pt maximum come from the design** — at the 600pt window minimum the design places a 320pt column 32pt inside the 384pt area right of the sidebar, and 288 is its fade width. Do not raise the minimum: it makes the *narrowest* column, and at 40 the column falls just short of the 288pt that two 140pt cards need, so every grid on every page drops to a single column.
  - `.pageSection()` wraps its content in a `ZStack` before applying the frames. `rdFrame` goes through a custom `Layout` on macOS 13 and 14, and a `Layout` is handed *no* subviews at all when the view it wraps is empty — an `EmptyView`, or `if`-content on its empty branch, which is what a page's `emptyState:` is whenever the page is **not** empty. RDSwiftUIKit asserts on any count other than one (it crashed the Home tab on macOS 13/14 debug builds); the stack is one subview however many children it has. A single-child `ZStack` costs nothing else.
- 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` that paints the page background over everything outside the column: an opaque strip behind the translucent sidebar, an opaque fill beyond the gutters, and the two gradient gutters themselves (`.rdElevatedL0Color → .clear`). These fade content under the sidebar on the left and against the trailing edge on the right. Horizontal section scrollers (every Home page row — see *Horizontal rows*) use `.scrollClipDisabled()` so their items glide *under* these gradients rather than getting hard-clipped. Below macOS 14 there is no gradient overlay.
  - It is a custom `Layout` (`PageOverlayLayout`) over four subviews — outer fill, gradient, gradient, outer fill — each handed an exact frame. The Layout derives the column the same way the content does, `min(pageWidth, available − 2 × minimumGutter)`, so nothing is measured and no `layoutPriority` juggling decides who gets the leftover space. Deriving it is what the earlier fixed-`pageWidth` version got wrong once the column became responsive: below `sidebar + pageWidth` the gutters collapsed to nothing while the content kept narrowing.

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.

A page's top header title is `.lineLimit(1)`: once the column narrows past it, it truncates rather than wrapping and pushing the page down. Its trailing controls keep their intrinsic width, so the title is the subview the header stack shrinks. Section titles still wrap.

### Hosting views (`ContainerSizedHostingView`)

Every Home tab hosting view — the tab root (`HomeTabViewController`), each cached page (`UIFactory.makePageView`), and the split-screen pane (`SplitHomePageController`) — is a `ContainerSizedHostingView` rather than a plain `NSHostingView`, because none of them may contribute Auto Layout content-size constraints. The name states the difference: its size comes from its container, never from its content.

- An `NSHostingView` created the plain way installs constraints derived from its SwiftUI content's minimum and ideal size. An Auto Layout requirement outranks the window's `contentMinSize`, so a page's own width requirement would hold the window at the page's full-width layout instead of the minimum window size, and a `ScrollView`'s ideal height (reported as its whole content height) breaks the hosting view's `fittingSize` (PEM-10080, asserted in `presentPagesManagementViewControllerAnimated`).
- On macOS 13+ `sizingOptions = [.maxSize]` drops both. `.maxSize` is kept only because the content is unbounded in both axes, so it constrains nothing.
- On macOS 12 `sizingOptions` does not exist, so only the ideal size can be silenced, and only from the SwiftUI side — the hosting view wraps its content in `ContainerSizedContent`, which applies `rdFrame(idealHeight: 0)` there and passes the content through unchanged on macOS 13+. That wrapper is also why the class is generic over the caller's content while its superclass is `NSHostingView<ContainerSizedContent<Content>>`: an `NSHostingView` subclass cannot re-wrap its own `rootView`, since the wrap changes the generic parameter. `init(rootView:)` takes the caller's view and wraps it; the inherited required initializer takes the already-wrapped form.

The content never needs those constraints: Auto Layout pins each of these views to fill its container.

### Horizontal rows (`HorizontalItemsRow`)

Every Home page row — Tools for You, Recents, Favorites, Opened Files — is one shared view, `HorizontalItemsRow` (`Pages/Common/HorizontalItemsRow.swift`), so all four behave identically. It is generic over its item content (a `@ViewBuilder` init parameter, evaluated into a stored value so the row holds no closure) and parameterised by `itemWidth` / `itemHeight` / `itemSpacing`, plus `showsScrollControls` and `arrowsBottomInset` (see *Scroll controls*). A single content value serves both of its branches, because a view-builder group works as stack children and as grid cells alike:

- **macOS 14+** — the items go into a horizontal `ScrollView` with `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` (snapping to whole cards), `.scrollClipDisabled()` (so items glide under the gradient gutters instead of being cut at the column edge), the arrow controls below, and `.scrollBounceBehavior(.basedOnSize, axes: .horizontal)` so a row whose items fit does not rubber-band (see *Scroll controls*).
- **macOS 12–13** — there is no horizontal `ScrollView`, so the items go into a `LazyVGrid` with a **single adaptive column** at `itemWidth`, clamped to one row's height (`.rdFrame(height:alignment: .top)`) and `.clipped()`. The framework fits as many whole cards as the column allows — fewer in a narrow window, the full row when wide, **never a sliced card**. Items past that point are cut off, which is acceptable because the Recents/Favorites/Tools *pages* remain the complete view of the same content on those systems (their grids are adaptive too).

The row sizes the scroll content, the grid column and the snapping pitch from the numbers it is given; it does **not** impose them on the items, so each caller keeps applying its own card frame. Tool cards and document cards deliberately share item width and spacing (`ToolItemView.Constants.itemSpacing`, matching `DocumentsCollectionView.Constants.columnSpacing`), so the scroll pitch is the same in every row.

Callers own their own animation: `DocumentsSectionView` applies `.animation(_:value:)` (keyed on the document ids) to the row, where it previously sat on the items stack.

Because the row applies `.pageSection()` itself (the modern branch must, so the unclipped scroller and its arrow overlay are both measured against the column), a section that contains one must **not** wrap the whole section in `.pageSection()` — the header opts in separately. See `HomePageView.section(for:)`'s `.tools` case and `DocumentsSectionView`.

#### Scroll controls

The arrow buttons on top of that `ScrollView` are gated to **macOS 14+** as well (macOS 12–13 gets no controls, since the fallback grid does not scroll). 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`, both answered in one pass over the same geometry: the clip view's bounds (scroll position), the clip view's frame (so a resize counts) and the document view's frame (so items arriving after the scroll view exists re-enable the trailing arrow). It also snaps arrow scrolls to the item grid.
  - Whether there is anywhere to scroll is **re-read on every layout pass**, not only on those notifications, because a layout pass is the first point at which the geometry is real. A row filled synchronously (the Home page's "Tools for You", unlike Recents and Favorites) gets no later notification to correct the initial "nowhere to scroll" answer, so its arrows stayed hidden until a wheel scroll moved the clip view. What makes a per-layout re-read safe is publishing **only actual changes** — an `@Published` write with an unchanged value invalidates the view that laid the state out, which lays it out again.
  - Bouncing is left to `.scrollBounceBehavior(.basedOnSize, axes: .horizontal)` on the row itself (note the `axes:` — it defaults to `.vertical`). A scroll view whose content fits still rubber-bands under a swipe by default, and "Tools for You" always fits at full width, so every row bounced with nothing to scroll to.
- **`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 on `viewDidMoveToWindow`, on every SwiftUI update and on every `layout()`, but only while 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. Its **hit box is a rectangle larger than the capsule on every side** (`hitAreaInset`): the capsule always overhangs the column onto the first or last card, which is a button too, so a click aimed at the arrow would otherwise fall through and open a document. That margin is invisible — the modifier subtracts it back out of its negative insets, so the capsule keeps its size and position.
- **`.horizontalScrollControls(isVisible:state:itemPitch:bottomInset:)`** overlays the arrows, shown only when `isVisible` (`HorizontalItemsRow` forwards its `showsScrollControls`, which callers set from `false == items.isEmpty`), the row is hovered (via `onHoverWhenWindowActive`), *and* scrollable in that direction. The overlay matches the content column via `.pageSection()` and lays the buttons out beyond it with a **negative** horizontal inset (the capsule overhang plus the hit margin), so the overhang stays correct at every column width.
  - **The hover region is widened by that same overhang** (`.padding(.horizontal, totalArrowBleed)` before `onHoverWhenWindowActive`, taken straight back out after, so layout is unchanged). Necessary because in a narrow window the column is *all* the width the row has, so the buttons sit outside it. Without the margin, the tracking area ends where the column does: moving onto an arrow un-hovers the row, the arrow fades out from under the pointer, and the click lands on the card beneath.
  - `bottomInset` pushes the arrows up from the row's vertical centre for items that are not visually centred on themselves — `DocumentsSectionView` passes one because its cards carry name + date below the thumbnail; the Tools row leaves it at 0. The number belongs to the caller's card, not to the shared control. Clicking an arrow steps one item, snapped to the grid via `itemPitch`. There is intentionally **no drag-to-scroll gesture** — it was removed because a drag keeps the cursor over the pressed item, so macOS would deliver a spurious tap and open the document on release.

### Banners (`HomeTabBanner`, `BannerLayoutView`, `BannerLayout`)

The Home page's banner is **not** built by this framework — the host app injects one through
`HomeTabViewController.setBanner(_:)` (see *HomeTabBannerController*) as a `HomeTabBanner`:

| Case | Used by | Sizing |
|---|---|---|
| `.content(AnyView)` | Support Ukraine, update available, trial/upgrade | laid out in the page's own SwiftUI hierarchy, like any other section |
| `.legacyView(NSView)` | DevMate license verification | Auto Layout; does not adapt to the column width |

`.content` is the case to use for anything new. It exists because the alternative — the host wrapping a
SwiftUI banner in an `NSHostingView` and this layer unwrapping it back into SwiftUI — destroys
height-for-width: an `NSView` handed to SwiftUI is measured through its `intrinsicContentSize`, and an
`NSHostingView` computes that at its content's *ideal* width, never at the width the page actually gives
the banner. An adaptive banner bridged that way reports its widest form's height at every width and has
the taller forms clipped. Recovering the right height from the AppKit side takes a detached measuring
twin plus `NSViewRepresentable.sizeThatFits`, and still cannot work below macOS 13. Not round-tripping
through AppKit removes the problem instead of paying for it.

`.legacyView` survives only because the DevMate banners are `StackBannerView`s shared with the document
viewer, which has no SwiftUI hierarchy to lay them out in. They do not adapt to the column and never did.
`BannerView` (`Pages/Common/BannerView.swift`) switches on the enum and bridges that one case back.

`RecentsLimitTipView` is banner-shaped but is **not** injected: it is a framework-internal section of the
Recents page, so it uses `BannerLayoutView` directly. Because `.content` banners are laid out the same
way, the injected and internal paths now behave identically at every width.

**`BannerLayoutView`** (`Pages/Common/BannerLayoutView.swift`) — public, takes slots for icon, title,
optional subtitle and buttons, and owns the two arrangements a banner ever takes: the **side-by-side**
form all of them have shipped with, and a **stacked** form (icon and text on top, buttons trailing-aligned
beneath) for when the content column is too narrow for the first.

**`BannerLayout`** (`Pages/Common/BannerLayout.swift`, macOS 13+) is the custom `Layout` that chooses
between them, over three subviews — icon, text column, buttons. The crossover is **derived, not
authored**:

```
sideBySideTextWidth = width − insets − icon − buttons − 2 × spacing
isSideBySide        = sideBySideTextWidth >= textColumnMinWidth
```

Every banner therefore gets its own crossover out of its own icon, buttons and insets, and a longer
localisation moves that crossover on its own. There is no window-width breakpoint anywhere.

- Placement is explicit rather than emergent: the buttons are anchored to the **trailing edge** rather
  than laid out after the text, so they stay put whatever natural width the text column settles on.
- An unspecified or infinite width proposal is a "how big would you like to be?" query, answered with the
  side-by-side form at the text's natural width.
- Icon size, `spacing` and `insets` are **per caller**, deliberately not unified on the current
  design-system values, so no banner's side-by-side appearance changes. The token drift between them is
  preserved, not fixed. The stacked form uses the narrow design's own insets.
- The banner's fixed height is a **minimum** (`minHeight`), so a banner grows as its title wraps rather
  than clipping it. Content is centred vertically, so a banner held open by `minHeight` does not sit
  against its top edge.

#### Below macOS 13

There is no `Layout` on macOS 12, so the banner stays **side by side at every width** and lets its text
wrap — growing the banner — instead of rearranging. Two details keep that acceptable:

- The text carries **no floor** there. The floor exists to make the crossover measurable, and there is no
  crossover.
- The buttons are **fixed at their ideal width** (`fixedSize(horizontal: true)`), so the shortfall lands
  on the text rather than the `HStack` also taking it out of the buttons — which is what wrapped
  "Install Now" onto two lines on Monterey.

Switching arrangement without `Layout` would mean a `GeometryReader`, and it is deliberately not done:
macOS 12 is the deployment floor, the banner still shows all of its copy, and the only loss is that a
narrow window gets a taller banner than the stacked design would give it.

## 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` in a `ContainerSizedHostingView`). 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.
- The pane's minimum width (`PDFDocumentsSplitViewController.splitHomePaneMinimumThickness`, 384) is the tab window's minimum content width less the sidebar the pane does not show, so the pane narrows in step with the tab and reaches the same narrowest content column. It had to come down with the window minimum — left as it was, the pane refused to narrow at all.
- Like the full tab, the controller wraps its hosting view 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 a `ContainerSizedHostingView` (see *Hosting views*) — not `NSHostingView` directly.

## 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 popover. Its width is dynamic: the frame declares `minWidth` 260pt / `maxWidth` 400pt (`PageCustomizationPopoverMetrics`) and the popover settles wherever the longest section title needs it to, so long translations aren't truncated. The one piece deliberately excluded from that measurement is the empty hidden group's hint — it asks only for `hiddenHintIdealWidth` (the narrowest its capsule can ever be, i.e. `minWidth` less the content insets) and wraps to as many lines as it needs at whatever width the rest of the content resolved to, reporting its laid-out height back through `onSizeChange` → `setHiddenPlaceholderHeight(_:)` so `contentHeight` reserves it. That measurement goes through `onSizeChange(_:)`, the shared helper in RDSwiftUIKit (`Extensions/View+OnSizeChange.swift`), which reads a laid-out size via the system `onGeometryChange(for:of:action:)` on macOS 13+ and via a `GeometryReader` + `PreferenceKey` below it (we still ship a macOS 12 deployment target). Height stays pinned to that exact `contentHeight` rather than a minimum, which would let the popover grow but never shrink back. Since no row width can be hardcoded any more, the floating dragged row lives in a top-aligned `.overlay` that stretches to the resolved width. 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.
