luminate-old/AGENTS.md

438 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<Page>` 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<NavigationStack<Page>>` |
| `ItemGrid.swift` | Grid of items with header; takes `items: [BaseItemDto]`, `navigation: Binding<NavigationStack<Page>>`, 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<Page>` 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<NavigationStack<Page>>`. Parallel async data loading via `async let`. |
| `Pages/ItemPage.swift` | Scrollable wrapper for browsing a folder/library, uses `Clamp` + `ItemGrid` with `NavigationStack<Page>` |
---
## 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<Page>`** — 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<Page>`** 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
//
// <FileName>.swift
//
// Copyright <year> Brendan Szymanski <hello@bscubed.dev>
//
// 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 <https://www.gnu.org/licenses/>.
//
// 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.