# Technical Documentation: jeffvli/feishin

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/jeffvli/feishin)

### Overview (`README.md`)

<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" width="60px" />

# Feishin

  <p align="center">
    <a href="https://github.com/jeffvli/feishin/blob/main/LICENSE">
      <img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen"
      alt="License">
    </a>
      <a href="https://github.com/jeffvli/feishin/releases">
      <img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
      alt="Release">
    </a>
    <a href="https://github.com/jeffvli/feishin/releases">
      <img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange"
      alt="Downloads">
    </a>
  </p>
  <p align="center">
    <a href="https://discord.gg/FVKpcMDy5f">
      <img src="https://img.shields.io/discord/922656312888811530?color=black&label=discord&logo=discord&logoColor=white"
      alt="Discord">
    </a>
    <a href="https://matrix.to/#/#sonixd:matrix.org">
      <img src="https://img.shields.io/matrix/sonixd:matrix.org?color=black&label=matrix&logo=matrix&logoColor=white"
      alt="Matrix">
    </a>
  </p>

---

Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).

## Features

- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)

## Screenshots

<a href="./media/preview_full_screen_player.png"><img src="./media/preview_full_screen_player.png" width="49.5%"/></a> <a href="./media/preview_album_artist_detail.png"><img src="./media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="./media/preview_album_detail.png"><img src="./media/preview_album_detail.png" width="49.5%"/></a> <a href="./media/preview_smart_playlist.png"><img src="./media/preview_smart_playlist.png" width="49.5%"/></a>

## Getting Started

### Desktop (recommended)

Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.

#### macOS Notes

If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.

For media keys to work, you will be prompted to allow Feishin to be a Trusted Accessibility Client. After allowing, you will need to restart Feishin for the privacy settings to take effect.

#### Linux Notes

Feishin is available in [Flathub](https://flathub.org/en/apps/org.jeffvli.feishin).

Alternatively, you can install it as an Appimage. We provide a small install script to download the latest `.AppImage`, make it executable, and also download the icons required by Desktop Environments. Finally, it generates a `.desktop` file to add Feishin to your Application Launcher.

Simply run the installer like this:

```sh
dir=/your/application/directory
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir"
```

The script also has an option to add launch arguments to run Feishin in native Wayland mode. Note that this is experimental in Electron and therefore not officially supported. If you want to use it, run this instead:

```sh
dir=/your/application/directory
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir" wayland-native
```

It also provides a simple uninstall routine, removing the downloaded files:

```sh
dir=/your/application/directory
curl 'https://raw.githubusercontent.com/jeffvli/feishin/refs/heads/development/install-feishin-appimage' | sh -s -- "$dir" remove
```

The entry should show up in your Application Launcher immediately. If it does not, simply log out, wait 10 seconds, and log back in. Your Desktop Environment may alternatively provide a way to reload entries.

### Web and Docker

Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.

Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:

```bash
# Run the latest version
docker run --name feishin -p 9180:9180 ghcr.io/jeffvli/feishin:latest

# Build the image locally
docker build -t feishin .
docker run --name feishin -p 9180:9180 feishin
```

#### Docker Compose

To install via Docker Compose, use the following snippet. This also works on Portainer.

```yaml
services:
    feishin:
        container_name: feishin
        image: 'ghcr.io/jeffvli/feishin:latest'
        restart: unless-stopped
        environment:
            - SERVER_NAME=jellyfin # pre-defined server name
            - SERVER_LOCK=true # When true AND name/type/url are set, only username/password can be toggled
            - SERVER_TYPE=jellyfin # the allowed types are: jellyfin, navidrome, subsonic. These values are case insensitive
            - SERVER_URL= # http://address:port or https://address:port
            - REMOTE_URL= # http://address or https://address
            - LEGACY_AUTHENTICATION=false # When SERVER_LOCK is true, sets the legacy (plaintext) authentication flag for Subsonic/OpenSubsonic servers
            - ANALYTICS_DISABLED=true # Set to true to disable Umami analytics tracking
        ports:
            - 9180:9180
            # Alternatively, to restrict to only localhost, - 127.0.0.1:9180:8190
```

### Configuration

1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.

2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).

- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
    - **Linux users** - The default password store uses `libsecret`. `kwallet4/5/6` are also supported, but must be explicitly set in Settings > Window > Passwords/secret store.

3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.

4. _Optional_ - To hard code the server url, pass the following environment variables: `SERVER_NAME`, `SERVER_TYPE` (one of `jellyfin` or `navidrome` or `subsonic`), `SERVER_URL`. To prevent users from changing these settings, pass `SERVER_LOCK=true`. This can only be set if all three of the previous values are set. When `SERVER_LOCK=true`, you can also set `LEGACY_AUTHENTICATION=true` or `LEGACY_AUTHENTICATION=false` to configure the legacy authentication flag for the server (only applicable for Subsonic/OpenSubsonic servers).

5. _Optional_ - If your server uses a separate public-facing URL than what integrating applications use internally to communicate with your server, such as a separate Navidrome `ShareURL`, set `REMOTE_URL` to said public-facing URL.

6. _Optional_ - To disable Umami analytics tracking in the Docker/web version, set the environment variable `ANALYTICS_DISABLED=true`. When enabled, the analytics script will not be loaded and all tracking will be disabled.

7. _Optional_ - App settings (theme, language, sidebar options, etc.) can be overridden with environment variables on first run. The variables use the `FS_` prefix (e.g. `FS_GENERAL_THEME=defaultDark`, `FS_GENERAL_LANGUAGE=de`). See [the settings environment variable documentation](docs/ENV_SETTINGS.md) for the full list.

## FAQ

### MPV is either not working or is rapidly switching between pause/play states

First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.

