From 2efe17e85958ba17493590c1eae202f204aeb3db Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Wed, 24 Jun 2026 00:41:13 -0400 Subject: [PATCH] Add mpv setup instructions to AGENTS.md --- AGENTS.md | 438 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35aa284 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,438 @@ +# Luminate — GNOME Jellyfin Client + +## Quick Facts + +| Attribute | Value | +|-----------|-------| +| **App ID** | `dev.bscubed.Luminate` | +| **Platform** | GNOME/Linux via Flatpak (GNOME Platform 50) | +| **Dev Platform** | macOS 13+ | +| **Language** | Swift 6 (`.v5` language mode) | +| **UI Framework** | **Adwaita for Swift** (wraps GTK4/libadwaita) | +| **API** | Jellyfin v10.11.10 via `swift-openapi-generator` | +| **Media** | Movies + TV Shows (v1 scope) | +| **License** | GPL-3.0 | + +--- + +## ⚠️ CRITICAL: Adwaita for Swift ≠ SwiftUI + +**This project uses [Adwaita for Swift](https://git.aparoksha.dev/aparoksha/adwaita-swift), NOT SwiftUI.** While the DSL syntax looks similar (declarative `View` structs, `@State`, `VStack`/`HStack`, modifiers), the frameworks are fundamentally different. + +### What Does NOT Transfer From SwiftUI + +| SwiftUI Assumption | Adwaita Reality | +|---|---| +| `List` | Use `ForEach` (vertical list) or `FlowGrid` (grid) | +| `ObservableObject` / `@StateObject` / `@ObservedObject` | **Does not exist.** Use `@State` for local state, pass dependencies via initializer or `@Injected` | +| `@EnvironmentObject` | **Does not exist.** Inject via constructor or `@Injected` | +| `@Published` | **Does not exist.** Mutate `@State` directly | +| `@AppStorage` / `@SceneStorage` | **Does not exist.** Use `SQLiteStore` + `PersistenceService` | +| `onAppear` / `onDisappear` | Exists as `.onAppear {}` but different behavior | +| `Button("Label") { }` | `Button("Label") { }` works, but icons use `icon: .default(icon: .iconName)` | +| `Image(systemName:)` | Use `Picture()` with `.data()` or `.icon()` | +| `ScrollView(.horizontal)` | `ScrollView { ForEach(items, horizontal: true) { } }` | +| `.padding()` / `.frame()` | Similar but different API surface | +| `.sheet()` / `.fullScreenCover()` | **Does not exist.** Use `.aboutDialog()`, `.shortcutsDialog()` etc. | +| `.task { }` modifier | **Does not exist.** Use `.onAppear { Task { } }` | +| `@MainActor` on views | Adwaita views are implicitly `@MainActor` | + +### Adwaita for Swift Key Concepts + +- **`@State`** — local view state only. Triggers view update on mutation. +- **`@Binding`** — shared state between parent/child views. +- **`@Injected`** — DI property wrapper; resolves from `DIContainer.shared` (e.g. `@Injected(\.client)`). +- **`View` protocol** — requires `var view: Body { get }` (not `some View`/`@ViewBuilder`). +- **`Body`** — a concrete type, not an opaque return (`some View`). +- **`VStack` / `HStack` / `Box`** — layout primitives. +- **`ScrollView`** — scrollable container with `.hscrollbarPolicy()` / `.vscrollbarPolicy()` modifiers. +- **`FlowGrid`** — wraps children into a responsive grid (custom C widget, ported from Gelata's flow_grid.rs). Prefer over `GtkFlowBox` which has stale size cache issues. +- **`AspectContainer`** — single-child container that preserves a fixed aspect ratio (custom C widget). Reports `height = width × ratio` during measure pass; does not delegate width to children. +- **`ForEach`** — list rendering; `ForEach(items)` for vertical lists, `ForEach(items, horizontal: true)` for horizontal scrolls. +- **`Picture()`** — image display via `.data()` or file path. +- **`StatusPage`** — centered hero page with icon, title, description (used for login screen). +- **`PreferencesGroup` / `EntryRow` / `PasswordEntryRow`** — form input widgets. +- **`SearchEntry`** — search input widget with `.text($binding)`. +- **`Spinner()`** — loading indicator. +- **`HeaderBar`** — window header with `.start`/`.end` sections. +- **`Menu` / `MenuButton` / `MenuSection`** — dropdown menus. +- **`LevelBar`** — progress bar (used for seek position). +- **`NavigationView`** — stack-based navigation with `NavigationStack` enum. +- **`.style("card")` / `.style("suggested-action")` / `.style("flat")`** — widget styling. +- **`.halign(.start)` / `.hexpand(true)`** — alignment and expand modifiers. + +### Always Load the Skill + +If an `adwaita-swift` skill is available in your toolset, **load it before working on this project**. It provides API reference, widget docs, and patterns without needing the full documentation in context. + +--- + +## Architecture + +### Target Dependency Graph + +``` +Luminate (executable) + ├── Pages/ HomeView, ItemPage (top-level navigation views) + ├── LuminateUI ───────────┐ + │ ├── Components/ │ poster cells, detail views, search, etc. + │ └── Utilities/ │ PageAnimationTracker, AnyView+Overflow + ├── LuminatePlayer ───────┤ + ├── LuminateDI ───────────┤ + ├── LuminateCore ─────────┤ + ├── CGtkWidgets ──────────┤ Custom GObject widgets (AspectContainer, FlowGrid) + └── LuminateAPI │ + └── All UI components are in LuminateUI +``` + +**Rule:** All UI components live in `LuminateUI`. `LuminatePlayer` is the only separate feature target with its own concerns. + +### Module Roles + +#### `LuminateAPI` — Generated API client + +| File | Purpose | +|------|---------| +| `LuminateAPI.swift` | Module namespace | +| `openapi.yaml` | Jellyfin OpenAPI spec v10.11.10 | +| `openapi-generator-config.yaml` | Generator config (public access, types + client) | + +Generated types are in `.build/`. Clean build required to regenerate. + +#### `LuminateCore` — Core library + +| File | Purpose | +|------|---------| +| `LuminateCore.swift` | Module namespace, re-exports `LuminateAPI`, `OpenAPIRuntime`, `OpenAPIURLSession` via `@_exported import` | +| `JellyfinClient.swift` | `actor` wrapping OpenAPI-generated `Client`. Exposes curated API surface: auth, items, search, playback reporting, favorites. Uses `AuthMiddleware` for `MediaBrowser` header injection. | +| `BaseItemDto+Display.swift` | Extensions on generated types: `Identifiable` conformance, `isMovie`/`isSeries`/`isEpisode` helpers, `runtimeString`, `yearString`, image tag accessors | +| `ImageService.swift` | `actor` with 2-tier image cache (`NSCache` memory + disk at `cachesDirectory/luminate/images/`) | +| `WebSocketClient.swift` | `actor` connecting to `ws(s)://server/socket`, parses JSON events, publishes via `NotificationCenter` | +| `Observation.swift` | `ObservationRegistrar`, `ObservableProtocol`, `@Observable` macro — reactive property-mutation tracking for classes. Wire via `ObservationRegistrar.onChange = { StateManager.updateViews() }` | +| `PageAnimationTracking.swift` | Protocol for page transition animation state: `isAnimating: Bool`, `markPush()`. Implemented by `PageAnimationTracker` in `LuminateUI` | +| `Page.swift` | Navigation `Page` enum (`.library`, `.folder`) used with `NavigationStack` | +| `PersistenceService.swift` | `PersistenceService` protocol + `AuthData` struct for auth/preference persistence | +| `SQLiteStore.swift` | `actor` implementing `PersistenceService` via SQLite (SQLite.swift) | +| `PersistenceService+Mock.swift` | `MockPersistenceService` for testing | + +#### `LuminateDI` — Dependency Injection + +| File | Purpose | +|------|---------| +| `DIContainer.swift` | `@unchecked Sendable` singleton with thread-safe `register`/`resolve`/`addObserver`; triggers `StateManager.updateViews()` on change | +| `Injected.swift` | `@propertyWrapper` that resolves from `DIContainer` and observes changes; auto-cleans up observer on deinit | +| `InjectionValues.swift` | Holds optional injectable values: `client`, `userId`, `imageService`, `webSocketClient`, `persistence`, `pageAnimationTracker` | + +#### `CGtkWidgets` — Custom C GObject Widgets + +| File | Purpose | +|------|---------| +| `aspect_container.h` / `aspect_container.c` | GObject subclass of `GtkWidget` — single-child container that reports `height = width × ratio` during `measure()`. Properties: `aspect-ratio` (float), `max-width` (int). Used via `AspectContainer` Swift wrapper. | +| `flow_grid.h` / `flow_grid.c` | GObject subclass of `GtkWidget` — reflowing grid that re-measures children during allocation (avoids stale size caches). Properties: `minimum-size`, `column-spacing`, `row-spacing`, `justify`. Used via `FlowGrid` Swift wrapper. | + +#### `LuminateUI` — Shared UI Components + +| File | Purpose | +|------|---------| +| `PageAnimationTracker.swift` | `@Observable`-free class implementing `PageAnimationTracking`. Tracks `isAnimating` state for 250ms page transition windows. Uses `Idle(delay: 250)` timer and manually calls `StateManager.updateViews()` when animation ends to flush deferred image data. Push-generation counter prevents stale timers. | + +All UI components live in `LuminateUI`, organized into `Components/` and `Utilities/` subdirectories. Files from the former `LuminateHome` and `LuminateLibrary` targets now reside here: + +| File | Purpose | +|------|---------| +| `HomePosterCell.swift` | Poster cell (200×300) with deferred image loading, uses `@Injected(\.client)` and `@Injected(\.pageAnimationTracker)` | +| `MediaRow.swift` | Horizontal scrolling row of poster cells with title + "See All" button; takes `navigation: Binding>` | +| `ItemGrid.swift` | Grid of items with header; takes `items: [BaseItemDto]`, `navigation: Binding>`, optional `title`. Renamed from `LibraryGrid`. | +| `PosterCell.swift` | Reusable poster cell (150×225). Uses `@Injected(\.pageAnimationTracker)` for deferred loading; `client` still via parameter. | +| `MovieDetailView.swift` | Full movie detail: backdrop hero, poster, metadata, action buttons (play, favorite, mark played), overview, cast grid, similar items | +| `TVShowView.swift` | TV show detail: backdrop, poster, metadata, season picker (horizontal buttons), episode list | +| `EpisodeList.swift` | Vertical list of episode rows with thumbnail, number, title, runtime, play button | +| `SearchView.swift` | Debounced search with `SearchEntry`, results via `getSearchHints()`. **`performSearch()` never called** — no `.onChange` or `onSubmit` trigger. `SearchResultRow` uses `@Injected(\.pageAnimationTracker)` for deferred image loading. | +| `Components/AspectContainer.swift` | Swift `Widget` wrapper for the custom aspect-ratio C widget. `init(aspectRatio:)`, modifiers: `.child {}`, `.maxWidth(Int?)`. | +| `Components/FlowGrid.swift` | Swift `Widget` wrapper for the custom flow grid C widget. Generic over `Element`/`Identifier`. Modifiers: `.minimumSize(Int)`, `.columnSpacing(Int)`, `.rowSpacing(Int)`, `.justify(FlowGridJustifySetting)`. | +| `Components/RatingBadge.swift` | Star rating display (`★ 7.5`) | +| `Components/PersonCell.swift` | Cast/crew display (avatar, name, role) | +| `Utilities/AnyView+Overflow.swift` | Extension on `AnyView` for GTK overflow behavior (`visible`/`hidden`), uses `CAdw` directly | + +#### `LuminatePlayer` — Video Playback + +| File | Purpose | +|------|---------| +| `LuminatePlayer.swift` | Module placeholder | +| `PlayerView.swift` | Player screen: binds `VideoPlayerWidget` + `PlayerControls`, reports playback start/stop to Jellyfin | +| `PlayerControls.swift` | Control bar: close, seek back, play/pause, seek forward, time/progress, fullscreen | +| `VideoPlayerWidget.swift` | **Placeholder** — shows "Now Playing" + URL. Planned to become mpv+GLArea custom `Widget` | + +#### `Luminate` — App Assembly + +| File | Purpose | +|------|---------| +| `Luminate.swift` | `@main` entry. `App` struct with `AdwaitaApp`, `Window` scene. On startup, creates `SQLiteStore` and tries to load saved session from SQLite. `ContentView` uses `NavigationView` with `NavigationStack` to switch between login, home, and library folder views. | +| `ServerSetupView.swift` | Login form: server URL, username, password. Calls `JellyfinClient.authenticate()`. Saves `AuthData` via `DIContainer.shared.values.persistence` on success. | +| `ToolbarView.swift` | Hamburger menu with About dialog and keyboard shortcuts dialog | +| `Localized.yml` | Localization strings (en/de) via the `localized` package | +| `Pages/HomeView.swift` | Main scrollable home: Continue Watching, Next Up, Recently Added rows + Library grid. Uses `@Injected` for client/userId. Takes `navigation: Binding>`. Parallel async data loading via `async let`. | +| `Pages/ItemPage.swift` | Scrollable wrapper for browsing a folder/library, uses `Clamp` + `ItemGrid` with `NavigationStack` | + +--- + +## Design Patterns + +### State Management +- **`@State`** for local view state (loading flags, data arrays, UI state) +- **`@State.rawValue`** — reads/writes `@State` storage **without** triggering `StateManager.updateViews()`. Used to store loaded image data silently during page animations; the re-render is deferred until the animation completes. +- **`@Injected`** for reactive dependency injection (client, userId, etc.) +- **`@Observable`** — custom macro (not SwiftUI) for class property mutation tracking. Adds `_$observationRegistrar` to the class and wraps stored `var` properties with get/set that call `ObservationRegistrar.didChange()`. Wire globally: `ObservationRegistrar.onChange = { StateManager.updateViews() }`. Use sparingly — triggers global re-render on every mutation. Do NOT use for properties mutated inside GTK signal handlers (`.pushed`/`.popped`) as the synchronous `updateViews()` may reference freed widgets. +- **Dependencies** passed via initializer properties in `LuminateUI` views +- **Child → parent communication** via closure callbacks (`onClose`, `onSeeAll`, etc.) or `@Binding` +- **No ObservableObject, no @StateObject, no @EnvironmentObject** +- **DI observation**: `@Injected` sets up an observer in `DIContainer` that calls `StateManager.updateViews()` when the injected value changes + +### Concurrency +- **Services are `actor`s** — `JellyfinClient`, `ImageService`, `WebSocketClient`, `SQLiteStore` +- **Views use `Task { }`** inside `.onAppear` or action handlers +- **`await MainActor.run`** to update `@State` from background tasks +- **`async let`** for parallel fetches (see `HomeView.loadHomeData()`) + +### Data Flow +``` +ServerSetupView → JellyfinClient (authenticate) → client + userId registered in DIContainer + ↓ +Luminate.loadSavedSession() → SQLiteStore.loadAuth() → if valid, register in DI → ContentView + ↓ +ContentView → NavigationView with: + - HomeView (uses @Injected for client/userId) + - ItemPage (for folder browsing) + - (future: detail views) + +HomeView → MediaRow → HomePosterCell (image from ImageService via @Injected(\.client)) +ItemGrid → HomePosterCell +SearchView → SearchHint results → tap → detail (not connected) +ContentView → activePlayerItem != nil ? PlayerView : NavigationView +``` + +### Navigation +- **`NavigationView` + `NavigationStack`** — stack-based navigation +- `Page` enum: `.library(item:)`, `.folder(title:items:)` +- `HomeView` pushes `.folder` when "See All" tapped +- `ContentView` switches between initial view (home) and pushed pages +- Detail views (`MovieDetailView`, `TVShowView`) are not yet connected to navigation +- **Note:** `NavigationView` is an Adwaita-for-Swift widget, not SwiftUI's +- **Page transition animation control:** `.pushed { tracker.markPush() }` and `.popped { tracker.markPush() }` start a 250ms window during which cells defer image data updates (see Image Loading Pattern) + +### Image Loading Pattern (repeated across views) +```swift +@State private var imageData: Data? +@Injected(\.pageAnimationTracker) var pageAnimationTracker + +// In view body: +if let data = imageData { + Picture().data(data) +} else { + Box(spacing: 0) {}.style("card") +} + +// In loadImage(): +guard let tag = item.primaryImageTag, let itemId = item.id else { return } +Task { + guard let url = await client.imageURL(itemId: itemId, imageType: .Primary, tag: tag, maxWidth: 300) else { return } + let service = ImageService() + let data = try? await service.loadImage(url: url) + // Defer UI update during page transition animations (250ms) + if pageAnimationTracker.isAnimating { + _imageData.rawValue = data // Silent write, no re-render + // When animation ends, StateManager.updateViews() fires → cell re-renders → shows Picture + } else { + imageData = data // Triggers re-render immediately + } +} +``` + +**Deferred loading rationale:** When a `Page` is pushed onto `NavigationStack`, cells render during the 250ms transition animation. If `imageData` is set immediately, `StateManager.updateViews()` re-renders the widget tree mid-transition, causing stutter. By checking `PageAnimationTracker.isAnimating` and using `rawValue`, image data is loaded eagerly (populating `ImageService`'s cache) but the UI update is deferred until the animation completes. The `NavigationView`'s `.pushed`/`.popped` signals call `tracker.markPush()` to start the 250ms window. + +### Dependency Injection +- **`@Injected`** property wrapper for reactive DI in Adwaita views +- `InjectionValues` struct defines injectable keys: `client`, `userId`, `imageService`, `webSocketClient`, `persistence`, `pageAnimationTracker` +- `DIContainer.shared.register(\.key, value:)` during startup/login +- `DIContainer.shared.resolve(\.key)` for manual resolution (fatalError if unregistered) +- **Note:** `Luminate` page views (`HomeView`) use `@Injected`; `LuminateUI` components (`ItemGrid`, `MovieDetailView`, `TVShowView`, `EpisodeList`) still use parameter-based injection for `client`/`userId`, but `PosterCell` and `SearchResultRow` now use `@Injected(\.pageAnimationTracker)` + +### Persistence +- `SQLiteStore` actor (implements `PersistenceService`) for persistent auth/preferences +- Database at `$XDG_DATA_HOME/dev.bscubed.Luminate/db.sqlite` (Linux) or `ApplicationSupport/dev.bscubed.Luminate/db.sqlite` (macOS) +- WAL journal mode, schema migration via `db.userVersion` +- Auth data saved after successful login, loaded on app launch for auto-login +- `MockPersistenceService` available for testing + +--- + +## Development Setup + +### Build & Run + +```bash +# Build +swift build + +# Run (macOS development) +swift run + +# Release build +swift build -c release +``` + +### Prerequisites + +- **macOS:** `brew install mpv` +- **Linux (Flatpak):** mpv is built as part of the Flatpak manifest; no manual install needed +- **Linux (direct):** `sudo dnf install mpv-libs-devel` (Fedora) or `sudo apt install libmpv-dev` (Ubuntu/Debian) + +### VS Code Dev Container +The `.devcontainer/` directory provides a Docker container with Swift 6 + libadwaita. Open the project in VS Code with the Dev Containers extension and Swift + CodeLLDB extensions. + +### GNOME Builder +Open the project folder in GNOME Builder. It auto-detects the Flatpak manifest and downloads dependencies. + +### Flatpak Build + +```bash +flatpak-builder --force-clean build-dir dev.bscubed.Luminate.json +flatpak-builder --run build-dir dev.bscubed.Luminate.json Luminate +``` + +--- + +## Coding Conventions + +### File Organization +- **One view per file** — each `View` struct in its own `.swift` file +- **Grouped by feature target** — `Sources/Luminate/` contains the app shell and `Pages/` subdirectory +- **Component subdirectories** — `LuminateUI/Components/` for reusable views, `LuminateUI/Utilities/` for helpers + +### Import Order +```swift +import Foundation +import Adwaita +import LuminateCore +// LuminateDI if using @Injected +``` + +### Visibility +- **`public`** on types and initializers that need to be accessed across targets +- **`internal`** (default) for types internal to a feature module +- Module placeholders use `public struct` + +### @State Initialization +```swift +init(item: ..., client: ..., userId: ...) { + self.item = item + _isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false) +} +``` + +### @Injected Usage +```swift +@Injected(\.client) var client +@Injected(\.userId) var userId +``` + +### View Structure +```swift +struct SomeView: View { + + @State private var data: [Type] = [] + var client: JellyfinClient + + var view: Body { + // layout + } + + private func loadData() { + Task { + // async work + } + } +} +``` + +### Formatting +- `.` at start of chained modifiers, indented 4 spaces from parent +- `@State` before `var` (non-state), before closures +- `Components.Schemas.BaseItemDto` fully qualified + +--- + +## Dependencies + +| Package | Purpose | Source | +|---------|---------|--------| +| **Adwaita for Swift** | Swift DSL over GTK4/libadwaita | `git.aparoksha.dev/aparoksha/adwaita-swift` (branch `main`) | +| **Localized** | YAML-based localization | `git.aparoksha.dev/aparoksha/localized` (branch `main`) | +| **swift-openapi-generator** | Generates API client from OpenAPI spec | `github.com/apple/swift-openapi-generator` (>=1.0.0) | +| **swift-openapi-runtime** | Runtime for generated OpenAPI client | `github.com/apple/swift-openapi-runtime` (>=1.0.0) | +| **swift-openapi-urlsession** | URLSession transport for OpenAPI | `github.com/apple/swift-openapi-urlsession` (>=1.0.0) | +| **SQLite.swift** | SQLite database for persistence | `github.com/stephencelis/SQLite.swift` (>=0.16.0) | + +--- + +## Known Gaps & Future Work + +- **`VideoPlayerWidget` is a placeholder** — needs mpv + `GtkGLArea` integration +- **`LuminateUI` views still use parameter injection** — `ItemGrid`, `MovieDetailView`, `TVShowView`, `EpisodeList` take `client`/`userId` as constructor params instead of `@Injected`. `PosterCell` and `SearchResultRow` now use `@Injected(\.pageAnimationTracker)` but `client` remains a parameter. +- **Detail views not connected to navigation** — `MovieDetailView`/`TVShowView` exist but cannot be reached from poster cells (no tap handlers on `HomePosterCell`/`PosterCell`) +- **`ContentView` `.library` case placeholder** — `NavigationView` branch for `Page.library` shows `Text("REPLACE ME")` +- **`SearchView` search method never called** — `performSearch()` exists but no `.onChange` or `onSubmit` triggers it +- **No CI/CD** — no GitHub Actions or other pipeline +- **Keyboard shortcuts are stubs** — `Ctrl+F`/`Ctrl+R` actions are empty +- **`README.md` is outdated** — still references "Adwaita Template" +- **No error handling UI** — most `Task` blocks use `try?` silently +- **`DIContainer` uses `@unchecked Sendable`** — relies on manual `NSLock` synchronization +- **`SQLiteStore.Connection` uses `@retroactive @unchecked Sendable`** — workaround for SQLite.swift's lack of Sendable conformance + +--- + +## Agent Instructions + +1. **Always load the `adwaita-swift` skill** before writing or modifying views +2. **Do NOT import or assume SwiftUI patterns** — the DSL is adwaita-swift +3. **Run `swift build`** after making changes to verify compilation +4. **Check existing patterns** before adding new views or features — the codebase has established conventions +5. **No formatters or linters** are configured — maintain the existing style +6. **`Components.Schemas.BaseItemDto`** is the main model type used throughout (generated by OpenAPI) +7. **No snapshot or UI testing framework** is available +8. **`LuminateCore` module** uses `@_exported import` for `LuminateAPI`, `OpenAPIRuntime`, and `OpenAPIURLSession` — these types are available to all feature targets without explicit imports +9. **All views requiring `Adwaita`** should import it explicitly in each file +10. **When adding new files**, place them in the correct feature target directory and ensure they're included in the right SPM target in `Package.swift` +11. **Use `@Injected` for DI** in new views (preferred over constructor parameter passing) — requires `import LuminateDI` +12. **Use `NavigationView` + `NavigationStack`** for navigation — do not add a separate navigation stack framework +13. **Clean `.build/` when `openapi.yaml` changes** — the OpenAPI generator plugin caches generated code +14. **Defer image loading during page transitions** — check `pageAnimationTracker.isAnimating` and use `_imageData.rawValue` to store loaded data silently; the `NavigationView`'s `.pushed`/`.popped` signals will trigger a re-render at 250ms to flush pending data +15. **Do NOT call `StateManager.updateViews()` inside GTK signal handlers** (`.pushed`, `.popped`, etc.) — use `Idle` to defer or rely on timer-based triggers instead +16. **Do NOT use `@Observable` for properties mutated in GTK signal handlers** — the synchronous `didChange()` → `StateManager.updateViews()` call will reference freed widgets during navigation transitions +17. **Every `.swift` file must start with a header comment** in this exact format: + ```swift + // + // .swift + // + // Copyright Brendan Szymanski + // + // This program is free software: you can redistribute it and/or modify + // it under the terms of the GNU General Public License as published by + // the Free Software Foundation, either version 3 of the License, or + // (at your option) any later version. + // + // This program is distributed in the hope that it will be useful, + // but WITHOUT ANY WARRANTY; without even the implied warranty of + // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + // GNU General Public License for more details. + // + // You should have received a copy of the GNU General Public License + // along with this program. If not, see . + // + // SPDX-License-Identifier: GPL-3.0-or-later + // + ``` + Use the git creation year of the file if available; otherwise use the current year. +18. **Test coverage is required for new functionality** — use the `swift-testing` skill and Swift Testing framework (`@Test`, `#expect`). Place tests in `Tests/LuminateTests/`. Test files must follow XCTest migration boundaries; existing tests provide a pattern. +19. **Run `swift test` after large changes** to verify no regressions. Run it before committing to catch breakage early. +20. **Always ask before committing** — never commit without explicit user permission.