...
DATE
`
-- **Cover:** `
`
-- **Review %:** Score extracted from review section
-- **Tags:** From tag elements on the store page
-
-## GOG
-
-**Module:** `gog.rs`, `gog_auth.rs`
-
-### Authentication
-
-OAuth 2.0 using well-known Galaxy client credentials (same for all GOG Galaxy clients):
-- Client ID: `46899977096215655`
-- Client secret: `9d85c43b1482497dbbce61f6e4aa173a433796eeae2571571f...`
-- Redirect: `https://embed.gog.com/on_login_success`
-
-Token refresh is automatic on 401 responses.
-
-### Endpoints
-
-| Endpoint | Purpose |
-|----------|---------|
-| `embed.gog.com/user/data/games` | Owned games list (auth required) |
-| `api.gog.com/products/{id}` | Game metadata (public) |
-
-### Import Flow
-
-1. User logs in via OAuth (browser flow)
-2. Fetch owned game IDs from user endpoint
-3. Fetch metadata per game from public API
-4. Data: title, cover (`logo2x`), description (`lead`), release date
-
-**Note:** GOG does not expose achievement data via API.
-
-## Epic Games
-
-**Module:** `epic.rs`
-
-### Authentication
-
-None required. Reads local manifest files directly.
-
-### Data Source
-
-Scans `.item` JSON manifests from the Epic Games Launcher:
-
-| OS | Path |
-|----|------|
-| Windows | `C:\ProgramData\Epic\EpicGamesLauncher\Data\Manifests\` |
-| macOS | `/Users/Shared/Epic Games/Launcher/Data/Manifests/` |
-| Linux (Heroic) | `~/.config/heroic/store_cache/` |
-| Linux (Legendary) | `~/.config/legendary/installed.json` |
-| Wine/Proton | `~/.wine/drive_c/ProgramData/Epic/...` |
-
-### Extracted Fields
-
-- `DisplayName` → title
-- `CatalogItemId` → epic_id
-- `AppName` → internal reference
-- `AppCategories` → used to filter non-game content
-
-## Xbox Live
-
-**Module:** `xbox.rs`
-
-### Authentication
-
-Uses [OpenXBL](https://xbl.io) as a proxy API:
-- Header: `X-Authorization: {api_key}`
-
-### Endpoint
-
-| Endpoint | Purpose |
-|----------|---------|
-| `xbl.io/api/v2/player/titleHistory` | Title history with stats |
-
-### Import Flow
-
-1. Fetch title history
-2. Filter by device type: `XboxOne`, `XboxSeries`, `Xbox360`, `PC`, `Win32`
-3. Extract: title, `displayImage` (cover), `minutesPlayed`, `lastTimePlayed`, achievements (current/total)
-
-### Filtering
-
-The `is_game()` heuristic filters out apps by checking for Xbox-specific device categories.
-
-## PlayStation Network
-
-**Module:** `playstation.rs`
-
-### Authentication
-
-Multi-step OAuth flow:
-1. User provides **NPSSO token** (extracted from browser cookies at `store.playstation.com`)
-2. Exchange NPSSO → authorization code via `ca.account.sony.com/api/v1/oauth/authorize`
-3. Exchange code → access token via `ca.account.sony.com/api/v1/oauth/token`
-
-Uses hardcoded PSN Android app credentials for the OAuth client.
-
-### Endpoints
-
-| Endpoint | Purpose |
-|----------|---------|
-| `ca.account.sony.com/api/v1/oauth/authorize` | Authorization code |
-| `ca.account.sony.com/api/v1/oauth/token` | Access token exchange |
-| `m.np.playstation.com/api/graphql/v1/op?operationName=RetrieveTitleList` | Game library (GraphQL) |
-| `m.np.playstation.com/api/trophy/v2/trophyTitles` | Trophy counts |
-
-### Import Flow
-
-1. Exchange NPSSO for OAuth token
-2. Fetch game library via GraphQL
-3. Fetch trophy counts (bronze/silver/gold/platinum → converted to unlocked/total)
-4. Extract: title, playtime, last played, trophy data
-
-**Note:** No individual trophy details are available, only counts.
-
-## Nintendo
-
-No API integration. Games are added manually through the "Add Game" form with `Platform::Nintendo` selected.
-
-## HTTP Client
-
-All API calls go through `api_client.rs`:
-
-```
-Connect timeout: 10s
-Read timeout: 15s (API) / 30s (downloads)
-Retry strategy: Exponential backoff (500ms × 2^attempt)
- 4xx errors: fail immediately (no retry)
- 5xx/network: retry up to N times
-```
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
deleted file mode 100644
index 7c88cb3..0000000
--- a/docs/ARCHITECTURE.md
+++ /dev/null
@@ -1,147 +0,0 @@
-# Architecture
-
-Spotter is a cross-platform game library tracker built with **Rust** and the **Iced** GUI framework. It uses **SQLite** for local persistence and supports importing games from 5 platforms.
-
-## Module Map
-
-```
-main.rs Entry point → spotter::run()
-lib.rs Module tree, re-exports app::run
-app.rs (1300+ lines) State machine: Spotter struct, Message enum, update(), view()
-├── views/
-│ ├── library.rs Game list with search, filters, sort
-│ ├── detail.rs Single game: metadata, achievements, rating, notes
-│ ├── stats.rs Statistics dashboard with charts
-│ ├── preferences.rs Preferences page on the shared Colony kit
-│ ├── profile.rs User profile, credentials, data export/import
-│ ├── import.rs Platform import cards with step-by-step flows
-│ ├── sidebar.rs Navigation + quick stats
-│ └── add_game.rs Manual game entry form
-├── models.rs Data structures: Game, Preferences, Platform, Achievement, etc.
-├── i18n/ English and French strings (en.json, fr.json) and t/tf/tn
-├── db.rs SQLite: schema, CRUD, playtime history, export/import
-├── paths.rs File locations (colony_ui::paths) and the copy from Spotter 0.3
-├── preferences.rs preferences.json: lenient load, save, import from Spotter 0.3
-├── theme.rs Icons and style helpers on the Colony palette
-├── error.rs SpotterError enum (migration in progress)
-├── api_client.rs HTTP agents, retry logic, URL encoding, browser open
-├── images.rs Cover art + achievement icon download/caching
-├── steam.rs Steam import (Web API + HTML scraping)
-├── steam_auth.rs Steam OpenID login (localhost:29876)
-├── gog.rs GOG import (OAuth API)
-├── gog_auth.rs GOG OAuth 2.0 token exchange
-├── epic.rs Epic Games local manifest scanner
-├── xbox.rs Xbox Live import (OpenXBL API)
-└── playstation.rs PSN import (OAuth + trophy API)
-```
-
-## Data Flow
-
-```
-User Interaction
- │
- ▼
- Message enum (100+ variants)
- │
- ▼
- app::update() ──→ Task
(async operations)
- │ │
- ▼ ▼
- State mutation Background thread
- (Spotter fields) (API calls, DB writes)
- │ │
- ▼ ▼
- app::view() Result callback
- (renders UI) (re-enters Message loop)
-```
-
-All state mutations pass through the `Message` enum in `app.rs`. Async operations (API calls, DB writes) are spawned as `Task` and return results via callback messages.
-
-## Screen Navigation
-
-```
-┌──────────────┐ ┌────────────────────────────────────┐
-│ Sidebar │ │ Main Content Area │
-│ │ │ │
-│ Library ────┼───►│ Library (search, filter, game list)│
-│ + Add Game ─┼───►│ AddGame (form) │
-│ Statistics ─┼───►│ Statistics (charts, distributions) │
-│ Import ─────┼───►│ Import (platform cards) │
-│ Profile ────┼───►│ Profile (credentials, export) │
-│ │ │ │
-│ ── Quick ── │ │ GameDetail (from library click) │
-│ Playing: X │ │ Preferences (from the Spotter name)│
-│ Completed: Y│ │ │
-│ Unplayed: Z │ └────────────────────────────────────┘
-│ │
-│ [Username] │
-└──────────────┘
-```
-
-## Key Architectural Patterns
-
-### Message-Driven Updates
-
-Every user interaction produces a `Message` variant. The `update()` function pattern-matches on it, mutates state, and optionally returns an async `Task`.
-
-```rust
-// Example flow: rating a game
-Message::SetGameRating(game_id, Some(8))
- → find game by ID, set rating
- → spawn Task: save to DB
- → on completion: Message::DataSaved(Result)
- → show toast notification
-```
-
-### Async via Thread Spawning
-
-Iced uses a `Task` abstraction. Spotter wraps blocking operations in `std::thread::spawn`:
-
-```rust
-fn spawn_task(f: F, msg: fn(Result) -> M) -> Task
-```
-
-This runs `f` on a new thread and maps the result through `msg`.
-
-### Import Merge Logic
-
-When importing games from a platform, deduplication uses a multi-key index:
-
-1. **Platform ID match** (e.g., `steam_appid`) — most reliable
-2. **Title + Platform match** — for games without IDs
-3. **Title-only match** — last resort
-
-Existing user data (ratings, notes, status) is always preserved. Only empty metadata fields are backfilled from imports.
-
-### Theme System
-
-Colours come from colony-ui. `StandardPreferences::apply` sets the theme, accent and high contrast at startup and on every change; widgets read the active palette wherever they are drawn, including inside style closures:
-
-```rust
-text(label).color(Palette::TEXT_MUTED())
-container(content).style(theme::card_style(Palette::BG_CARD(), Palette::BORDER_SUBTLE(), 8.0))
-```
-
-Platforms and game statuses keep one colour on every theme: a shared accent swatch picked by key (`Platform::accent_key`, `GameStatus::accent_key`).
-
-## Dependencies
-
-| Crate | Version | Role |
-|-------|---------|------|
-| iced | 0.13 | GUI framework (with `image` feature) |
-| rusqlite | 0.32 | SQLite (bundled, zero system deps) |
-| ureq | 2 | Blocking HTTP client |
-| serde / serde_json | 1 | JSON serialization |
-| chrono | 0.4 | Date/time formatting |
-| csv | 1 | CSV export |
-| thiserror | 1 | Error derive macros |
-| dirs | 5 | Platform-specific directories |
-
-## Build Profiles
-
-```toml
-[profile.release]
-lto = "thin" # Link-time optimization
-strip = true # Strip debug symbols
-opt-level = 3 # Maximum optimization
-```
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
deleted file mode 100644
index 8e358cd..0000000
--- a/docs/CONFIGURATION.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Configuration
-
-All preferences are saved to `preferences.json` in Spotter's config folder (`~/.config/Colony/Spotter/preferences/preferences.json` on Linux; see [Data Storage](../README.md#data-storage) for the other platforms) and applied immediately on change. There is no Save button.
-
-## The Preferences page
-
-Clicking **Spotter** at the top of the sidebar opens Preferences in place of the current screen; clicking it again, the page's Close button or Esc goes back to that screen. The page is the shared Colony one (`colony_ui::widgets`): General, Appearance and Accessibility come first, then Spotter's own Library and Imports. Every section starts collapsed.
-
-### General
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `language` | `"en"`, `"fr"` or none | none | Interface language; none follows the system (`LC_ALL`, `LC_MESSAGES`, then `LANG`) |
-| `start_screen` | StartScreen | Library | Screen shown on launch (Library / Statistics / Import / Profile) |
-| `notifications_enabled` | bool | true | Show toast notifications for success messages (errors always show) |
-| `toast_duration` | ToastDuration | Normal (4 s) | Auto-dismiss duration: Short (2 s), Normal (4 s), Long (8 s) |
-| `date_format` | DateFormat | YYYY-MM-DD | Date display: YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY |
-
-### Appearance
-
-Theme, accent, text size, effects and a live preview come from the shared kit (see below). Spotter adds a Layout section:
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `sidebar_width` | u16 | 220 | Sidebar width in pixels (160-400, presets: 180/220/280) |
-| `compact_list` | bool | false | Reduced spacing in game list (4px vs 8px) |
-| `show_covers_in_list` | bool | true | Show cover thumbnails in library |
-
-### Accessibility
-
-Vision, Motion and Reading come from the shared kit. Spotter adds Color cues and Navigation:
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `show_status_labels` | bool | true | Show text labels alongside status dots |
-| `large_click_targets` | bool | false | Increase button padding (8px to 12px) |
-
-### Library
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `default_status` | GameStatus | Unplayed | Default status for new games |
-| `default_platform` | Platform | Steam | Default platform for new games |
-| `default_sort_order` | SortOrder | Title A-Z | Default library sort |
-| `remember_filters` | bool | true | Persist filter selections across sessions |
-| `show_game_descriptions` | bool | false | Show store descriptions in library cards |
-| `achievements_display` | AchievementsDisplay | Full | Full list / Summary only / Hidden |
-| `confirm_before_delete` | bool | true | Show confirmation dialog before deleting a game |
-
-### Imports
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `download_covers_auto` | bool | true | Automatically download cover art on import |
-
-### Shared Colony preferences
-
-Theme, accent, text sizes and the accessibility switches are the ones every Colony program has (`colony_ui::preferences::StandardPreferences`), saved in the same file under the same names:
-
-| Setting | Type | Default | Description |
-|---------|------|---------|-------------|
-| `selected_theme`, `selected_variant` | string | `gruvbox`, `dark` | A shared Colony theme; an unknown one falls back to Gruvbox dark |
-| `selected_accent` | string | none | Accent override (`red`, `orange`, `yellow`, `green`, `blue`, `indigo`, `violet`, `amber`); none uses the theme's own |
-| `auto_accent` | bool | false | Use the theme's accent even when one is picked |
-| `font_size` | small / default / large | default | Appearance text size |
-| `animations` | bool | true | Animations and fades |
-| `high_contrast` | bool | false | Push text and borders away from the background |
-| `dyslexia_font` | bool | false | Use OpenDyslexic |
-| `reduce_motion` | bool | false | Turn every animation off |
-| `text_size_a11y` | small / default / large / xlarge | default | Accessibility text size, multiplied with `font_size` |
-
-## Upgrading from Spotter 0.3
-
-Spotter 0.3 and earlier kept the settings in the library database, with three dark themes, six accents and an interface scale of its own. The first start of a newer Spotter imports them into `preferences.json`:
-
-| Old setting | Becomes |
-|-------------|---------|
-| `theme_mode` Dark / Darker / Midnight | Tokyo Night night / Material deep ocean / Modus vivendi |
-| `accent_color` Blue, Cyan / Purple / Green / Gold / Red | `blue` / `violet` / `green` / `amber` / `red` |
-| `ui_scale` Small / Normal / Large | `text_size_a11y` small / default / large |
-
-The old scale enlarged the whole window; the text sizes now enlarge the text only, and the Appearance and Accessibility sizes multiply (0.7225x to 1.68x).
-
-## Style Helpers
-
-Spotter defines no colour of its own. Widgets read the active Colony palette (`colony_ui::theme::Palette`, for example `Palette::TEXT_MUTED()`), so a theme, accent or high-contrast change reaches every screen. Platforms and game statuses use the shared accent swatches (`theme::shared_accent`), the same on every theme; text on a filled button takes `colony_ui::contrast_on` of the fill.
-
-Reusable style functions in `theme.rs`:
-
-| Function | Description |
-|----------|-------------|
-| `card_style(bg, border, radius)` | Rounded card container |
-| `chip_style(active, color, radius)` | Chip toggle: tinted with `color` when active, outlined otherwise |
-| `transparent_btn_style()` | Invisible button wrapper |
-| `primary_btn_style(bg)` | Solid filled action button, label in `contrast_on(bg)` |
-| `outline_btn_style(color)` | Outlined button on any surface |
-| `disabled_btn_style()` | Dimmed disabled button |
-| `loading_btn_style()` | Warning-tinted state for imports and logins in progress |
-| `danger_btn_style()` | Delete action |
-| `accent_card_style(accent)` | Card with a border tinted by `accent` |
-| `dot_style(color, size)` | Small circular indicator |
-| `pill_style(bg, border)` | Rounded status badge |
-
-## Languages
-
-The interface ships in English and French. Spotter's own strings are in `src/i18n/en.json` and `src/i18n/fr.json`; the Preferences skeleton and the theme and accent names come from `colony_ui::i18n`. Views never hold text: they call `i18n::t(key)`, `i18n::tf(key, vars)` to fill `{name}` placeholders, or `i18n::tn(key, count, vars)` for a string that depends on a count (`key_one` and `key_other`; French also uses the singular for zero). Numbers with decimals and percentages go through `i18n::decimal` and `i18n::percent`, which follow the French typography (`12,5 %`).
-
-English is canonical: name a new key after its English meaning, write the English first, then the French in both files. `cargo test` fails when the two files hold different keys, when a string is empty or the placeholders differ, when a key is missing or unused, or when one repeats a shared colony-ui label. Platform, program and theme names are never translated. Game statuses are stored in the database by their English name (`GameStatus` `Display`) and shown through `GameStatus::label`.
-
-## Preferences Schema Versioning
-
-Preferences include a `version` field (currently `2`: the move to `preferences.json`), and `Preferences` is `#[serde(default)]`, so a missing field takes its default.
-
-`preferences::parse` also reads the file one field at a time when the whole struct does not parse: a value this build does not accept (for example one added by a newer version) falls back to that field's default, and every other preference keeps its stored value.
-
-If the startup load fails anyway, for example because `preferences.json` is not JSON at all, Spotter shows an error and runs on defaults, and refuses every write of saved data for the rest of the session: preferences, profile and game saves, deletions, exports and the auto-backup are all refused (`Spotter::spawn_write`), so the data on disk stays as it was.
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
deleted file mode 100644
index 26e5157..0000000
--- a/docs/DATA_MODEL.md
+++ /dev/null
@@ -1,220 +0,0 @@
-# Data Model
-
-## Core Structures
-
-### Game
-
-The primary entity. 21 fields tracking a game across platforms.
-
-```rust
-Game {
- id: Option, // SQLite AUTOINCREMENT primary key
- title: String, // Display name
- platform: Platform, // Steam | Gog | Epic | PlayStation | Xbox | Nintendo
- playtime_minutes: u32, // Total play time
- achievements_unlocked: u32, // Player's unlocked count
- achievements_total: u32, // Available achievements
- status: GameStatus, // Playing | Completed | Unplayed | Dropped | Wishlist
- rating: Option, // 0-10 user rating (None = unrated)
- genre: String, // e.g. "Action RPG"
- last_played: String, // "YYYY-MM-DD" or "—" if never
- cover_url: String, // Remote URL for cover art
- steam_appid: Option, // Steam App ID (e.g. 1245620 for Elden Ring)
- gog_id: Option, // GOG product ID
- epic_id: Option, // Epic catalog item ID
- xbox_id: Option, // Xbox title ID
- psn_id: Option, // PSN game ID
- notes: String, // User notes (max 2000 chars)
- description: String, // Store description
- release_date: String, // "YYYY-MM-DD"
- review_percent: Option, // Store review score (0-100)
- tags: String, // Comma-separated tags
-}
-```
-
-**Builder pattern:**
-
-```rust
-GameBuilder::new("Elden Ring", Platform::Steam)
- .playtime(12000)
- .achievements(32, 42)
- .status(GameStatus::Playing)
- .rating(9)
- .genre("Action RPG")
- .steam_appid(1245620)
- .build()
-```
-
-### Platform
-
-```
-Steam → color: rgb(0.4, 0.6, 0.9) (blue)
-Gog → color: rgb(0.7, 0.5, 0.9) (purple)
-Epic → color: rgb(0.9, 0.9, 0.3) (yellow)
-PlayStation → color: rgb(0.2, 0.4, 0.9) (navy)
-Xbox → color: rgb(0.2, 0.8, 0.2) (green)
-Nintendo → color: rgb(0.9, 0.2, 0.2) (red)
-```
-
-### GameStatus
-
-```
-Playing → color: rgb(0.2, 0.8, 0.4) (green)
-Completed → color: rgb(0.3, 0.6, 1.0) (blue)
-Unplayed → color: rgb(0.9, 0.7, 0.2) (gold)
-Dropped → color: rgb(0.8, 0.3, 0.3) (red)
-Wishlist → color: rgb(0.7, 0.5, 0.9) (purple)
-```
-
-### Achievement
-
-Per-game achievement data. Only available for Steam games currently.
-
-```rust
-Achievement {
- api_name: String, // Internal ID (e.g. "ACHIEVEMENT_001")
- display_name: String, // "First Steps"
- description: String, // "Complete the tutorial"
- icon_url: String, // Colored icon URL (unlocked)
- icon_gray_url: String, // Grayscale icon URL (locked)
- unlocked: bool, // Player's unlock status
- unlock_time: u64, // Unix timestamp (0 if locked)
-}
-```
-
-### UserProfile
-
-Stores user identity and platform credentials.
-
-```rust
-UserProfile {
- username: String, // Display name (default: "Player")
- avatar_path: String, // Reserved for future use
- steam_api_key: String, // From steamcommunity.com/dev/apikey
- steam_id: String, // 64-bit Steam ID (from login)
- gog_token: String, // OAuth access token
- gog_refresh_token: String, // For automatic token renewal
- xbox_api_key: String, // From xbl.io
- xbox_gamertag: String, // Xbox username
- psn_npsso: String, // PSN browser cookie token
- member_since: String, // "YYYY-MM-DD" (auto-set on creation)
-}
-```
-
-### Preferences
-
-The user's preferences, saved to `preferences.json`, not to the database. See [CONFIGURATION.md](CONFIGURATION.md) for details.
-
-## Database Schema
-
-SQLite with WAL mode and foreign keys enabled.
-
-### games
-
-```sql
-CREATE TABLE games (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- title TEXT NOT NULL,
- platform TEXT NOT NULL,
- playtime_minutes INTEGER DEFAULT 0,
- achievements_unlocked INTEGER DEFAULT 0,
- achievements_total INTEGER DEFAULT 0,
- status TEXT DEFAULT 'Unplayed',
- rating INTEGER,
- genre TEXT DEFAULT '',
- last_played TEXT DEFAULT '',
- cover_url TEXT DEFAULT '',
- steam_appid INTEGER,
- gog_id TEXT,
- epic_id TEXT,
- xbox_id TEXT,
- psn_id TEXT,
- notes TEXT DEFAULT '',
- description TEXT DEFAULT '',
- release_date TEXT DEFAULT '',
- review_percent INTEGER,
- tags TEXT DEFAULT ''
-);
-```
-
-**Indexes:** `platform`, `status`, `steam_appid`, `title`, `rating`, `last_played`
-
-### achievements
-
-```sql
-CREATE TABLE achievements (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- steam_appid INTEGER NOT NULL,
- api_name TEXT NOT NULL,
- display_name TEXT DEFAULT '',
- description TEXT DEFAULT '',
- icon_url TEXT DEFAULT '',
- icon_gray_url TEXT DEFAULT '',
- unlocked INTEGER DEFAULT 0,
- unlock_time INTEGER DEFAULT 0,
- UNIQUE(steam_appid, api_name)
-);
-```
-
-### playtime_history
-
-```sql
-CREATE TABLE playtime_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- game_id INTEGER NOT NULL REFERENCES games(id),
- date TEXT NOT NULL, -- "YYYY-MM-DD"
- minutes_played INTEGER DEFAULT 0
-);
-```
-
-### user_profile
-
-Single-row table (id = 1) storing `UserProfile` fields as individual columns.
-
-The credential columns (`steam_api_key`, `gog_token`, `gog_refresh_token`, `xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) stay empty: `keyring.rs` keeps those secrets in the OS keyring, or, when no keyring is usable, in `credentials/tokens.json` in the config folder. A value here comes from Spotter 0.3 or earlier, or from a save that could not write that file, and `keyring.rs` moves it out on the next start.
-
-### settings
-
-Single-row table (id = 1) holding the settings Spotter 0.3 and earlier saved, as JSON. A newer Spotter reads it once, to import it into `preferences.json`, and leaves it as it was.
-
-```sql
-CREATE TABLE settings (
- id INTEGER PRIMARY KEY,
- data TEXT NOT NULL -- JSON blob
-);
-```
-
-## Storage Locations
-
-Under `Colony/Spotter/` in the platform's config, data and cache folders (`colony_ui::paths`):
-
-| | Linux | macOS | Windows |
-|---|---|---|---|
-| Config | `~/.config/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` |
-| Data | `~/.local/share/Colony/Spotter/` | `~/Library/Application Support/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` |
-| Cache | `~/.cache/Colony/Spotter/` | `~/Library/Caches/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` |
-
-**Subdirectories:**
-
-```
-/
-├── preferences/
-│ └── preferences.json # Preferences, with the shared Colony ones
-└── credentials/
- └── tokens.json # Platform tokens, only when no OS keyring is usable
-
-/
-├── library/
-│ └── spotter.db # SQLite database
-├── exports/
-│ ├── spotter_export.json # JSON backup file
-│ └── spotter_auto_backup.json
-└── logs/
- └── import.log
-
-/
-├── covers/ # Game cover art (JPEG)
-└── achievement_icons/ # Achievement icons (colored + grayscale)
-```
-
-Spotter 0.3 and earlier kept `data/spotter.db`, `exports/`, `logs/` and `cache/` directly in the config folder. `paths.rs` copies the database, exports and log once (`.migrated-from-0.3` in the data folder marks it done), leaves the old files in place, and keeps using them if the copy fails.
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
deleted file mode 100644
index 9c5c4e7..0000000
--- a/docs/DEVELOPMENT.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Development Guide
-
-## Prerequisites
-
-- **Rust** 1.90+ (2021 edition)
-- SQLite is bundled via `rusqlite`, nothing to install for it
-- On Linux, the windowing and keyring development packages (Debian/Ubuntu names):
- `pkg-config libxkbcommon-dev libxkbcommon-x11-dev libxcb1-dev libxcb-xkb-dev libwayland-dev libdbus-1-dev libsecret-1-dev`
-
-## Build
-
-```bash
-# Debug build
-cargo build
-
-# Release build (optimized, stripped)
-cargo build --release
-```
-
-The release binary is at `target/release/spotter`.
-
-## Run
-
-```bash
-cargo run
-```
-
-On first launch, Spotter creates its data directories and an empty SQLite database.
-
-## Test
-
-```bash
-# Run all tests
-cargo test
-```
-
-Tests are in `tests/unit_tests.rs` and `tests/integration_tests.rs`. Each DB test creates an isolated temporary directory with its own database via `db::open_at()`, so tests can run in parallel.
-
-### Test Categories
-
-| Category | Count | What's tested |
-|----------|-------|---------------|
-| Playtime display | 3 | Format hours/minutes strings |
-| Models | 20+ | Enum roundtrips, Display impls, distinct platform and status colours, settings serialization |
-| Database | 8 | CRUD, batch save, achievements, playtime history, export/import, profile, settings |
-| API client | 3 | URL encode/decode, roundtrip |
-| Error | 2 | Display format, String conversion |
-
-### Coverage Gaps
-
-The following areas lack test coverage:
-- HTML scraping (Steam store pages)
-- Import merge/deduplication logic
-- OAuth flows (Steam, GOG, PSN)
-- View rendering (Iced widget testing is limited)
-- API client retry behavior
-
-## Project Structure
-
-```
-src/
-├── main.rs # Entry point
-├── lib.rs # Module tree; public API for integration tests
-├── app.rs # Core state machine (~1300 lines)
-├── models.rs # All data structures (~800 lines)
-├── i18n/ # en.json, fr.json and the t/tf/tn lookup
-├── db.rs # SQLite operations
-├── paths.rs # File locations, copy from Spotter 0.3
-├── preferences.rs # preferences.json load/save/import
-├── theme.rs # Icons and style helpers on the Colony palette
-├── error.rs # SpotterError enum
-├── api_client.rs # HTTP utilities
-├── images.rs # Cover/icon download + caching
-├── steam.rs # Steam import
-├── steam_auth.rs # Steam OpenID
-├── gog.rs # GOG import
-├── gog_auth.rs # GOG OAuth
-├── epic.rs # Epic local scanner
-├── xbox.rs # Xbox OpenXBL import
-├── playstation.rs # PSN import
-└── views/
- ├── mod.rs # Module declarations
- ├── library.rs # Game list view
- ├── detail.rs # Game detail view
- ├── stats.rs # Statistics dashboard
- ├── preferences.rs # Preferences page
- ├── profile.rs # Profile & data management
- ├── import.rs # Import flows
- ├── sidebar.rs # Navigation sidebar
- └── add_game.rs # Manual game entry
-
-tests/
-└── unit_tests.rs # All tests (40 tests)
-
-docs/
-├── ARCHITECTURE.md # Architecture overview
-├── DATA_MODEL.md # Data model & DB schema
-├── API_INTEGRATIONS.md # External API documentation
-├── CONFIGURATION.md # Preferences, languages and theming
-└── DEVELOPMENT.md # This file
-```
-
-## Key Conventions
-
-### Error Handling
-
-The codebase currently uses `Result` for most operations. A typed `SpotterError` enum exists in `error.rs` for gradual migration. New code should prefer `SpotterError` where possible.
-
-### State Mutations
-
-All state changes go through the `Message` enum in `app.rs`. Never mutate `Spotter` fields directly from views. Views are pure rendering functions.
-
-### Async Operations
-
-Use `spawn_task()` for background work:
-
-```rust
-fn spawn_task(f: F, msg: fn(Result) -> M) -> Task
-```
-
-This spawns a thread and maps the result through a `Message` variant.
-
-### Database Access
-
-Always open a fresh connection per operation via `db::open()`. The connection uses WAL mode for concurrent reads.
-
-### Styling
-
-Take every colour from the active palette (`colony_ui::theme::Palette::*()`), in view code and in style closures alike, and the style helpers in `theme.rs`. Never write a colour literal: it would ignore the user's theme and high-contrast setting.
-
-Write text with `theme::ui_text(content, base)` rather than `text(..).size(N)`: it sizes the text with `colony_ui::sz(base)`, so both text-size preferences scale it, sets it in the interface font, so the dyslexia font reaches it, and keeps the Nerd Font glyphs inside it in the glyph font. A text input takes `.size(sz(N)).font(ui_font())`.
-
-## Environment Variables
-
-| Variable | Purpose |
-|----------|---------|
-| `XDG_CONFIG_HOME` | Override config directory (Linux). |
-
-## Common Warnings
-
-The following `dead_code` warnings are expected and can be ignored:
-
-- `save_single_game`: utility function reserved for future single-game save
-- `export_games_json` / `export_games_csv`: called via message dispatch, not detected by static analysis
-- `error::Result`: alias for future `SpotterError` migration
-- Various `GameBuilder` methods: available for test convenience
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..66d5c05
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,21 @@
+# Spotter documentation
+
+Sorted by who is reading, not by subject.
+
+## Using Spotter
+
+| | |
+|---|---|
+| [guide/platform-setup.md](guide/platform-setup.md) | what each store needs before you can import from it, and where to get it |
+| [guide/configuration.md](guide/configuration.md) | every preference, what it changes, and where the preferences file lives |
+
+## Reading the code
+
+| | |
+|---|---|
+| [internals/architecture.md](internals/architecture.md) | how a click becomes a state change, a background task and a database write |
+| [internals/data-model.md](internals/data-model.md) | the game, profile and achievement records, the SQLite schema, and where every file lives |
+| [internals/api-integrations.md](internals/api-integrations.md) | which endpoints each importer calls, how it signs in, and what it reads |
+| [internals/development.md](internals/development.md) | building, testing, and the conventions a change should follow |
+
+Security policy: [../SECURITY.md](../SECURITY.md).
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
new file mode 100644
index 0000000..8c238a3
--- /dev/null
+++ b/docs/guide/configuration.md
@@ -0,0 +1,100 @@
+# Configuration
+
+Every preference is set on the **Preferences** page. Open it with the
+**Spotter** button at the top of the sidebar; the same button, **Close** or
+`Esc` takes you back to the screen you were on. A change applies at once and
+is saved at once: there is no Save button.
+
+The page lists the categories every Colony program shares, **General**,
+**Appearance** and **Accessibility**, then Spotter's own, **Library** and
+**Imports**. Each category is a column of sections that start collapsed; click
+a section's title to open it.
+
+## The preferences file
+
+Preferences are one JSON file, `preferences/preferences.json`, in Spotter's
+config folder:
+
+| System | Config folder |
+|---|---|
+| Linux | `~/.config/Colony/Spotter/` |
+| Windows | `%LOCALAPPDATA%\Colony\Spotter\` |
+| macOS | `~/Library/Application Support/Colony/Spotter/` |
+
+You can edit it while Spotter is closed. A missing field takes its default, and
+a value Spotter does not understand (for example one written by a newer
+version) falls back to its default while every other field keeps its value.
+If the file is not valid JSON or cannot be read, Spotter starts on defaults,
+says so, and refuses to save anything for the rest of the session, so the file
+on disk stays as it was.
+
+The file also holds a little state that is not a preference: your favourite
+games and, when **Remember filters** is on, the last status and platform
+filters.
+
+## Shared Colony preferences
+
+These come from colony-ui, look and behave the same in every Colony program,
+and are stored under the same keys.
+
+| Where | Preference | Key | Default | What it changes |
+|---|---|---|---|---|
+| Appearance, Theme | theme | `selected_theme`, `selected_variant` | Gruvbox, dark | the colour theme, picked from the shared Colony theme list |
+| Appearance, Colors & accents | accent | `selected_accent` | none | one of the eight shared accent colours; none keeps the theme's own |
+| Appearance, Colors & accents | Auto accent from background | `auto_accent` | off | uses the theme's own accent instead of the one picked |
+| Appearance, Typography | Text size | `font_size` | default | small, default or large |
+| Appearance, Effects | Animations | `animations` | on | animated transitions and fades; off, imported games appear at once instead of one by one |
+| Accessibility, Vision | High contrast | `high_contrast` | off | stronger text and border contrast |
+| Accessibility, Vision | Dyslexia-friendly font | `dyslexia_font` | off | switches the interface to OpenDyslexic |
+| Accessibility, Motion | Reduce motion | `reduce_motion` | off | turns every animation off, whatever **Animations** says |
+| Accessibility, Reading | Text size | `text_size_a11y` | default | small, default, large or extra large, multiplied with the Typography size: text ranges from 0.7225x to 1.68x |
+
+## Spotter's own preferences
+
+| Where | Preference | Key | Default | What it changes |
+|---|---|---|---|---|
+| General, Language | Interface language | `language` | none (System language) | English or Français. The system language is read from `LC_ALL`, `LC_MESSAGES`, then `LANG`, which Windows and macOS usually leave unset; anything but French means English |
+| General, Startup | Start screen | `start_screen` | Library | Library, Statistics, Import or Profile |
+| General, Notifications | Show notifications | `notifications_enabled` | on | success messages; errors always show |
+| General, Notifications | Duration | `toast_duration` | Normal | how long a message stays: Short (2 s), Normal (4 s), Long (8 s) |
+| General, Dates | Date format | `date_format` | YYYY-MM-DD | or DD/MM/YYYY, or MM/DD/YYYY |
+| Appearance, Layout | Sidebar width | `sidebar_width` | 220 | Narrow (180), Normal (220) or Wide (280) pixels; the file accepts any width, drawn between 160 and 400 |
+| Appearance, Layout | Compact library list | `compact_list` | off | less space between games |
+| Appearance, Layout | Covers in the library | `show_covers_in_list` | on | cover thumbnails next to the titles |
+| Accessibility, Color cues | Show status labels | `show_status_labels` | on | a text label next to each status colour, so status does not rely on colour alone |
+| Accessibility, Navigation | Large click targets | `large_click_targets` | off | more padding on buttons and chips |
+| Library, New games | Default status | `default_status` | Unplayed | the status pre-selected on the **Add game** page |
+| Library, New games | Default platform | `default_platform` | Steam | the platform pre-selected on the **Add game** page |
+| Library, Sorting and filters | Sort order | `default_sort_order` | A-Z | the library's sort when Spotter starts |
+| Library, Sorting and filters | Remember filters | `remember_filters` | on | keeps the status and platform filters between sessions |
+| Library, Game details | Descriptions in the library | `show_game_descriptions` | off | store descriptions on the library cards |
+| Library, Game details | Achievements | `achievements_display` | Full list | the full list, a summary, or nothing on a game's page |
+| Library, Deleting | Confirm before deleting | `confirm_before_delete` | on | asks before a game is removed |
+| Imports, Covers | Download covers after an import | `download_covers_auto` | on | downloads the missing covers after every import |
+
+## Upgrading from Spotter 0.3 or older
+
+Older versions kept everything in one `Colony/Spotter/` folder and their
+settings inside the database. On the first start of a newer version, Spotter:
+
+- copies the database, the exports and the import logs to the folders listed in
+ [data-model.md](../internals/data-model.md#where-the-data-lives);
+- writes the old settings to `preferences.json`, with the old look mapped to
+ the nearest shared one;
+- leaves the old folder untouched, so nothing is lost if something goes wrong.
+ Delete it yourself once your library is all there. On a system without a
+ usable keyring, the old database in that folder still holds your platform
+ tokens unencrypted until you do.
+
+| Old setting | Becomes |
+|---|---|
+| `theme_mode` Dark, Darker, Midnight | the Tokyo Night night, Material deep ocean and Modus vivendi themes |
+| `accent_color` Blue or Cyan, Purple, Green, Gold, Red | the `blue`, `violet`, `green`, `amber` and `red` accents |
+| `ui_scale` Small, Normal, Large | the Accessibility text size small, default, large |
+
+The old scale enlarged the whole window; the text sizes enlarge the text only.
+
+If the copy fails, Spotter keeps using the old files where they are and tries
+again on the next start.
+
+Covers and achievement icons are not copied: they download again.
diff --git a/docs/guide/platform-setup.md b/docs/guide/platform-setup.md
new file mode 100644
index 0000000..3be319b
--- /dev/null
+++ b/docs/guide/platform-setup.md
@@ -0,0 +1,106 @@
+# Platform setup
+
+Everything below happens on the **Import** screen: each store has a card with
+its numbered steps, a status badge (Setup needed, Ready and so on) and an
+import button. What you enter there is kept in the operating system's keyring, see
+[Privacy](../../README.md#privacy).
+
+| Store | What Spotter needs | Where to get it |
+|---|---|---|
+| Steam | your Steam ID and a Steam Web API key | the **Log in** button, then [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) |
+| GOG | a GOG login | the **Log in** button, then paste the address GOG redirects you to |
+| Epic Games | nothing, or an Epic login for the whole library | the **Log in** button, then paste the answer Epic shows |
+| Xbox | an OpenXBL API key | [xbl.io](https://xbl.io) (free account) |
+| PlayStation | an NPSSO token | [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie) while signed in to PlayStation |
+| Nintendo | nothing | no import: add games on the **Add game** page |
+
+## Steam
+
+1. Click **Log in**. Steam's sign-in page opens in your browser. Sign in and
+ approve; Steam sends you back to a page served by Spotter on `127.0.0.1`,
+ and the card shows your Steam ID. Spotter waits two minutes for the answer.
+2. Create a Web API key at
+ [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) (any
+ domain name works) and paste it into the **API key** field.
+3. Click **Import Steam**.
+
+Achievements are read from your Steam community profile, so set **Game
+details** to Public in Steam's privacy settings, or the import finds your games
+but no achievements. The import lists your owned games, then reads each game's
+store page and your achievements, five games at a time.
+
+## GOG
+
+1. Click **Log in**. GOG's sign-in page opens in your browser.
+2. After signing in, the browser lands on a mostly blank
+ `embed.gog.com/on_login_success?...` page. Copy that whole address from the
+ address bar, paste it into the card, and click **Finish**. Pasting only
+ the `code=` value works too.
+3. Click **Import GOG**.
+
+GOG gives Spotter a token that expires; Spotter renews it at startup, and
+again when an import finds it expired, so you log in only once. GOG has no achievement data in the
+API Spotter uses.
+
+## Epic Games
+
+The login is optional, and it decides what the import button does:
+
+- **Without a login**, the import scans this computer for installed games: the
+ Epic Games Launcher's manifests (on Windows, on macOS, or in the default Wine
+ prefix `~/.wine` on Linux) and Legendary's `installed.json`. The folders are
+ listed in
+ [api-integrations.md](../internals/api-integrations.md#epic-games).
+- **With a login**, it imports every game the account owns. Click **Log in**,
+ sign in on the page that opens, and copy what Epic shows afterwards: a short
+ JSON text that contains `authorizationCode`. Paste it into the card and click
+ **Finish**. The code alone, or an address with `?code=`, works too.
+
+Then click **Import installed games** or, once logged in, **Import the whole
+library**. Spotter renews the Epic login at startup, like
+GOG's.
+
+## Xbox
+
+Spotter reads Xbox data through [OpenXBL](https://xbl.io), a third-party
+service, not through Microsoft directly.
+
+1. Sign in at [xbl.io](https://xbl.io) with your Microsoft account and copy
+ your API key.
+2. Paste it into the card. The **Gamertag** field appears; it is only a label
+ for your profile, the import does not need it.
+3. Click **Import Xbox**.
+
+The import reads your title history (Xbox One, Xbox Series, Xbox 360 and PC
+games), each game's achievements, and store details. OpenXBL's free plan has an
+hourly request limit, which a large library can reach.
+
+## PlayStation
+
+1. Sign in at [playstation.com](https://www.playstation.com) in your browser.
+2. In the same browser, open
+ [ca.account.sony.com/api/v1/ssocookie](https://ca.account.sony.com/api/v1/ssocookie).
+ It shows `{"npsso":"..."}`. Copy the 64-character value between the quotes.
+3. Paste it into the **NPSSO token** field and click **Import PlayStation**.
+
+The NPSSO token is a login cookie for your Sony account: treat it like a
+password. It expires after about two months, and signing out of
+playstation.com ends it sooner; paste a new one when the import says it is no
+longer valid. Spotter imports every game in your trophy list with its trophy
+counts, and adds playtime and covers for PS4 and PS5 games. It does not read
+individual trophies.
+
+## Nintendo
+
+Nintendo offers no library API. Add your games on the **Add game** page, from
+the sidebar.
+
+## After an import
+
+- Importing again updates playtime, achievements and store details, adds new
+ games, and keeps your status, rating and notes.
+- Covers download after each import unless **Download covers after an
+ import** is off in Preferences, under Imports.
+- Every import writes `spotter_auto_backup.json` and a line per step to
+ `import.log`; their folders are listed in
+ [data-model.md](../internals/data-model.md#where-the-data-lives).
diff --git a/docs/internals/api-integrations.md b/docs/internals/api-integrations.md
new file mode 100644
index 0000000..1f7042b
--- /dev/null
+++ b/docs/internals/api-integrations.md
@@ -0,0 +1,143 @@
+# API integrations
+
+Spotter imports from five stores. Each importer is a blocking function that
+runs on a worker thread and returns `Vec`; the merge into the library is
+described in [architecture.md](architecture.md#import-merge). Only Steam's Web
+API and OpenXBL are documented public APIs: the GOG, Epic and PlayStation
+endpoints below are the ones their own clients use, and can change without
+notice. The recorded responses in `tests/integration_tests.rs` pin down what
+each parser expects.
+
+Every importer appends its steps and counts to `import.log` through
+`api_client::ImportLogger` (see
+[data-model.md](data-model.md#where-the-data-lives)).
+
+## HTTP client
+
+All requests go through `ureq` agents built in `api_client.rs`:
+
+| | |
+|---|---|
+| connect timeout | 10 s |
+| read timeout | 15 s for API calls, 30 s for downloads |
+| `http_get_retry` | exponential backoff, 500 ms x 2^attempt; a 4xx fails at once, except 429 |
+| `RateLimiter` | doubles its delay on each 429, up to 60 s, and eases back 25% per success |
+| images | at most 10 MB, and the bytes must be a JPEG, PNG, WebP or GIF image |
+
+## Steam
+
+`steam/mod.rs` (import) and `steam/auth.rs` (login).
+
+**Login.** Steam OpenID 2.0. `steam_login` binds the first free port among
+29876 to 29880 on `127.0.0.1`, opens `steamcommunity.com/openid/login` in the
+browser with `http://localhost:/callback` as the return address, and
+waits up to two minutes. The Steam ID is taken from `openid.claimed_id` only
+after `steamcommunity.com` confirms the answer (`check_authentication`), so a
+forged callback is rejected. The Web API key is typed by the user.
+
+| Endpoint | Used for |
+|---|---|
+| `api.steampowered.com/IPlayerService/GetOwnedGames/v0001/` | owned games, names and playtime (key + Steam ID) |
+| `store.steampowered.com/app//` | genre, release date, description, tags, review score (HTML) |
+| `steamcommunity.com/stats//achievements` | the game's achievement list with icons (HTML) |
+| `steamcommunity.com/profiles//stats/appid//achievements/` | the player's unlocks and dates (HTML, public profile only) |
+| `shared.fastly.steamstatic.com/store_item_assets/steam/apps//header.jpg` | cover image |
+
+Five worker threads share the game list, pause 500 ms between games, and retry
+a store page twice. Games that already have store details or complete
+achievements in the library are skipped, so a second import is much faster.
+Achievement icons are downloaded during the import.
+
+## GOG
+
+`gog/mod.rs` (import) and `gog/auth.rs` (login).
+
+**Login.** OAuth 2.0 with the public GOG Galaxy client id. The browser opens
+`auth.gog.com/auth`; after sign-in GOG redirects to
+`embed.gog.com/on_login_success`, and the user pastes that address (or its
+`code`). `auth.gog.com/token` exchanges the code for an access and a refresh
+token, with the parameters in a form body so they stay out of URLs. The refresh
+token renews the access token at startup and when an import fails.
+
+| Endpoint | Used for |
+|---|---|
+| `embed.gog.com/account/getFilteredProducts?mediaType=1&page=` | owned games, all pages (token) |
+| `api.gog.com/products/?expand=description` | description, release date, genres and cover (public) |
+
+GOG's API has no achievement data for Spotter.
+
+## Epic Games
+
+`epic/`. Two independent imports, picked by whether the user is logged in.
+
+**Local scan** (`local.rs`), no login. Reads the Epic Games Launcher's `.item`
+manifests and Legendary's `installed.json` from:
+
+| System | Folder |
+|---|---|
+| Windows | `%PROGRAMDATA%\Epic\EpicGamesLauncher\Data\Manifests\` |
+| macOS | `/Users/Shared/Epic Games/Launcher/Data/Manifests/` |
+| Linux | `~/.config/legendary/` (`installed.json`), `~/.config/heroic/store_cache/`, and the launcher folder inside `~/.wine/drive_c/ProgramData/` |
+
+Only entries in the `games` category are kept. Store details then come from
+`store-content-ipv4.ak.epicgames.com`.
+
+**Account import** (`auth.rs`, `online.rs`, `api.rs`, `graphql.rs`). OAuth with
+the public Epic Games Launcher client id, the same one Legendary and Heroic
+use. The browser opens `www.epicgames.com/id/login`, which ends on a JSON page
+holding `authorizationCode`; the user pastes it, and
+`account-public-service-prod.ol.epicgames.com` exchanges it for an access
+token, a refresh token, the account id and the display name.
+
+| Endpoint | Used for |
+|---|---|
+| `library-service.live.use1a.on.epicgames.com/library/api/public/items` | owned items, paginated |
+| `entitlement-public-service-prod08.ol.epicgames.com/entitlement/api/account//entitlements` | items missing from the library service |
+| `launcher-public-service-prod06.ol.epicgames.com/launcher/api/public/assets/Windows` | the launcher's own asset list |
+| `catalog-public-service-prod06.ol.epicgames.com/catalog/api/shared/namespace//bulk/items` | titles, categories, images |
+| `store-content-ipv4.ak.epicgames.com/api/en-US/content/products/` | description, cover, tags, release date |
+| `store.epicgames.com/graphql` | slugs, titles of unresolved items, ratings, achievement totals, fallback metadata |
+
+The three item lists are merged by namespace and catalog item id, DLC and
+non-game items are filtered out, and items whose title cannot be resolved are
+dropped. Epic does not grant the launcher client the scope for player
+achievements, so only the total count is known. A 401 triggers one token
+refresh and a retry.
+
+## Xbox
+
+`xbox.rs`, through [OpenXBL](https://xbl.io), a third-party proxy for Xbox Live.
+Every request carries the user's key in the `X-Authorization` header.
+
+| Endpoint | Used for |
+|---|---|
+| `xbl.io/api/v2/account` | the user's XUID |
+| `xbl.io/api/v2/player/titleHistory` | played titles with cover, playtime, last played and achievement counts, up to 50 pages |
+| `xbl.io/api/v2/achievements/player//` | Xbox One, Series and PC achievements |
+| `xbl.io/api/v2/achievements/x360//title/` | Xbox 360 achievements |
+| `xbl.io/api/v2/marketplace/details` | descriptions and release dates for a batch of titles |
+
+`is_game` keeps titles played on `XboxOne`, `XboxSeries`, `Xbox360`, `PC` or
+`Win32`. Achievements are saved per title under `platform_id = xbox:`.
+
+## PlayStation
+
+`playstation.rs`.
+
+**Login.** The user pastes an NPSSO token from
+`ca.account.sony.com/api/v1/ssocookie`. Spotter exchanges it with the public
+PlayStation app client id: `ca.account.sony.com/api/authz/v3/oauth/authorize`
+returns an authorization code, and `.../oauth/token` an access token. The
+access token is not stored; each import exchanges the NPSSO again.
+
+| Endpoint | Used for |
+|---|---|
+| `m.np.playstation.com/api/trophy/v1/users/me/trophyTitles` | every game with trophies and its earned and total trophy counts, paginated |
+| `m.np.playstation.com/api/gamelist/v2/users/me/titles?categories=ps4_game,ps5_native_game` | playtime, last played and covers for PS4 and PS5 games |
+
+The trophy list is the game list; the played-games list only enriches it.
+Individual trophies are not read.
+
+## Nintendo
+
+No API. Games are added by hand with **Add Game**.
diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md
new file mode 100644
index 0000000..22807e1
--- /dev/null
+++ b/docs/internals/architecture.md
@@ -0,0 +1,180 @@
+# Architecture
+
+Spotter is one Rust binary: an [iced](https://iced.rs) 0.14 application on the
+Elm pattern (state, messages, `update`, `view`), a SQLite database through
+`rusqlite`, and one blocking `ureq` client per store. The look, the
+Preferences page building blocks, the fonts, the en/fr shared labels and the
+folder paths come from
+[colony-ui](https://github.com/Project-Colony/Project-Colony-Resources/tree/main/crates/colony-ui),
+the Project Colony design-system crate.
+
+## Module map
+
+```
+src/
+├── main.rs entry point: --version, then spotter::run()
+├── lib.rs the whole module tree; the pub modules are what tests/ uses
+├── app.rs Spotter (the state), Message, update(), view(), run()
+├── handlers/ update() split by area: data and persistence, imports,
+│ store logins, preferences
+├── messages/ the message enums those areas add to Message
+├── views/ one file per screen (library, game detail, statistics,
+│ import, profile, add game, Preferences) plus the sidebar
+├── models.rs Game, Platform, GameStatus, Achievement, UserProfile and
+│ Spotter's own preferences
+├── preferences.rs preferences.json: lenient load, save, import of the 0.3
+│ settings
+├── paths.rs where every file lives (on colony_ui::paths), and the
+│ copy of the 0.3 files
+├── i18n/ en.json, fr.json and the t / tf / tn lookup
+├── db.rs SQLite schema, migrations, queries, export and import
+├── keyring.rs the seven secrets, in the OS keyring or the fallback file
+├── api_client.rs HTTP agents, retry and rate limiting, the import log,
+│ URL encoding, opening the browser
+├── images.rs cover and achievement icon download and cache
+├── theme.rs icons, ui_text and the widget styles over the colony-ui
+│ palette
+├── error.rs SpotterError
+├── steam/ mod.rs: owned games, store and achievement scraping
+│ auth.rs: OpenID login on 127.0.0.1
+├── gog/ mod.rs: library and store metadata; auth.rs: OAuth code exchange
+├── epic/ online.rs: account import; local.rs: installed-games scan;
+│ auth.rs, api.rs, graphql.rs and helpers
+├── xbox.rs OpenXBL import
+└── playstation.rs NPSSO exchange, trophy titles, game list
+```
+
+`main.rs` declares no module of its own: it calls `spotter::run`, and
+everything else hangs off `lib.rs`. Which store endpoints each importer calls is
+in [api-integrations.md](api-integrations.md).
+
+## Data flow
+
+```
+user input / keyboard
+ │
+ ▼
+ Message ──────► Spotter::update() ──► state change ──► Spotter::view()
+ ▲ │
+ │ ▼
+ │ Task (spawn_task / spawn_write)
+ │ │
+ │ ▼
+ └──── result ◄── worker thread: store requests, SQLite, files
+```
+
+Every change goes through a `Message`. Views only read `&Spotter` and return
+widgets; they never mutate it. Blocking work runs in `app::spawn_task`, which
+moves the closure to a new thread and maps its `Result` back into a
+message:
+
+```rust
+fn spawn_task(f: F, msg: impl FnOnce(Result) -> M + Send + 'static) -> Task
+```
+
+Writes of saved data go through `Spotter::spawn_write` instead. If the startup
+load failed, the app is holding defaults rather than the user's data, so
+`spawn_write` refuses every write (preferences, profile, games, exports,
+auto-backup) and reports it, and the files on disk stay as they were.
+
+## Screens
+
+```
+┌───────────────┐ ┌──────────────────────────────────────────┐
+│ [Spotter ⚙]───┼──►│ Preferences (replaces the content area; │
+│ │ │ same button or Close goes back) │
+│ Library ──────┼──►│ Library ──► Game detail │
+│ Add game ─────┼──►│ Add game │
+│ Statistics ───┼──►│ Statistics │
+│ Import ───────┼──►│ Import (one card per store) │
+│ Profile ──────┼──►│ Profile (name, keyring state, data paths, │
+│ │ │ export and import) │
+│ quick stats │ └──────────────────────────────────────────┘
+└───────────────┘
+```
+
+The sidebar stays on every screen. Keyboard: `Esc` cancels a pending delete,
+or else closes Preferences, or else goes back to the library; `Ctrl+F` focuses
+the search, and `Ctrl+A` selects every game the library
+currently shows (`Cmd` instead of `Ctrl` on macOS).
+
+## Import merge
+
+An import returns a `Vec`, and `merge_imported_games`
+(`handlers/data.rs`) folds it into the library. Each incoming game is matched,
+in this order, on:
+
+1. its store id (`steam_appid`, `gog_id`, `epic_id`, `xbox_id`, `psn_id`);
+2. its title and platform;
+3. its title alone.
+
+A matched game gets the incoming genre, description, release date, tags, cover
+address and review score when the import has them, the achievement counts when
+the import has any, and the larger of the two playtimes; store ids it lacked
+are filled in. Status, rating, notes and last-played date are never touched.
+Unmatched games are added, and arrive in the library one by one (the import
+cascade) unless reduced motion is on.
+
+After an import Spotter saves the library, writes `spotter_auto_backup.json`,
+and, unless turned off, downloads the missing covers.
+
+## Look and preferences
+
+Colours come from the active colony-ui theme, never from literals: the
+`colony_ui::theme::Palette` roles (`Palette::TEXT_MUTED()`, `Palette::BG_CARD()`
+...), read wherever a widget is drawn, including inside style closures, and
+`contrast_on` for text on a filled button. Platforms and game statuses keep
+one colour on every theme: `Platform::accent_key` and `GameStatus::accent_key`
+name a shared accent swatch, which `theme::shared_accent` turns into a colour.
+The style helpers (cards, chips, buttons, pills) are in `theme.rs`.
+
+Text goes through `theme::ui_text(content, base)`, which sizes it with
+`colony_ui::sz(base)`, so the Appearance and Accessibility text sizes both
+apply, sets it in the interface font, so the dyslexia font reaches it, and
+keeps the Nerd Font glyphs inside it in the glyph font. Animations, the import
+cascade included, check `colony_ui::effects_enabled()`, which is false when
+**Animations** is off or reduced motion is on.
+
+The preferences file is Spotter's own struct with colony-ui's
+`StandardPreferences` flattened into it. At startup Spotter loads it, calls
+`StandardPreferences::apply()` and sets the locale; a change on the Preferences
+page goes through `StandardPreferences::update()` and is saved at once. Text is
+looked up by key (`i18n::t`, `tf`, `tn`) in Spotter's own en/fr tables
+(`src/i18n/en.json`, `src/i18n/fr.json`), then in colony-ui's shared labels;
+tests keep the two locales' keys and placeholders identical.
+
+## Credentials
+
+`keyring.rs` sits between the profile and the database. On load it reads the
+seven secret fields from the keyring; on save it writes them there and keeps
+their database columns empty. The first keyring failure in a session switches
+it to the fallback file `/credentials/tokens.json` for the rest of the
+session, and the Profile page shows a warning. If that file cannot be written
+either, the secrets stay in their database columns, unencrypted, and the next
+start moves them out again. On load, secrets still in the database (Spotter
+0.3 and older) move to the keyring, or to the file without one, and secrets in
+the file move to the keyring once it works again. It only deletes keyring entries it has
+read or written this session, because a locked store can answer "no entry"
+for a secret it does hold.
+
+## Dependencies
+
+| Crate | Role |
+|---|---|
+| `iced` (with `image`) | GUI |
+| `colony-ui` | themes, typography, fonts, Preferences widgets, shared labels, folder paths |
+| `rusqlite` (bundled) | SQLite, compiled in; no system library needed |
+| `dirs` | the home folder, for the Epic Games local scan |
+| `ureq` (with `json`) | blocking HTTP |
+| `scraper` | Steam store and achievement pages |
+| `keyring`, `keyring-core` | OS keyring, and its mock store in tests |
+| `serde`, `serde_json`, `csv` | JSON and CSV |
+| `chrono` | dates |
+| `thiserror` | `SpotterError` |
+| `winresource` (build) | the Windows icon and version resource |
+
+Every dependency names its full version in `Cargo.toml`, and `Cargo.lock` is
+committed; CI builds with `--locked`.
+
+The release profile uses thin LTO, one codegen unit, `opt-level = 3`, and strips
+symbols.
diff --git a/docs/internals/data-model.md b/docs/internals/data-model.md
new file mode 100644
index 0000000..d472732
--- /dev/null
+++ b/docs/internals/data-model.md
@@ -0,0 +1,227 @@
+# Data model
+
+## Where the data lives
+
+Spotter uses the three standard Colony folders, through
+`colony_ui::paths::{config_dir, data_dir, cache_dir}("Spotter")`, and keeps
+them apart by sub-directory, because config and data are the same folder on
+Windows and macOS:
+
+| | Linux | Windows | macOS |
+|---|---|---|---|
+| config | `~/.config/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` |
+| data | `~/.local/share/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\` | `~/Library/Application Support/Colony/Spotter/` |
+| cache | `~/.cache/Colony/Spotter/` | `%LOCALAPPDATA%\Colony\Spotter\cache\` | `~/Library/Caches/Colony/Spotter/` |
+
+```
+/
+├── preferences/
+│ └── preferences.json every preference, see guide/configuration.md
+└── credentials/
+ └── tokens.json only when no OS keyring is usable (0600 on Unix)
+
+/
+├── library/
+│ └── spotter.db the SQLite database below
+├── exports/
+│ ├── spotter_export.json written by Export JSON, read by Import JSON
+│ ├── spotter_export.csv written by Export CSV
+│ └── spotter_auto_backup.json rewritten after every import
+└── logs/
+ ├── import.log one line per import step
+ └── import.log.1 the previous log, rotated at 5 MB
+
+/
+├── covers/ one .jpg per game, named after its title
+└── achievement_icons/ _.jpg and its grey variant
+```
+
+`paths::layout()` in `src/paths.rs` resolves these paths and creates the
+folders once, at startup; the Profile page shows the paths it resolved.
+Deleting the cache folder costs nothing but the downloads.
+
+Spotter 0.3 and older kept everything under `~/.config/Colony/Spotter/`
+(`Application Support` on macOS, `AppData\Local` on Windows), with the database
+in `data/` and the settings in the database. A newer version copies the
+database, exports and logs from there on its first start, imports the old
+settings row into `preferences.json`, writes a marker
+(`/.migrated-from-0.3`) so the check runs once, and never deletes the old
+folder. If the copy fails, it keeps using the old
+files where they are (`/data/spotter.db`, `/exports/`,
+`/logs/`) rather than start on an empty library, and tries the copy
+again on the next start. On a system without a usable keyring, the old
+database left in `/data/` can still hold the platform tokens
+unencrypted.
+
+## Records
+
+### Game
+
+The library entry, one row of the `games` table.
+
+```rust
+Game {
+ id: Option, // SQLite primary key, None before the first save
+ title: String,
+ platform: Platform, // Steam | Gog | Epic | PlayStation | Xbox | Nintendo
+ playtime_minutes: u32,
+ achievements_unlocked: u32,
+ achievements_total: u32,
+ status: GameStatus, // Playing | Completed | Unplayed | Dropped | Wishlist
+ rating: Option, // 0-10, None = unrated
+ genre: String,
+ last_played: String, // "YYYY-MM-DD", empty if never
+ cover_url: String, // remote address; the file is in /covers
+ steam_appid: Option,
+ gog_id: Option,
+ epic_id: Option,
+ xbox_id: Option,
+ psn_id: Option,
+ notes: String, // the user's own notes
+ description: String, // store description
+ release_date: String,
+ review_percent: Option, // store review score, 0-100
+ tags: String, // comma-separated
+}
+```
+
+The store ids are what an import matches on before it falls back to the title;
+see [architecture.md](architecture.md#import-merge).
+
+### Achievement
+
+```rust
+Achievement {
+ api_name: String, // the store's internal id
+ display_name: String,
+ description: String,
+ icon_url: String, // unlocked icon
+ icon_gray_url: String, // locked icon
+ unlocked: bool,
+ unlock_time: u64, // Unix timestamp, 0 while locked
+}
+```
+
+Steam and Xbox imports store one row per achievement. PlayStation only fills
+the game's unlocked and total counts.
+
+### UserProfile
+
+```rust
+UserProfile {
+ username: String, // display name, "Player" by default
+ avatar_path: String, // unused
+ steam_api_key: String, // secret
+ steam_id: String, // 64-bit Steam ID, from the Steam login
+ gog_token: String, // secret
+ gog_refresh_token: String, // secret
+ xbox_api_key: String, // secret, the OpenXBL key
+ xbox_gamertag: String,
+ xbox_xuid: String, // Xbox user id, read during an import
+ psn_npsso: String, // secret
+ epic_token: String, // secret
+ epic_refresh_token: String, // secret
+ epic_account_id: String,
+ epic_display_name: String,
+ member_since: String, // "YYYY-MM-DD", set on creation
+}
+```
+
+`keyring.rs` loads and saves the seven secret fields. They live in the OS
+keyring under the service name `spotter`, one entry per field. When no keyring
+is usable they go to `/credentials/tokens.json` instead, and the
+Profile page says so. The matching database columns stay empty.
+
+### Preferences
+
+`preferences.json` holds Spotter's own preferences and colony-ui's
+`StandardPreferences`, flattened into one object, with a `version` field (2
+since the move out of the database). Every field is read on its own, so one
+bad value only resets that field. The keys are listed in
+[guide/configuration.md](../guide/configuration.md).
+
+## Database schema
+
+`spotter.db` is SQLite in WAL mode with foreign keys on and `secure_delete`
+on, so an overwritten value does not linger in the file's free pages.
+`db::open` creates the tables if they are missing and then runs the column
+migrations in `db.rs`, once per session.
+
+### games
+
+```sql
+CREATE TABLE games (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ playtime_minutes INTEGER DEFAULT 0,
+ achievements_unlocked INTEGER DEFAULT 0,
+ achievements_total INTEGER DEFAULT 0,
+ status TEXT DEFAULT 'Unplayed',
+ rating INTEGER,
+ genre TEXT DEFAULT '',
+ last_played TEXT DEFAULT '',
+ cover_url TEXT DEFAULT '',
+ steam_appid INTEGER,
+ gog_id TEXT,
+ epic_id TEXT,
+ xbox_id TEXT,
+ psn_id TEXT,
+ notes TEXT DEFAULT '',
+ description TEXT DEFAULT '',
+ release_date TEXT DEFAULT '',
+ review_percent INTEGER,
+ tags TEXT DEFAULT '',
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+Indexes: `platform`, `status`, `title`, `rating`, `last_played`, and each store
+id (`steam_appid`, `gog_id`, `epic_id`, `xbox_id`, `psn_id`).
+
+### achievements
+
+```sql
+CREATE TABLE achievements (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ steam_appid INTEGER NOT NULL,
+ platform_id TEXT NOT NULL DEFAULT '', -- '' for Steam, 'xbox:' for Xbox
+ api_name TEXT NOT NULL,
+ display_name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ icon_url TEXT NOT NULL DEFAULT '',
+ icon_gray_url TEXT NOT NULL DEFAULT '',
+ unlocked INTEGER NOT NULL DEFAULT 0,
+ unlock_time INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(steam_appid, platform_id, api_name)
+);
+```
+
+### playtime_history
+
+```sql
+CREATE TABLE playtime_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ game_id INTEGER NOT NULL,
+ date TEXT NOT NULL, -- "YYYY-MM-DD"
+ minutes_played INTEGER NOT NULL,
+ FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
+);
+```
+
+The Statistics screen charts the last 30 days of it.
+
+### user_profile
+
+A single row (`id = 1`) with one column per `UserProfile` field. The seven
+secret columns (`steam_api_key`, `gog_token`, `gog_refresh_token`,
+`xbox_api_key`, `psn_npsso`, `epic_token`, `epic_refresh_token`) are empty:
+the secrets are in the keyring, or in `/credentials/tokens.json`
+without one. A secret stays in its column, unencrypted, only when neither the
+keyring nor that file could take it; `keyring.rs` moves it out on the next
+start.
+
+### settings
+
+A single row (`id = 1`) with a JSON `data` column. Only Spotter 0.3 and older
+wrote it; a newer version reads it once to seed `preferences.json` on upgrade.
diff --git a/docs/internals/development.md b/docs/internals/development.md
new file mode 100644
index 0000000..3da6cda
--- /dev/null
+++ b/docs/internals/development.md
@@ -0,0 +1,117 @@
+# Development
+
+## Prerequisites
+
+- **Rust 1.90** or newer (`rust-version` in `Cargo.toml`; CI builds and tests on
+ exactly 1.90 too).
+- Nothing for SQLite: `rusqlite` compiles it in.
+- On Linux, the windowing and keyring development packages. With Debian or
+ Ubuntu names, as CI installs them:
+
+ ```bash
+ sudo apt-get install pkg-config libxkbcommon-dev libxkbcommon-x11-dev \
+ libxcb1-dev libxcb-xkb-dev libwayland-dev libdbus-1-dev libsecret-1-dev
+ ```
+
+## Build and run
+
+```bash
+cargo build --locked # debug build
+cargo run --locked # opens the window
+cargo run --locked -- --version # prints "spotter " and exits
+cargo build --locked --release # optimised and stripped: target/release/spotter
+```
+
+The first start creates Spotter's folders and an empty database; the folders
+are listed in [data-model.md](data-model.md#where-the-data-lives). A debug
+build uses the same folders as an installed Spotter, so it opens your real
+library: back it up first, or work under a test user.
+
+## Test
+
+```bash
+cargo test --locked
+```
+
+The tests run in parallel. Each database test opens its own SQLite file in a
+fresh temporary directory through `db::open_at`, and the keyring tests use
+`keyring-core`'s mock store, so nothing touches your library or your keyring.
+
+| Where | What it covers |
+|---|---|
+| `tests/unit_tests.rs` | models and enums, preferences parsing and versioning, the database (CRUD, batch save, achievements, playtime history, profile, export and import), CSV and JSON export, errors |
+| `tests/integration_tests.rs` | each importer's parser against recorded Steam, PlayStation, Xbox, GOG and Epic responses, image validation, the rate limiter, the import log, URL encoding |
+| `#[cfg(test)]` modules in `src/` | Steam HTML scraping, multi-byte title and description cuts, keyring moves, the write refusal after a failed load, the folders and their migration, `preferences.json` and the import of 0.3 settings, the en/fr tables, the Preferences toggle and `Esc`, imports without effects, the window icon |
+
+Not covered by automated tests: live store requests (the importers are only
+tested against recorded responses), the login flows end to end, the import
+merge in `handlers/data.rs`, and rendering.
+A change to a view, the Preferences page or the French layout needs a look on a
+real desktop at both ends of the text scale (0.7225x and 1.68x).
+
+## Before a pull request
+
+CI runs these on Linux, Windows and macOS with the latest stable Rust, then
+builds and tests again on exactly Rust 1.90:
+
+```bash
+cargo fmt --check
+cargo clippy --locked --all-targets -- -D warnings
+cargo build --locked
+cargo test --locked
+cargo audit
+```
+
+On Windows it also checks that the executable's version resource says
+`ProductName` Spotter and the crate's version, which code signing requires.
+Pull request titles are conventional commits (`feat:`, `fix:`, `docs:` ...),
+because they become the squash commit and the changelog entry. Releases are cut
+by release-please; see the
+[code signing policy](../../README.md#code-signing-policy) for what a release
+carries.
+
+## Conventions
+
+**State changes.** Everything goes through a `Message` and `Spotter::update`;
+views only read `&Spotter`. Add a variant to the area's message enum in
+`messages/` and handle it in the matching file in `handlers/`.
+
+**Background work.** Wrap blocking work in `app::spawn_task`, and anything that
+writes saved data in `Spotter::spawn_write`, which refuses the write after a
+failed startup load.
+
+**Database.** Open a connection per operation with `db::open()`; WAL mode lets
+reads run alongside a write. A new column goes into both `init_tables` and the
+`migrate` list in `db.rs`, so existing databases get it.
+
+**Secrets.** A new credential is a `UserProfile` field listed in
+`keyring::secrets`, so it goes to the keyring and never into the database.
+
+**Look.** Take every colour from the active palette
+(`colony_ui::theme::Palette::*()`), in view code and in style closures alike,
+and reuse the style helpers in `theme.rs`; a colour literal would ignore the
+user's theme and high contrast. Write text with `theme::ui_text(content, base)`
+rather than `text(..).size(N)`, and give a text input
+`.size(sz(N)).font(ui_font())`, so both text sizes and the dyslexia font reach
+it. Gate animations on `colony_ui::effects_enabled()`.
+
+**Text.** Views hold no text: they call `i18n::t(key)`, `i18n::tf(key, vars)`
+to fill `{name}` placeholders, or `i18n::tn(key, count, vars)` for a string that
+depends on a count (`key_one` and `key_other`; French also uses the singular
+for zero). Numbers with decimals and percentages go through `i18n::decimal` and
+`i18n::percent`, which follow French typography (`12,5 %`). English is
+canonical: name a new key after its English meaning and write the English first,
+then the French. `cargo test` fails when `en.json` and `fr.json` hold different
+keys, when a string is empty or the placeholders differ, when a key is missing
+or unused, or when one repeats a shared colony-ui label. French uses the vous
+form and a non-breaking space before `:`, `;`, `!` and `?`. Store, program and
+theme names stay untranslated. Game statuses are stored by their English name
+(`GameStatus`'s `Display`) and shown through `GameStatus::label`.
+
+**Files.** Spotter's own paths come from `paths::layout()` in `src/paths.rs`,
+built on `colony_ui::paths` (`config_dir`, `data_dir`, `cache_dir`); never
+build them from `dirs` directly. The layout is resolved, and its folders
+created, once at startup.
+
+**Errors.** Most functions still return `Result`; the database layer
+uses `SpotterError` from `error.rs`, and new code should prefer it.
diff --git a/env.example b/env.example
deleted file mode 100644
index 108b06a..0000000
--- a/env.example
+++ /dev/null
@@ -1,21 +0,0 @@
-# Spotter — Environment Variables
-#
-# These are NOT required for basic usage. Spotter keeps the credentials you
-# enter in the app in the OS keyring (or, when none is available, in its
-# local SQLite database).
-#
-# Set XDG_DATA_HOME to override the default data directory:
-# XDG_DATA_HOME=/path/to/custom/dir
-#
-# ── Platform API keys (configured in Spotter's Profile UI) ──
-#
-# Steam: Get an API key at https://steamcommunity.com/dev/apikey
-# STEAM_API_KEY=
-#
-# Xbox: Get a free API key at https://xbl.io
-# XBOX_API_KEY=
-#
-# PlayStation: Get NPSSO token from https://ca.account.sony.com/api/v1/ssocookie
-# PSN_NPSSO=
-#
-# GOG: Uses OAuth login flow — no manual API key needed