### What music servers does Feishin support?

Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/), [Jellyfin](https://jellyfin.org/), or [OpenSubsonic compatible](https://opensubsonic.netlify.app/) API.

- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [OpenSubsonic](https://opensubsonic.netlify.app/) compatible servers, such as...
    - [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
    - [Ampache](https://ampache.org)
    - [Astiga](https://asti.ga/)
    - [Funkwhale](https://www.funkwhale.audio/)
    - [Gonic](https://github.com/sentriz/gonic)
    - [LMS](https://github.com/epoupon/lms)
    - [Nextcloud Music](https://apps.nextcloud.com/apps/music)
    - [Supysonic](https://github.com/spl0k/supysonic)
    - [Qm-Music](https://github.com/chenqimiao/qm-music)
    - More (?)

- [Plex](https://www.plex.tv/media-server-downloads)
    - [Feishin fork by lux032](https://github.com/lux032/feishin) - Plex is not natively supported. Use the fork by lux032 to use Plex with Feishin.

### I have the issue "The SUID sandbox helper binary was found, but is not configured correctly" on Linux

This happens when you have user (unprivileged) namespaces disabled (`sysctl kernel.unprivileged_userns_clone` returns 0). You can fix this by either enabling unprivileged namespaces, or by making the `chrome-sandbox` Setuid.

```bash
chmod 4755 chrome-sandbox
sudo chown root:root chrome-sandbox
```

Ubuntu 24.04 specifically introduced breaking changes that affect how namespaces work. Please see https://discourse.ubuntu.com/t/ubuntu-24-04-lts-noble-numbat-release-notes/39890#:~:text=security%20improvements%20 for possible fixes.

### How can I add custom themes?

On the desktop app, you can add custom themes by dropping JSON files into the Themes folder (Settings → General → Theme → Open Folder). See [the custom themes documentation](docs/CUSTOM_THEMES.md) for the file format and examples.

## Development

Built and tested using Node `v23.11.0`.

This project is built off of [electron-vite](https://github.com/alex8088/electron-vite)

- `pnpm run dev` - Start the development server
- `pnpm run dev:watch` - Start the development server in watch mode (for main / preload HMR)
- `pnpm run start` - Starts the app in production preview mode
- `pnpm run build` - Builds the app for desktop
- `pnpm run build:electron` - Build the electron app (main, preload, and renderer)
- `pnpm run build:remote` - Build the remote app (remote)
- `pnpm run build:web` - Build the standalone web app (renderer)
- `pnpm run package` - Package the project
- `pnpm run package:dev` - Package the project for development locally
- `pnpm run package:linux` - Package the project for Linux locally
- `pnpm run package:mac` - Package the project for Mac locally
- `pnpm run package:win` - Package the project for Windows locally
- `pnpm run publish:linux` - Publish the project for Linux
- `pnpm run publish:linux:beta` - Publish the project for Linux (beta channel)
- `pnpm run publish:linux-arm64` - Publish the project for Linux ARM64
- `pnpm run publish:linux-arm64:beta` - Publish the project for Linux ARM64 (beta channel)
- `pnpm run publish:mac` - Publish the project for Mac
- `pnpm run publish:mac:beta` - Publish the project for Mac (beta channel)
- `pnpm run publish:win` - Publish the project for Windows
- `pnpm run publish:win:beta` - Publish the project for Windows (beta channel)
- `pnpm run typecheck` - Type check the project
- `pnpm run typecheck:node` - Type check the project with tsconfig.node.json
- `pnpm run typecheck:web` - Type check the project with tsconfig.web.json
- `pnpm run lint` - Lint the project
- `pnpm run lint:fix` - Lint the project and fix linting errors
- `pnpm run i18next` - Generate i18n files

## Translation

This project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.

## License

[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)

---

### Overview (`.agents/skills/caveman-commit/README.md`)

# caveman-commit

Terse Conventional Commits. Why over what.

## What it does

Generates commit messages in Conventional Commits format. Subject ≤50 chars, hard cap 72. Imperative mood. Body only when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts — future debuggers need the context.

Outputs only the message. Does not stage, commit, or amend.

## How to invoke

```
/caveman-commit
```

Also triggers on phrases like "write a commit", "commit message", "generate commit".

## Example output

Diff: new endpoint for user profile.

```
feat(api): add GET /users/:id/profile

Mobile client needs profile data without the full user payload
to reduce LTE bandwidth on cold-launch screens.

Closes #128
```

Diff: breaking API rename.

```
feat(api)!: rename /v1/orders to /v1/checkout

BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout
before 2026-06-01. Old route returns 410 after that date.
```

## See also

- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview

---

### Overview (`.agents/skills/caveman-review/README.md`)

# caveman-review

One-line PR comments. Location, problem, fix. No throat-clearing.

## What it does

Generates code review comments in `L<line>: <severity> <problem>. <fix>.` format. One line per finding. Severity emoji: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps exact line numbers, backticked symbols, and concrete fixes.

Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts where the author needs the *why*. Resumes terse for the rest.

Output only — does not approve, request changes, or run linters.

## How to invoke

```
/caveman-review
```

Also triggers on "review this PR", "code review", "review the diff".

## Example output

```
L42: 🔴 bug: user can be null after .find(). Add guard before .email.
L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.
L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).
L107: ❓ q: why drop the cache here? Reads on next request will miss.
```

## See also

- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview

---

### Overview (`.claude/skills/caveman-commit/README.md`)

# caveman-commit

Terse Conventional Commits. Why over what.

## What it does

Generates commit messages in Conventional Commits format. Subject ≤50 chars, hard cap 72. Imperative mood. Body only when the *why* is non-obvious or there are breaking changes. No AI attribution, no "this commit does X", no emoji unless the project uses them. Body always required for breaking changes, security fixes, data migrations, and reverts — future debuggers need the context.

Outputs only the message. Does not stage, commit, or amend.

## How to invoke

```
/caveman-commit
```

Also triggers on phrases like "write a commit", "commit message", "generate commit".

## Example output

Diff: new endpoint for user profile.

```
feat(api): add GET /users/:id/profile

Mobile client needs profile data without the full user payload
to reduce LTE bandwidth on cold-launch screens.

Closes #128
```

Diff: breaking API rename.

```
feat(api)!: rename /v1/orders to /v1/checkout

BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout
before 2026-06-01. Old route returns 410 after that date.
```

## See also

- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview

---

### Overview (`.claude/skills/caveman-review/README.md`)

# caveman-review

One-line PR comments. Location, problem, fix. No throat-clearing.

## What it does

Generates code review comments in `L<line>: <severity> <problem>. <fix>.` format. One line per finding. Severity emoji: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question. Drops "I noticed that...", hedging, and restating what the diff already shows. Keeps exact line numbers, backticked symbols, and concrete fixes.

Auto-clarity: drops terse mode for CVE-class security findings, architectural disagreements, and onboarding contexts where the author needs the *why*. Resumes terse for the rest.

Output only — does not approve, request changes, or run linters.

## How to invoke

```
/caveman-review
```

Also triggers on "review this PR", "code review", "review the diff".

## Example output

```
L42: 🔴 bug: user can be null after .find(). Add guard before .email.
L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.
L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).
L107: ❓ q: why drop the cache here? Reads on next request will miss.
```

## See also

- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions
- [Caveman README](../../README.md) — repo overview

---

### CUSTOM THEMES (`docs/CUSTOM_THEMES.md`)

# Custom Themes

Custom themes let you add your own light and dark themes to the **desktop** app without rebuilding Feishin. Drop JSON theme files into the Themes folder; Feishin watches that folder and reloads themes when files change.

Custom themes are **Electron / desktop only**. They are not available in the web or Docker builds.

---

## Getting started

1. Open **Settings → General → Theme**.
2. Under **Custom Themes**, click **Open Folder** to reveal the Themes directory.
3. Add a `.json` file (for example `my-theme.json`).
4. Select the theme from the Theme dropdown (it appears under Dark or Light based on `mode`).

You can also click **Reload** in Settings if a change was not picked up automatically.

### Themes folder location

| Platform | Typical path |
|----------|----------------|
| Windows | `%APPDATA%\feishin\Themes` |
| macOS | `~/Library/Application Support/feishin/Themes` |
| Linux | `~/.config/feishin/Themes` |

In development builds the folder name is under `feishin-dev` instead of `feishin`.

Theme files must be **`.json` files in the root of the Themes folder** (not nested in subfolders). Linked stylesheets may live next to them or in subfolders, as long as they stay inside Themes.

---

## Theme file format

Each theme is a single JSON object.

```json
{
  "mode": "dark",
  "extends": "defaultDark",
  "colors": {
    "primary": "rgb(53, 116, 252)",
    "background": "rgb(12, 12, 12)",
    "foreground": "rgb(225, 225, 225)"
  },
  "app": {
    "scrollbar-size": "9px"
  },
  "mantineOverride": {
    "primaryShade": {
      "dark": 6
    }
  },
  "stylesheets": ["my-theme.css"]
}
```

### Identity

| Derived from | Behavior |
|--------------|----------|
| **id** | Filename without `.json` (e.g. `rose-pine-custom.json` → `rose-pine-custom`) |
| **label** | Title-cased id with `-` / `_` replaced by spaces (e.g. `Rose Pine Custom`) |

Use a unique filename. The id is what appears in settings and what you use when another theme `extends` this one.

### Fields

| Field | Required | Description |
|-------|----------|-------------|
| `mode` | No (default `dark`) | `"dark"` or `"light"`. Controls which theme group the theme appears in and the app color scheme. |
| `extends` | No | Built-in theme id or another custom theme’s id (filename without `.json`). Your theme is merged on top of the base. |
| `colors` | No | Palette overrides. Invalid CSS colors are dropped and shown as a warning in Settings. |
| `app` | No | App chrome / layout CSS variable overrides. |
| `mantineOverride` | No | Partial [Mantine theme override](https://mantine.dev/theming/theme-object/). |
| `stylesheets` | No | Array of CSS file paths **relative to the Themes folder**. Contents are inlined when the theme is active. Paths that escape the Themes folder are ignored. |

Unspecified color and app values fall back to Feishin’s default theme (and then to anything provided by `extends`).

---

## Colors

Supported `colors` keys:

| Key | Typical use |
|-----|-------------|
| `background` | Main app background |
| `background-alternate` | Alternate / nested background |
| `surface` | Cards, inputs, elevated surfaces |
| `surface-foreground` | Text on surfaces |
| `foreground` | Primary text |
| `foreground-muted` | Secondary / muted text |
| `primary` | Accent / primary action color (also drives generated primary shades) |
| `black` | Black token |
| `white` | White token |
| `state-error` | Error state |
| `state-info` | Info state |
| `state-success` | Success state |
| `state-warning` | Warning state |

Values must be valid CSS colors recognized by Feishin’s color validator (for example `rgb(...)`, `rgba(...)`, `#rrggbb`, `#rgb`). Named CSS colors like `red` are rejected. Invalid entries are ignored and listed under Custom Themes warnings in Settings.

---

## App variables

Supported `app` keys (CSS values as strings):

| Key | Description |
|-----|-------------|
| `content-max-width` | Max width of main content |
| `root-font-size` | Root font size |
| `overlay-header` | Header overlay background |
| `overlay-subheader` | Subheader overlay background |
| `scrollbar-size` | Scrollbar thickness |
| `scrollbar-handle-background` | Scrollbar handle |
| `scrollbar-handle-hover-background` | Handle on hover |
| `scrollbar-handle-active-background` | Handle when active |
| `scrollbar-handle-border-radius` | Handle corner radius |
| `scrollbar-track-background` | Scrollbar track |
| `scrollbar-track-hover-background` | Track on hover |
| `scrollbar-track-active-background` | Track when active |
| `scrollbar-track-border-radius` | Track corner radius |

---

## Extending themes

Use `extends` to start from a built-in or another custom theme and only override what you need.

```json
{
  "mode": "dark",
  "extends": "nord",
  "colors": {
    "primary": "#88c0d0"
  }
}
```

Rules:

- Custom theme fields always win over the theme they extend.
- Custom → custom chains are flattened when loading (depth limit of 10; cycles are ignored).
- If `extends` is a built-in id, merging with that built-in’s defaults happens in the renderer.
- If `extends` is omitted, the theme still merges onto Feishin’s shared default palette.

### Built-in theme ids

Use these as `extends` values (same ids as in Settings):

`ayuDark`, `ayuLight`, `catppuccinLatte`, `catppuccinMocha`, `defaultDark`, `defaultLight`, `dracula`, `everforestDark`, `everforestLight`, `githubDark`, `githubLight`, `glassyDark`, `gruvboxDark`, `gruvboxLight`, `highContrastDark`, `highContrastLight`, `materialDark`, `materialLight`, `monokai`, `nightOwl`, `nord`, `oneDark`, `rosePine`, `rosePineDawn`, `rosePineMoon`, `shadesOfPurple`, `solarizedDark`, `solarizedLight`, `tokyoNight`, `vscodeDarkPlus`, `vscodeLightPlus`, `zenburn`

---

## Linked stylesheets

For larger visual overrides (glass effects, layout tweaks, etc.), point `stylesheets` at CSS files inside the Themes folder:

```json
{
  "mode": "dark",
  "extends": "defaultDark",
  "stylesheets": ["overrides/my-theme.css"]
}
```

```text
Themes/
  my-theme.json
  overrides/
    my-theme.css
```

Notes:

- Feishin watches **JSON** theme files for automatic reload. After editing only a CSS file, click **Reload** in Settings (or touch/save the `.json` file) so stylesheets are re-read.
- Empty or unreadable stylesheet paths are skipped with a console warning.

---

## Examples

### Minimal accent-only dark theme

`accent-blue.json`:

```json
{
  "mode": "dark",
  "extends": "defaultDark",
  "colors": {
    "primary": "rgb(80, 160, 255)"
  }
}
```

### Full light palette

`paper-light.json`:

```json
{
  "mode": "light",
  "colors": {
    "background": "rgb(250, 249, 246)",
    "background-alternate": "rgb(242, 240, 235)",
    "surface": "rgb(255, 255, 255)",
    "surface-foreground": "rgb(40, 40, 40)",
    "foreground": "rgb(30, 30, 30)",
    "foreground-muted": "rgb(110, 110, 110)",
    "primary": "rgb(180, 83, 9)",
    "black": "rgb(0, 0, 0)",
    "white": "rgb(255, 255, 255)",
    "state-error": "rgb(185, 28, 28)",
    "state-info": "rgb(37, 99, 235)",
    "state-success": "rgb(22, 163, 74)",
    "state-warning": "rgb(217, 119, 6)"
  },
  "app": {
    "overlay-header": "linear-gradient(rgb(250 249 246 / 50%) 0%, rgb(250 249 246 / 80%))",
    "scrollbar-handle-background": "rgba(120, 120, 120, 30%)"
  },
  "mantineOverride": {
    "primaryShade": {
      "light": 5
    }
  }
}
```

### Theme that extends another custom theme

`nord-soft.json`:

```json
{
  "mode": "dark",
  "extends": "nord",
  "colors": {
    "background": "rgb(36, 41, 51)",
    "surface": "rgb(46, 52, 64)"
  }
}
```

---

## Troubleshooting

| Symptom | What to check |
|---------|----------------|
| Theme missing from the list | File must be `.json` in the Themes root; use **Reload**. |
| Theme shows an error in Settings | JSON is invalid or not a top-level object. Fix the file and reload. |
| Theme loads with a warning | One or more `colors` values were invalid and ignored. |
| CSS changes not applying | Edit/save the `.json` or click **Reload**; stylesheet watch is tied to JSON changes. |
| Extending does nothing / looks wrong | Confirm the `extends` id matches a built-in id or another custom filename (without `.json`). Avoid circular chains. |

Broken themes still appear in Settings with their error message so you can fix them without digging through logs.

---

### ENV SETTINGS (`docs/ENV_SETTINGS.md`)

# Environment variables for settings (web / Docker)

These variables override app settings **on first run** when no persisted settings exist. They are injected via `settings.js` (from `settings.js.template`) and only apply to the **web** build.

**Format:** All values are strings; booleans use `true`/`false`, numbers are numeric strings. Leave unset or empty to use the default.

---

## General

| Setting | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `general.accent` | `rgb(53, 116, 252)` | `FS_GENERAL_ACCENT` | CSS `rgb(r, g, b)` string (e.g. `rgb(53, 116, 252)`). Invalid values are ignored. |
| `general.albumBackground` | `false` | `FS_GENERAL_ALBUM_BACKGROUND` | `true` / `false` — Show album background image. |
| `general.albumBackgroundBlur` | `3` | `FS_GENERAL_ALBUM_BACKGROUND_BLUR` | Blur amount for album background (number). |
| `general.artistBackground` | `true` | `FS_GENERAL_ARTIST_BACKGROUND` | `true` / `false` — Show artist background image. |
| `general.artistBackgroundBlur` | `3` | `FS_GENERAL_ARTIST_BACKGROUND_BLUR` | Blur amount for artist background (number). |
| `general.blurExplicitImages` | `false` | `FS_GENERAL_BLUR_EXPLICIT_IMAGES` | `true` / `false` — Blur explicit images. |
| `general.combinedLyricsAndVisualizer` | `false` | `FS_GENERAL_COMBINED_LYRICS_AND_VISUALIZER` | `true` / `false` — Combine lyrics and visualizer panel. |
| `general.enableGridMultiSelect` | `false` | `FS_GENERAL_ENABLE_GRID_MULTI_SELECT` | `true` / `false` — Enable multi-select in grid views. |
| `general.externalLinks` | `true` | `FS_GENERAL_EXTERNAL_LINKS` | `true` / `false` — Show external links in UI. |
| `general.followCurrentSong` | `true` | `FS_GENERAL_FOLLOW_CURRENT_SONG` | `true` / `false` — Follow current song in list. |
| `general.followSystemTheme` | `false` | `FS_GENERAL_FOLLOW_SYSTEM_THEME` | `true` / `false` — Use OS light/dark preference. |
| `general.homeFeature` | `true` | `FS_GENERAL_HOME_FEATURE` | `true` / `false` — Show home featured carousel. |
| `general.homeFeatureStyle` | `single` | `FS_GENERAL_HOME_FEATURE_STYLE` | `multiple` / `single` - Home featured carousel style. |
| `general.imagePlaceholderPriority` | `thumbhash` | `FS_GENERAL_IMAGE_PLACEHOLDER_PRIORITY` | `thumbhash` / `blurhash` / `dominantColor` / `off` - Which image placeholder shows first while art loads; `off` disables placeholders. |
| `general.language` | `en` | `FS_GENERAL_LANGUAGE` | UI language code (e.g. `en`, `de`, `fr`). |
| `general.theme` | `defaultDark` | `FS_GENERAL_THEME` | One of: `ayuDark`, `ayuLight`, `catppuccinLatte`, `catppuccinMocha`, `defaultDark`, `defaultLight`, `dracula`, `githubDark`, `githubLight`, `glassyDark`, `gruvboxDark`, `gruvboxLight`, `highContrastDark`, `highContrastLight`, `materialDark`, `materialLight`, `monokai`, `nightOwl`, `nord`, `oneDark`, `rosePine`, `rosePineDawn`, `rosePineMoon`, `shadesOfPurple`, `solarizedDark`, `solarizedLight`, `tokyoNight`, `vscodeDarkPlus`, `vscodeLightPlus`. |
| `general.themeDark` | `defaultDark` | `FS_GENERAL_THEME_DARK` | Same as theme (used when system is dark). |
| `general.themeLight` | `defaultLight` | `FS_GENERAL_THEME_LIGHT` | Same as theme (used when system is light). |
| `general.lastfmApiKey` | *(empty)* | `FS_GENERAL_LASTFM_API_KEY` | Last.fm API key. |
| `general.lastFM` | `true` | `FS_GENERAL_LAST_FM` | `true` / `false` — Enable Last.fm. |
| `general.listenBrainz` | `true` | `FS_GENERAL_LISTEN_BRAINZ` | `true` / `false` — ListenBrainz links. |
| `general.musicBrainz` | `true` | `FS_GENERAL_MUSIC_BRAINZ` | `true` / `false` — MusicBrainz links. |
| `general.nativeAspectRatio` | `false` | `FS_GENERAL_NATIVE_ASPECT_RATIO` | `true` / `false` — Use native cover art aspect ratio. |
| `general.pathReplace` | *(empty)* | `FS_GENERAL_PATH_REPLACE` | Path pattern to replace (e.g. server path in Docker). |
| `general.pathReplaceWith` | *(empty)* | `FS_GENERAL_PATH_REPLACE_WITH` | Replacement path. |
| `general.playerbarOpenDrawer` | `false` | `FS_GENERAL_PLAYERBAR_OPEN_DRAWER` | `true` / `false` — Open queue/lyrics as drawer from player bar. |
| `general.primaryShade` | `6` | `FS_GENERAL_PRIMARY_SHADE` | Mantine primary shade 0–9 (number). |
| `general.qobuz` | `true` | `FS_GENERAL_QOBUZ` | `true` / `false` — Qobuz links. |
| `general.resume` | `true` | `FS_GENERAL_RESUME` | `true` / `false` — Resume playback on load. |
| `general.showLyricsInSidebar` | `true` | `FS_GENERAL_SHOW_LYRICS_IN_SIDEBAR` | `true` / `false` — Show lyrics in sidebar. |
| `general.showRatings` | `true` | `FS_GENERAL_SHOW_RATINGS` | `true` / `false` — Show star ratings. |
| `general.showVisualizerInSidebar` | `true` | `FS_GENERAL_SHOW_VISUALIZER_IN_SIDEBAR` | `true` / `false` — Show visualizer in sidebar. |
| `general.sidebarCollapsedNavigation` | `true` | `FS_GENERAL_SIDEBAR_COLLAPSED_NAVIGATION` | `true` / `false` — Start with collapsed sidebar nav. |
| `general.sidebarCollapseShared` | `false` | `FS_GENERAL_SIDEBAR_COLLAPSE_SHARED` | `true` / `false` — Share sidebar collapse state. |
| `general.sidebarImageExpand` | `false` | `FS_GENERAL_SIDEBAR_IMAGE_EXPAND` | `true` / `false` — Show the player-bar album-art button that expands art into the sidebar. |
| `general.sidebarPlaylistFolders` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDERS` | `true` / `false` — Group playlists into folders by name separator. |
| `general.sidebarPlaylistFolderSeparator` | `/` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_SEPARATOR` | Character or string that separates folder levels in a playlist name. Empty = use default. |
| `general.sidebarPlaylistFolderTreeIndent` | `16` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_INDENT` | Pixels each tree level is indented (0–64). |
| `general.sidebarPlaylistFolderTreeLineColor` | *(empty)* | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_TREE_LINE_COLOR` | CSS color for tree connecting lines. Empty = theme default. |
| `general.sidebarPlaylistFolderView` | `tree` | `FS_GENERAL_SIDEBAR_PLAYLIST_FOLDER_VIEW` | `single` / `tree` / `navigation` — How folders are displayed in the sidebar. |
| `general.sidebarPlaylistList` | `true` | `FS_GENERAL_SIDEBAR_PLAYLIST_LIST` | `true` / `false` — Show playlist list in sidebar. |
| `general.sidebarPlaylistMode` | `expanded` | `FS_GENERAL_SIDEBAR_PLAYLIST_MODE` | `compact` / `expanded` — Sidebar playlist row layout. |
| `general.sidebarPlaylistSorting` | `false` | `FS_GENERAL_SIDEBAR_PLAYLIST_SORTING` | `true` / `false` — Enable playlist sorting in sidebar. |
| `general.sideQueueType` | `sideQueue` | `FS_GENERAL_SIDE_QUEUE_TYPE` | `sideDrawerQueue` / `sideQueue` — Side play queue style. |
| `general.sideQueueLayout` | `horizontal` | `FS_GENERAL_SIDE_QUEUE_LAYOUT` | `horizontal` / `vertical` — Attached side queue layout orientation. |
| `general.useThemeAccentColor` | `false` | `FS_GENERAL_USE_THEME_ACCENT_COLOR` | `true` / `false` — Use theme’s accent color instead of custom. |
| `general.useThemePrimaryShade` | `true` | `FS_GENERAL_USE_THEME_PRIMARY_SHADE` | `true` / `false` — Use theme’s primary shade. |
| `general.zoomFactor` | `100` | `FS_GENERAL_ZOOM_FACTOR` | UI zoom percentage (number). |

---

## Playback

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `playback.mediaSession` | `false` | `FS_PLAYBACK_MEDIA_SESSION` | `true` / `false` — Media Session API (e.g. browser/media keys). |
| `playback.webAudio` | `true` | `FS_PLAYBACK_WEB_AUDIO` | `true` / `false` — Use Web Audio for playback. |
| `playback.audioFadeOnStatusChange` | `true` | `FS_PLAYBACK_AUDIO_FADE_ON_STATUS_CHANGE` | `true` / `false` — Fade on play/pause. |
| `playback.preservePitch` | `true` | `FS_PLAYBACK_PRESERVE_PITCH` | `true` / `false` — Preserve pitch when changing speed. |
| `playback.scrobble.enabled` | `true` | `FS_PLAYBACK_SCROBBLE_ENABLED` | `true` / `false` — Enable scrobbling. |
| `playback.scrobble.notify` | `false` | `FS_PLAYBACK_SCROBBLE_NOTIFY` | `true` / `false` — Scrobble notifications. |
| `playback.scrobble.scrobbleAtDuration` | `240` | `FS_PLAYBACK_SCROBBLE_AT_DURATION` | Seconds of playback before scrobble. |
| `playback.scrobble.scrobbleAtPercentage` | `75` | `FS_PLAYBACK_SCROBBLE_AT_PERCENTAGE` | Percentage of track before scrobble. |
| `playback.transcode.enabled` | `false` | `FS_PLAYBACK_TRANSCODE_ENABLED` | `true` / `false` — Enable transcoding. |
| `playback.transcode.format` | *(unset)* | `FS_PLAYBACK_TRANSCODE_FORMAT` | Transcode format string (codec/container), e.g. server-specific value. Empty = use default. |
| `playback.transcode.bitrate` | *(unset)* | `FS_PLAYBACK_TRANSCODE_BITRATE` | Transcode bitrate (number, kbps or as defined by server). |
| `playback.filters` | `[]` | `FS_PLAYBACK_FILTERS` | JSON array of player filters: each object needs `id`, `field`, `operator`, `value`; optional `isEnabled`. Invalid JSON or shape is ignored. |

---

## Discord

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `discord.enabled` | `false` | `FS_DISCORD_ENABLED` | `true` / `false` — Discord rich presence. |
| `discord.clientId` | *(built-in)* | `FS_DISCORD_CLIENT_ID` | Custom Discord application ID. |
| `discord.displayType` | `feishin` | `FS_DISCORD_DISPLAY_TYPE` | `artist` / `feishin` / `song`. |
| `discord.linkType` | `none` | `FS_DISCORD_LINK_TYPE` | `last_fm` / `musicbrainz` / `musicbrainz_last_fm` / `none`. |
| `discord.showAsListening` | `false` | `FS_DISCORD_SHOW_AS_LISTENING` | `true` / `false`. |
| `discord.showPaused` | `true` | `FS_DISCORD_SHOW_PAUSED` | `true` / `false` — Show paused state. |
| `discord.showServerImage` | `false` | `FS_DISCORD_SHOW_SERVER_IMAGE` | `true` / `false`. |
| `discord.showStateIcon` | `true` | `FS_DISCORD_SHOW_STATE_ICON` | `true` / `false`. |

---

## Lyrics

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `lyrics.fetch` | `true` | `FS_LYRICS_FETCH` | `true` / `false` — Fetch lyrics. |
| `lyrics.follow` | `true` | `FS_LYRICS_FOLLOW` | `true` / `false` — Follow current line. |
| `lyrics.delayMs` | `0` | `FS_LYRICS_DELAY_MS` | Sync delay in milliseconds. |
| `lyrics.lineLeadTimeMs` | `800` | `FS_LYRICS_LINE_LEAD_TIME_MS` | Milliseconds before a line becomes active while following. |
| `lyrics.followScrollAlignment` | `0` | `FS_LYRICS_FOLLOW_SCROLL_ALIGNMENT` | Vertical follow position. `0` centers the active line; negative moves it higher, positive moves it lower (`-50` to `50`). |
| `lyrics.preferLocalLyrics` | `true` | `FS_LYRICS_PREFER_LOCAL` | `true` / `false` — Prefer local lyric files. |
| `lyrics.showMatch` | `true` | `FS_LYRICS_SHOW_MATCH` | `true` / `false`. |
| `lyrics.showProvider` | `true` | `FS_LYRICS_SHOW_PROVIDER` | `true` / `false`. |
| `lyrics.enableAutoTranslation` | `false` | `FS_LYRICS_ENABLE_AUTO_TRANSLATION` | `true` / `false`. |
| `lyrics.translationApiKey` | *(empty)* | `FS_LYRICS_TRANSLATION_API_KEY` | API key for lyric translation. |
| `lyrics.translationTargetLanguage` | `en` | `FS_LYRICS_TRANSLATION_TARGET_LANGUAGE` | Target language code. |
| `lyrics.alignment` | `center` | `FS_LYRICS_ALIGNMENT` | `center` / `left` / `right`. |

---

## Lyrics display

Applies to the default lyrics display profile (`lyricsDisplay.default`).

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `lyricsDisplay.default.paddingLeft` | `0` | `FS_LYRICS_PADDING_LEFT` | Left content padding as a percentage (`0`–`20`). |
| `lyricsDisplay.default.paddingRight` | `0` | `FS_LYRICS_PADDING_RIGHT` | Right content padding as a percentage (`0`–`20`). |

---

## Auto DJ

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `autoDJ.albumStrategy` | `similar` | `FS_AUTO_DJ_ALBUM_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.allowDuplicates` | `false` | `FS_AUTO_DJ_ALLOW_DUPLICATES` | `true` / `false` — Allow songs or albums already in the queue to be added again. |
| `autoDJ.enabled` | `false` | `FS_AUTO_DJ_ENABLED` | `true` / `false`. |
| `autoDJ.itemCount` | `5` | `FS_AUTO_DJ_ITEM_COUNT` | Number of items to add. |
| `autoDJ.mode` | `songs` | `FS_AUTO_DJ_MODE` | `songs` / `albums`. |
| `autoDJ.onlySimilar` | `false` | `FS_AUTO_DJ_ONLY_SIMILAR` | `true` / `false` — Treat item count as a maximum; use the first non-empty source (similar, then genre, artist, or random) and do not fill from later sources. |
| `autoDJ.songStrategy` | `similar` | `FS_AUTO_DJ_SONG_STRATEGY` | `similar` / `library_random`. |
| `autoDJ.timing` | `1` | `FS_AUTO_DJ_TIMING` | Timing value (number). |

---

## CSS

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `css.content` | *(empty)* | `FS_CSS_CONTENT` | Custom CSS string (sanitized like in-app custom CSS). Set `FS_CSS_ENABLED=true` to apply. |
| `css.enabled` | `false` | `FS_CSS_ENABLED` | `true` / `false` — Enable custom CSS. |

---

## Font

| Setting path | Default | Env variable | Available values / Description |
|-------------|---------|--------------|--------------------------------|
| `font.type` | `builtIn` | `FS_FONT_TYPE` | `builtIn` / `system` / `custom`. |
| `font.builtIn` | `Inter` | `FS_FONT_BUILT_IN` | Built-in font name. |
| `font.system` | *(empty)* | `FS_FONT_SYSTEM` | System font name (when type is `system`). |

---

### Agents Api (`docs/agents/api.md`)

# API

Server library data layer (Jellyfin / Navidrome / Subsonic). Read when adding or changing fetches, mutations, normalize, or query keys.

## Layers

| Layer | Path | Job |
|-------|------|-----|
| Feature queries / mutations | `src/renderer/features/<feature>/api/`, `.../mutations/` | `queryOptions` / `useMutation`; call `api.controller` |
| Facade | `src/renderer/api` → `controller.ts` | Pick backend from current server (`ServerType`) |
| Backend client + controller | `src/renderer/api/{jellyfin,navidrome,subsonic}/` | HTTP + map to controller endpoints |
| Types + normalize | `src/shared/api/{jellyfin,navidrome,subsonic}/` | Zod/types + `*Normalize` → domain |
| Domain contracts | `src/shared/types/domain-types.ts` | `ControllerEndpoint` shapes features rely on |
| Query keys | `src/renderer/api/query-keys.ts` | `[serverId, resource, …]`; use `splitPaginatedQuery` for list/count |

React Query client: `src/renderer/lib/react-query.ts`.

## Add a query

1. Prefer extending the matching `src/renderer/features/<feature>/api/*-api.ts` (or add one beside siblings).
2. Call `api.controller.<method>(…)` — do not invent one-off axios/fetch in UI.
3. Keys from `queryKeys` — keep `serverId` first; invalidate the same keys from mutations.
4. UI uses `useQuery(<feature>Queries.…)` like nearby features.

```ts
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
```

## Normalize / new endpoint

- Wire through the per-backend **controller**, not the feature component.
- Parse/validate with shared types; map with `jfNormalize` / `ndNormalize` / `ssNormalize` in `src/shared/api/...`.
- Keep domain types stable for features; backend quirks stay in normalize/controller.

## Upstream OpenAPI (last resort)

Do **not** fetch these for ordinary API work. Prefer in-repo controllers, `src/shared/api/*-types.ts`, normalize, and existing call sites.

Fetch **only** when an endpoint, field, or auth detail is missing or contradictory in-repo and you cannot finish without the vendor contract:

| Backend | Schema |
|---------|--------|
| OpenSubsonic (Subsonic / Navidrome-compatible) | https://opensubsonic.netlify.app/docs/openapi/openapi.json |
| Navidrome native API | https://github.com/navidrome/navidrome/tree/master/server/nativeapi |
| Jellyfin | https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json |

When you must open one: pull **only the path/operation/schema (or Go handler file) you need**, not the whole spec or folder into context. Then encode the result in our types/normalize/controller — do not leave features depending on the remote source.

## Don’t

- Bypass `api.controller` from feature UI.
- Put Jellyfin/Navidrome/Subsonic response shapes in React components — normalize first.
- Hand-roll query key arrays when `query-keys.ts` already has a factory.
- Open upstream OpenAPI “just in case” or to explore — in-repo code first.

---

### Agents Architecture (`docs/agents/architecture.md`)

# Architecture

Process and build boundaries. Read when work crosses main / preload / renderer / remote / web, or touches `window.api` / IPC.

## Layout

| Tree | Role |
|------|------|
| `src/main` | Electron main: window lifecycle, OS features, core IPC (player, remote server, themes, lyrics, …) |
| `src/preload` | `contextBridge` → `window.api` (`src/preload/index.ts`) |
| `src/renderer` | Full app UI (Electron renderer + web build root) |
| `src/remote` | Remote-control SPA, served by the Electron remote feature |
| `src/shared` | Cross-process UI, types, normalize, themes, utils — no process-specific imports |
| `src/i18n` | Locales + i18n setup |

## Builds

| Target | Command / config | Notes |
|--------|------------------|--------|
| Electron | `pnpm dev` / `build:electron` (`electron.vite.config.ts`) | main + preload + renderer |
| Web | `pnpm build:web` (`web.vite.config.ts`) | `out/web`; Docker uses this |
| Remote | `pnpm build:remote` (`remote.vite.config.ts`) | `out/remote` |
| Default package | `pnpm build` | electron + remote |

Desktop-only surfaces (custom themes, mpv, MPRIS, many `window.api.*` modules) must gate on `isElectron()` and null-check `window.api`. See `docs/CUSTOM_THEMES.md` for themes.

## Aliases

| Alias | → |
|-------|---|
| `/@/main` | `src/main` (Electron main; not in web/remote vite) |
| `/@/preload` | `src/preload` |
| `/@/renderer` | `src/renderer` |
| `/@/shared` | `src/shared` |
| `/@/i18n` | `src/i18n` |
| `/@/remote` | `src/remote` |

## Import boundaries (culture — not ESLint)

- **main** → `/@/main`, `/@/shared` only.
- **preload** → preload + shared (plus the existing relative main env exception).
- **shared** → no `/@/renderer`, `/@/main`, `/@/remote`, `/@/preload`.
- **renderer** → `/@/renderer`, `/@/shared`, `/@/i18n` — not `/@/main`.
- **remote** → `/@/remote`, `/@/shared`; may reuse selected `/@/renderer` utilities (theme, logger) — do not grow that into a full renderer dependency.

Electron capabilities from the UI: `window.api.*` (typed in `src/preload/index.d.ts`), never direct main imports.

## Related

- UI / CSS / i18n patterns: `docs/agents/frontend.md`
- Server library API: `docs/agents/api.md`
- Logging: `docs/agents/logging.md`

---

### Agents Commits (`docs/agents/commits.md`)

# Commits

Conventional Commits for history that can feed release notes and scoped changelogs. Enforced by commitlint (`commitlint.config.mjs`) and the Test workflow.

Automatic Weblate translation commits (subject `Translated using Weblate`) are skipped via the config's `ignores` list, since they do not follow the conventional format.

## Format

```
type(scope): subject

[optional body]

[optional footer]
```

Examples:

- `feat(player): add MPV reload control`
- `fix(window): restore minimized window`
- `chore(deps): bump electron`
- `feat!: drop legacy auth path` or footer `BREAKING CHANGE: …`

## Types

Use the conventional set (from `@commitlint/config-conventional`):

`feat` · `fix` · `perf` · `refactor` · `docs` · `style` · `test` · `build` · `ci` · `chore` · `revert`

Prefer `feat` / `fix` / `perf` for user-facing notes; keep chore/build/ci out of highlight sections when generating notes.

## Scopes

Scope = product area a release-note reader cares about — not Electron process (`main` / `preload`) and not “settings” (scope the feature the setting belongs to).

Scope is optional. When present, it must be one of (see `commitlint.config.mjs` — source of truth):

### Product

| Scope | Use for |
|-------|---------|
| `player` | Queue, engines, mpv/web, playerbar, now-playing playback (any process) |
| `radio` | Radio stations / radio playback |
| `lyrics` | Lyrics fetch, display, lyrics-specific options |
| `library` | Albums, artists, songs, folders, genres, favorites, search, playlists |
| `servers` | Server list, login, connection |
| `remote` | Remote-control SPA and remote server |
| `theme` | Themes, CSS variables, custom themes |
| `ui` | Pages, styling, and shared UI with no single feature owner |
| `visualizer` | Butterchurn / audio-motion visualizers |
| `window` | Titlebar, minimize/restore, fullscreen chrome |
| `sharing` | Shares |
| `tag-editor` | Tag editing |

### Meta

| Scope | Use for |
|-------|---------|
| `api` | Multi-backend controller / normalize / query keys with no single feature owner |
| `i18n` | Locales / Weblate |
| `ci` | GitHub Actions / workflows |
| `deps` | Dependency bumps |
| `release` | Versioning, changelogs, publish / packaging config |

Add a new scope in `commitlint.config.mjs` first, then document it here.

## Subject

- Imperative, present tense (“add”, not “added”)
- No trailing period
- Do not put agent attribution in the message (see `AGENTS.md`)

## Local check

```bash
pnpm exec commitlint --last
# or
echo "feat(player): your subject" | pnpm exec commitlint
```

CI runs commitlint over the PR or push range (see `.github/workflows/test.yml`).

---

### Agents Domain (`docs/agents/domain.md`)

# Domain Docs

How the engineering skills should consume this repo's domain documentation when exploring the codebase.

## Before exploring, read these

- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.

If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.

## File structure

Single-context repo (most repos):

```
/
├── CONTEXT.md
├── docs/adr/
│   ├── 0001-event-sourced-orders.md
│   └── 0002-postgres-for-write-model.md
└── src/
```

Multi-context repo (presence of `CONTEXT-MAP.md` at the root):

```
/
├── CONTEXT-MAP.md
├── docs/adr/                          ← system-wide decisions
└── src/
    ├── ordering/
    │   ├── CONTEXT.md
    │   └── docs/adr/                  ← context-specific decisions
    └── billing/
        ├── CONTEXT.md
        └── docs/adr/
```

## Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).

## Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:

> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

---

### Agents Frontend (`docs/agents/frontend.md`)

# Frontend

Conventions for UI in the renderer: imports, composition, CSS Modules, theme tokens, i18n, icons, toasts, modals, Zustand state.

## Imports

| Use | Path |
|-----|------|
| Shared UI / hooks / theme | `/@/shared/...` → `src/shared/...` |
| Features / layouts / stores | `/@/renderer/...` → `src/renderer/...` |
| i18n | `/@/i18n/...` or `react-i18next` / `i18next` as nearby files do |

Deep imports only — no component barrels. Example: `/@/shared/components/button/button`.

**Mantine:** feature UI imports wrappers under `/@/shared/components` and `/@/shared/hooks`, not `@mantine/core`. Open `docs/agents/mantine.md` only when debugging those wrappers or adding a new one.

**Exception:** `@mantine/modals` (`openModal`, `openContextModal`, `closeModal`, …) is the usual imperative API in features — match nearby callers.

## Composition ladder

1. **Shared wrapper** — `/@/shared/components/...` (design-system primitive).
2. **Feature-shared** — `src/renderer/features/shared/components/` (domain chrome reused across features).
3. **Feature-local** — `src/renderer/features/<feature>/components/` (one feature only).

Layout primitives: `Box`, `Flex`, `Stack`, `Group`, `Grid` from `/@/shared/components/...`. Prefer them over one-off layout divs when composing screens.

Put a new primitive in shared only when it is generic design-system; otherwise keep it feature-local or feature-shared.

## CSS Modules

- Co-locate `component-name.module.css` next to `component-name.tsx` (kebab folder + file names).
- Class names in CSS are kebab-case; Vite maps them to camelCase on `styles` (`.player-button` → `styles.playerButton`).
- Compose classes with `clsx`. On Mantine wrappers, pass module classes through the Styles API (`classNames={{ root: styles.root, ... }}`).
- Colors, spacing, chrome: prefer `var(--theme-…)` / `var(--theme-colors-…)` over raw hex or `--mantine-*` so custom themes keep working.
- PostCSS: `@mixin light-root` / `dark-root` where light/dark splits belong; `lighten` / `darken` / `alpha` are allowed by stylelint.

Global CSS is rare (`src/shared/styles/global.css` and third-party/theme overrides) — don’t add feature styles there.

## Theme tokens

| Layer | Source |
|-------|--------|
| Types / shape | `src/shared/themes/app-theme-types.ts` |
| Built-in themes | `src/shared/themes/*`, registry `app-theme.ts` |
| Runtime CSS variables | injected as `--theme-*` / `--theme-colors-*` (see `use-app-theme`) |
| Mantine theme object | `src/renderer/themes/mantine-theme.tsx` |
| Bridge to Mantine vars | `src/shared/styles/global.css` |

User-loadable desktop theme JSON (`colors` / `app` / `mantineOverride` / `stylesheets`): `docs/CUSTOM_THEMES.md` — only when changing that surface.

## i18n, icons, toasts, modals

- **Copy:** `useTranslation` / `t`; keys in `src/i18n/locales/en.json` (`common.*`, `form.*`, `error.*`, `page.*`, `entity.*`, …). Add keys next to existing namespaces.
- **Icons:** `<Icon icon="…" />` from `/@/shared/components/icon/icon`. New glyphs go on the `AppIcon` map there — don’t import `react-icons` / Lucide directly in features.
- **Toasts:** `toast.success|error|info|warn({ message, title? })` from `/@/shared/components/toast/toast`.
- **Modals:** open/close via `@mantine/modals`; render with shared `Modal` / `ModalButton` / provider patterns already in tree. Controlled visibility often uses `/@/shared/hooks/use-disclosure`.

## State (Zustand)

| Scope | Where |
|-------|--------|
| App-wide | `src/renderer/store/` — auth, settings, player, app chrome, etc. Prefer the barrel `src/renderer/store` when the store is re-exported; otherwise deep-import the `*.store.ts` file (e.g. `custom-themes.store.ts`). |
| Feature | `src/renderer/features/<feature>/store/` (or a hook-colocated store) when state is feature-owned |
| Remote SPA | `src/remote/store` — remote session / WebSocket only; not the library `api.controller` |

Patterns to match nearby stores:

- Prefer `createWithEqualityFn` from `zustand/traditional`.
- Persisted app stores often use `persist` + `immer` (+ `devtools` / `subscribeWithSelector` when siblings do).
- Multi-field reads: `useShallow` or the store’s exported selector hooks; see `src/renderer/lib/zustand.ts` (`createSelectors`) where used.
- Don’t duplicate player/auth/settings into a new global store — extend the existing one or keep a feature-local store.

---

### Agents Issue Tracker (`docs/agents/issue-tracker.md`)

# Issue tracker: Local Markdown

Issues and specs for this repo live as markdown files in `.scratch/`.

## Conventions

- One feature per directory: `.scratch/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` — never a single combined tickets file
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
- Comments and conversation history append to the bottom of the file under a `## Comments` heading

## When a skill says "publish to the issue tracker"

Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).

## When a skill says "fetch the relevant ticket"

Read the file at the referenced path. The user will normally pass the path or the issue number directly.

## Wayfinding operations

Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.

- **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
- **Claim**: set `Status: claimed` and save before any work.
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.

---

### Agents Logging (`docs/agents/logging.md`)

# Logging

Use the process logger for diagnosable failures and meaningful state transitions. Skip noise (render churn, routine success paths, prop dumps).

## Which logger

| Process | Import | API |
|---------|--------|-----|
| Renderer / remote | `import { logger } from '/@/renderer/utils/logger'` | `logger.debug\|info\|warn\|error(message, meta?)` |
| Main | `import log from '/@/main/logger'` | `log.debug\|info\|warn\|error(...)` |

Do not use bare `console.*` for new logging in app code — the loggers format, debounce (renderer), forward to electron-log / files, and respect the configured level.

## When to log

Add a log when the code path is hard to reconstruct from the UI alone:

- Failures and catch blocks that would otherwise vanish (`error` / `warn`)
- Engine / connection / auth / playback transitions that change behavior (`info` or `debug`)
- Fallback paths (e.g. playback engine fell back to web) (`warn` / `info`)

Skip: hot loops, every React render, expected empty states, data the user already sees as a toast unless the log adds process-side detail.

## Levels

Configured threshold is `debug` | `info` (`LogLevel` in `/@/shared/logger/types`). Message severity is `debug` | `info` | `warn` | `error`.

| Severity | Use for |
|----------|---------|
| `error` | Failed operation; include the error in `meta` or args |
| `warn` | Degraded / unexpected but continuing |
| `info` | Notable lifecycle (engine chosen, reconnect, update check) |
| `debug` | Detail useful when digging (payload shapes, step traces) — gated off unless level is `debug` |

## Message shape

- First arg: short human message string.
- Optional second arg (renderer): structured `meta` object — prefer `{ error, … }` over string-concatenating errors.
- No secrets: passwords, tokens, full auth headers, or anything `sanitize-for-diagnostics` would strip from exports. Main diagnostic packaging uses `/@/shared/utils/sanitize-for-diagnostics` — keep ad-hoc logs equally clean.

---

### Agents Mantine (`docs/agents/mantine.md`)

# Mantine

Feishin wraps the Mantine components and hooks it uses under `src/shared/components/` and `src/shared/hooks/`. Feature code imports those wrappers (`/@/shared/...`), not `@mantine/*`.

## Default (most UI work)

1. Import from `/@/shared/components/...` and `/@/shared/hooks/...`.
2. Match callers and props already in use — the wrapper is the API.
3. Do **not** fetch Mantine LLM docs, MCP, or upstream skills.

`@mantine/*` belongs only inside the wrapper modules (and a few shared theme/type utilities).

## When to open upstream docs

Reach for live Mantine docs **only** if you are:

- **Debugging a wrapper** — behavior/bug lives in `src/shared/components/*` or `src/shared/hooks/*` (or adjacent shared Mantine adapters), or
- **Adding a new wrapped** component or hook — introducing a new `/@/shared/...` re-export that did not exist.

Then:

1. Confirm **Mantine v9** in `package.json`.
2. Prefer **Mantine MCP** if configured (`search_docs`, `get_item_doc`, `get_item_props`); else https://mantine.dev/llms.txt → the linked per-page `.md` for that item only.
3. Do not vendor `llms-full.txt`.

## Skill branches (new wrappers only)

Install from [mantinedev/skills](https://github.com/mantinedev/skills) only when scaffolding a matching new wrapper:

| New wrapper kind | Skill |
|------------------|--------|
| `@mantine/form` / validation / form context | `mantine-form` |
| Custom select via `Combobox` | `mantine-combobox` |
| Factory + Styles API component | `mantine-custom-components` |

## Repo theme notes

User-loadable desktop theme JSON and `mantineOverride`: `docs/CUSTOM_THEMES.md` — only when changing that behavior or theme-object shape.

---

### Agents Triage Labels (`docs/agents/triage-labels.md`)

# Triage Labels

The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.

| Label in mattpocock/skills | Label in our tracker | Meaning                                  |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage`             | `needs-triage`       | Maintainer needs to evaluate this issue  |
| `needs-info`               | `needs-info`         | Waiting on reporter for more information |
| `ready-for-agent`          | `ready-for-agent`    | Fully specified, ready for an AFK agent  |
| `ready-for-human`          | `ready-for-human`    | Requires human implementation            |
| `wontfix`                  | `wontfix`            | Will not be actioned                     |

When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.

Edit the right-hand column to match whatever vocabulary you actually use.

---

--- METRICS ---
- Files Extracted: 16
- Estimated Token Budget: ~15346 tokens
- Canonical Reference: https://codewiki.google/github.com/jeffvli/feishin
