# Technical Documentation: go-vikunja/vikunja

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

### Overview (`README.md`)

<img src="https://vikunja.io/images/vikunja-logo.svg" alt="" style="display: block;width: 50%;margin: 0 auto;" width="50%"/>

[](https://github.com/go-vikunja/vikunja/actions/workflows/ci.yml)
[](LICENSE)
[](https://vikunja.io/docs/installing)
[](https://hub.docker.com/r/vikunja/vikunja/)
[](https://try.vikunja.io/api/v2/docs)

# Vikunja

> The task manager you actually own. 

If Vikunja is useful to you, please consider [supporting the project](https://vikunja.io/support/). You can [buy a coffee](https://www.buymeacoffee.com/kolaente), [sponsor on GitHub](https://github.com/sponsors/kolaente) or buy [a sticker pack](https://vikunja.io/stickers).
We're also offering [a hosted version of Vikunja](https://vikunja.cloud/) if you want a hassle-free solution for yourself or your team.
If you or your company needs admin panel, audit logs or time tracking, check out [Vikunja Pro](https://vikunja.io/pro/).

> [!NOTE]
> For the development of Vikunja, we're using LLM-Assisted coding tools in various parts of the codebase.
> Most contributions made @tink-bot are built that way.

## Table of contents

- [Security Reports](#security-reports)
- [Features](#features)
- [Docs](#docs)
	- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [License](#license)
	- [Unsplash Images](#unsplash-images)

## Security Reports

If you find any security-related issues you don't want to disclose publicly, please use [the contact information on our website](https://vikunja.io/contact/#security).

## Features

See [the features page](https://vikunja.io/features/) on our website for a more exhaustive list or 
try it on [try.vikunja.io](https://try.vikunja.io)!

## Docs

* [Installing](https://vikunja.io/docs/installing/)
* [Build from source](https://vikunja.io/docs/build-from-sources/)
* [Development setup](https://vikunja.io/docs/development/)
* [Magefile](https://vikunja.io/docs/magefile/)
* [Testing](https://vikunja.io/docs/testing/)

All docs can be found on [the Vikunja home page](https://vikunja.io/docs/).

### Roadmap

See [the roadmap](https://my.vikunja.cloud/share/QFyzYEmEYfSyQfTOmIRSwLUpkFjboaBqQCnaPmWd/auth) (hosted on Vikunja!) for more!

## Contributing

Please check out the contribution guidelines on [the website](https://vikunja.io/docs/development/).

## License

Most of this repository is licensed under [AGPL‑3.0‑or‑later](LICENSE).
The contents of [`desktop/`](desktop/) are licensed under
[GPL‑3.0‑or‑later](desktop/LICENSE).

### Unsplash Images

Background images from Unsplash are distributed under the [Unsplash License](https://unsplash.com/license). The license requires giving credit to the photographer and Unsplash. See [Unsplash’s terms](https://unsplash.com/terms) for more information.

---

### Overview (`desktop/README.md`)

# Vikunja desktop

[](LICENSE)

The Vikunja frontend all repackaged as an electron app to run as a desktop app!

## Dev

As this package does not contain any code, only a thin wrapper around electron, you will need to do this to get the 
actual frontend bundle and build the app:

First, build the frontend:

```
cd ../frontend
pnpm install
pnpm run build
```

Then, copy the frontend to this directory:

```
cd desktop
cp -r ../frontend/dist frontend/
sed -i 's/\/api\/v1//g' frontend/index.html # Make sure to trigger the "enter the Vikunja url" prompt
```

Then you can run the desktop app like this:

```
pnpm install
pnpm start
```

## Building for release

1. Run the snippet from above, but with a valid frontend version instead of `unstable`
2. Change the version in `package.json` (that's the one that will be used by electron-builder)
3. `pnpm install`
4. `pnpm run dist --linux --windows`

## License

This project is licensed under the GPL-3.0-or-later license. See the [LICENSE](LICENSE) file for details.

---

### Overview (`frontend/README.md`)

# Web frontend for Vikunja

> The todo app to organize your life.

[](LICENSE)
[](https://crowdin.com/project/vikunja)

This is the web frontend for Vikunja, written in Vue.js.

Take a look at [our roadmap](https://my.vikunja.cloud/share/UrdhKPqumxDXUbYpEGJLSIyNTwAnbBzVlwdDpRbv/auth) (hosted on Vikunja!) for a list of things we're currently working on!

For general information about the project, refer to the top-level readme of this repo.

## Project setup

```shell
pnpm install
```

### Development

#### Define backend server

You can develop the web front end against any accessible backend, including the demo at https://try.vikunja.io

In order to do so, you need to set the `DEV_PROXY` env variable. The recommended way to do so is to:

- Copy `.env.local.example` as `.env.local`
- Uncomment the `DEV_PROXY` line
- Set the backend url you want to use

In the end, it should look like `DEV_PROXY=https://try.vikunja.io` if you work against the online demo backend.


#### Start dev server (compiles and hot-reloads)

```shell
pnpm run dev
```

### Compiles and minifies for production

```shell
pnpm run build
```

### Lints and fixes files

```shell
pnpm run lint
```

## License

This project is licensed under the AGPL-3.0-or-later license. See the [LICENSE](LICENSE) file for details.

---

### Overview (`veans/README.md`)

# veans

A beans-shaped CLI for Vikunja. Drop it into a repo, run `veans init`, paste a
hook snippet into your coding agent's settings, and the agent immediately
knows to track its work in Vikunja instead of in `TodoWrite` or `.beans/`.

veans is a thin Go binary that wraps Vikunja's REST API with an opinionated
agent-friendly surface and emits a system prompt teaching agents the workflow
(claim → work → in-review → human closes). The agent prompt is re-emitted on
every `SessionStart` and `PreCompact`, so context never goes stale.

## Quick start

```sh
# 1. Build (or download) the binary
cd veans && mage build && sudo install ./veans /usr/local/bin/

# 2. In a repo with a Vikunja instance reachable
veans init --server https://vikunja.example.com

# 3. Wire it into Claude Code (.claude/settings.json):
{
  "hooks": {
    "SessionStart": [{ "hooks": [{ "type": "command", "command": "veans prime" }] }],
    "PreCompact":   [{ "hooks": [{ "type": "command", "command": "veans prime" }] }]
  }
}

# OpenCode (.opencode/plugin/veans-prime.ts):
export const VeansPrime = {
  event: ["session.start", "compact.before"],
  handler: async ({ exec }) => exec("veans prime"),
}
```

`veans prime` exits silently with status 0 when no `.veans.yml` is reachable
upward from cwd, so the hook is safe to install in a global `~/.claude/`
without breaking sessions in unrelated repos.

## What `veans init` does

1. Authenticates as you. Default is OAuth 2.0 Authorization Code + PKCE
   against Vikunja's built-in authorization server (Vikunja 2.3+ — no
   client registration needed). veans prints an authorize URL; you open
   it in your browser, sign in, and paste the resulting
   `vikunja-veans-cli://callback?code=...` URL back into the CLI. The
   browser will fail to open the custom scheme — that's expected; the
   address bar still has what we need.

   Alternative auth modes:
   - `--token <jwt-or-personal-api-token>` — paste-in, useful for SSO/OIDC
   - `--use-password` — fall back to `POST /login` (local accounts only)
   - `--username` + `--password` (non-interactive; implies `--use-password`)
2. Asks you to pick a project and a Kanban view.
3. Bootstraps the canonical buckets if missing: `Todo`, `In Progress`,
   `In Review`, `Done`, `Scrapped`.
4. Creates a `bot-<repo-name>` user (Vikunja bot user — no password, no
   email, can't log in interactively).
5. Shares the project with the bot at read+write.
6. Mints a long-lived API token for the bot via `PUT /tokens` with
   `owner_id`, scoped to the discovered route groups (tasks, comments,
   labels, relations, assignees, etc.) the server actually exposes.
7. Stores the token in your OS keychain (or
   `~/.config/veans/credentials.yml` if no keychain is available).
8. Writes `.veans.yml` to the repo root.

The token stored is the bot's, not yours. The human's transient session is
discarded as soon as init finishes — rotate or revoke the bot independently
without affecting your own session.

## Commands

```
veans init                     OAuth/login → create bot → mint token → write .veans.yml
veans prime                    emit system prompt for agents (silent if no .veans.yml)
veans list                     filtered list (--ready, --mine, --branch, --filter, --status); emits JSON
veans show <id>                view a task (JSON)
veans create "title"           --description, --label, --status, --priority, --parent, --blocked-by
veans update <id>              --status, --title, --priority, --label-add/remove,
                               --description, --description-replace-old/new, --description-append,
                               --comment, --reason, --if-unchanged-since
veans claim <id>               assign the bot, move to In Progress, tag with current branch label
veans api METHOD PATH          raw REST passthrough — escape hatch for endpoints not wrapped here
veans login                    re-mint the bot's token (rotation)
veans version
```

Task IDs accept `PROJ-NN` (when the project has an identifier), `#NN`
(when it doesn't), or a bare integer.

## `.veans.yml`

Committed to the repo root. The numeric IDs are the source of truth; cached
identifiers and bot username are for human-readable output.

```yaml
server: https://vikunja.example.com
frontend_url: https://vikunja.example.com/  # from /info; backfilled if missing
project_id: 42
project_identifier: PROJ        # may be "" — task IDs render as #NN then
view_id: 7
buckets:
  todo: 11
  in_progress: 12
  in_review: 13
  done: 14
  scrapped: 15
bot:
  username: bot-myrepo
  user_id: 99
```

## Credentials

Resolved in order on every command:

1. **OS keychain** (macOS Keychain, Windows Credential Manager,
   libsecret/gnome-keyring on Linux), via `github.com/zalando/go-keyring`.
2. **`VEANS_TOKEN`** env var (read-only). Intended for CI / containers.
3. **`~/.config/veans/credentials.yml`** (mode 0600) — automatic fallback
   when the keychain is unavailable.

## Trusted servers

`.veans.yml` is checked into the repo, so veans only talks to its `server`
once you trust it. `veans init` trusts the server you typed. Any other
command run in a terminal shows the server's origin and asks once; the
answer is stored in `~/.config/veans/trusted.json`.

Without a terminal (agent hooks, CI) veans never asks: it refuses to send
credentials, and `veans prime` renders without project data. In CI, set
`VEANS_SERVER` to the server URL to declare it trusted.

## Mage targets

```
mage build              # go build -o ./veans ./cmd/veans
mage test               # unit tests across the module
mage test:filter EXPR   # go test -run EXPR ./...
mage test:e2e           # e2e suite (needs VEANS_E2E_API_URL)
mage lint / lint:fix    # golangci-lint
mage fmt                # go fmt ./...
mage clean              # remove built binary
```

## End-to-end tests

The suite in `e2e/` assumes a running Vikunja API. Locally, point it at any
dev instance:

```sh
export VEANS_E2E_API_URL=http://localhost:3456
export VEANS_E2E_ADMIN_USER=user1
export VEANS_E2E_ADMIN_PASS=12345678   # canonical fixture password
mage test:e2e
```

CI spins Vikunja up the same way the frontend Playwright suite does — see
`.github/workflows/veans-e2e.yml`. The workflow builds the parent API
binary, starts it with `VIKUNJA_DATABASE_TYPE=sqlite`,
`VIKUNJA_DATABASE_PATH=memory`, fixtures from `pkg/db/fixtures/`, and runs
`mage test:e2e` from this directory.

E2E tests never touch the developer's keychain — they override `HOME` and
`XDG_CONFIG_HOME` per test, which forces the credential store to fall
through to its file backend.

## Status model

| Status        | Bucket name    | Done flag | Who moves there?                         |
| ------------- | -------------- | --------- | ---------------------------------------- |
| `todo`        | Todo           | false     | created here by default                  |
| `in-progress` | In Progress    | false     | `veans claim` / `update -s in-progress`  |
| `in-review`   | In Review      | false     | the agent, when work is finished         |
| `completed`   | Done           | true      | humans / merge hook only                 |
| `scrapped`    | Scrapped       | true      | the agent, with `--reason`               |

The agent never moves tasks to `completed` itself — it parks them in
`In Review` and a human (or the future merge hook) closes them once the
PR lands.

## Out of scope (for now)

- OAuth 2.0 device flow (RFC 8628) — would let SSH'd / headless setups
  authenticate without a browser-on-the-same-machine; not implemented
  upstream yet.
- Project-scoped API tokens — Vikunja doesn't ship them yet. The
  credential schema's `scope` field is forward-compatible for when it does.
- Auto-installing hook snippets. We print them; you paste them.
- Merge-hook GitHub Action that auto-closes tasks on PR merge — separate
  repo, future work.

---

### Overview (`pkg/web/readme.md`)

# Vikunja Web Handler

[](LICENSE)
[](https://goreportcard.com/report/code.vikunja.io/web)

> When I started Vikunja, I started like everyone else, by writing a bunch of functions to do the logic and then a bunch of
handler functions to parse the request data and call the implemented functions to do the logic and eventually return a dataset.
After I implemented some functions, I've decided to save me a lot of hassle and put most of that "parse the request and call a 
processing function"-logic to a general interface to facilitate development and not having to have a lot of similar code all over the place.

This webhandler was built to be used in a REST-API, it takes and returns JSON, but can also be used in combination with own 
other handler implementations, enabling a lot of flexibility while developing.

## Features

* Easy to use
* Built for REST-APIs
* Beautiful error handling built in
* Manages permissions
* Pluggable authentication mechanisms

## Table of contents

* [Installation](#installation)
* [Todos](#todos)
* [DB Sessions](#db-sessions)
* [CRUDable](#crudable)
* [Permissions](#permissions)
* [Handler Config](#handler-config)
  * [Auth](#auth)
  * [Logging](#logging)
  * [Full Example](#full-example)
* [Preprocessing](#preprocessing)
  * [Pagination](#pagination)
  * [Search](#search)
* [Standard web handler](#defining-routes-using-the-standard-web-handler)
* [Errors](#errors)
* [URL param binder](#how-the-url-param-binder-works)

### TODOs

* [x] Improve docs/Merge with the ones of Vikunja
* [x] Description of web.HTTPError
* [x] Permissions methods should return errors (I know, this will break a lot of existing stuff)
* [ ] optional Before- and after-{load|update|create} methods which do some preprocessing/after processing like making human-readable names from automatically up counting consts
* [ ] "Magic": Check if a passed struct implements Crudable methods and use a general (user defined) function if not

## Installation

Using the web handler in your application is pretty straight forward, simply run `go get -u code.vikunja.io/web` and start using it. 

In order to use the common web handler, the struct must implement the `web.CRUDable` and `web.Permissions` interface.

To learn how to use the handler, take a look at the [handler config](#handler-config) [defining routes](#defining-routes-using-the-standard-web-handler)

## DB Sessions

Each request runs in its own db session.
This ensures each operation is one atomic entity without any side effects for concurrent requests happening at the same time.

The session is started at the beginning of the request, rolled back in case of any errors and committed if no errors occur.
The permissions methods get the same session (for the same request) as the actual crud methods.

See [`SessionFactory`](#sessionfactory) for docs about how to configure it.

## CRUDable

This interface defines methods to Create/Read/ReadAll/Update/Delete something. It is defined as followed:

```go
type CRUDable interface {
	Create(*xorm.Session, Auth) error
	ReadOne(*xorm.Session, Auth) error
	ReadAll(s *xorm.Session, auth Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error)
	Update(*xorm.Session, Auth) error
	Delete(*xorm.Session, Auth) error
}
```

Each of these methods gets called on an instance of a struct like so:

```go
func (l *List) ReadOne() (err error) {
	*l, err = GetListByID(l.ID)
	return
}
```

In that case, it takes the `ID` saved in the struct instance, gets the full list object and fills the original object with it.
(See [parambinder](#how-the-url-param-binder-works) to understand where that `ID` is coming from in that specific case).

All functions should behave like this, if they create or update something, the struct instance they are called on should 
contain the created/updated struct instance. The only exception is `ReadAll()` which returns an interface. 
Usually this method returns a slice of results because you cannot make an array of a set type (If you know a 
way to do this, don't hesitate to [drop me a message](https://vikunja.io/en/contact/)).

## Permissions

This interface defines methods to check for permissions on structs. They accept an `Auth`-element as parameter and return a `bool` and `error`.

The `error` is handled [as usual](#errors).

The interface is defined as followed:

```go
type Permissions interface {
	CanRead(*xorm.Session, Auth) (bool, int, error) // The int is the max permission the user has for this entity.
	CanDelete(*xorm.Session, Auth) (bool, error)
	CanUpdate(*xorm.Session, Auth) (bool, error)
	CanCreate(*xorm.Session, Auth) (bool, error)
}
```

When using the standard web handler, all methods are called before their `CRUD` counterparts.
Use pointers for methods like `CanRead()` to get the base data of the model first, then check the permission and then add additional data.

The `CanRead` method should also return the max permission a user has on this entity.
This number will be returned in the `x-max-permission` header to enable user interfaces to show/hide UI elements based on the permission the user has.

## Handler Config

The handler has some options which you can (and need to) configure.

#### Auth

`Auth` is an interface with some methods to decouple the action of getting the current user from the web handler.
The function defined via `Auths` should return a struct which implements the `Auth` interface.

To define the thing which gets the appropriate auth object, you need to call a middleware like so (After all auth middlewares were called):

#### Logging

You can provide your own instance of `slog.Logger` from Go's standard library to the handler.
It will use this instance to log errors which are not better specified or things like users trying to do something they're
not allowed to do and so on.

#### MaxItemsPerPage

Contains the maximum number of items per page.
If the client requests more items than this, the number of items requested is set to this value.

See [pagination](#pagination) for more.

#### SessionFactory

To create a new session for each request, you need to call the `SetSessionFactory` method before any web request.
It has the following signature:

```go
func SetSessionFactory(sessionFactory func() *xorm.Session)
```

The closure will be called for every request.

#### Full Example

```go
handler.SetAuthProvider(&web.Auths{
    AuthObject: func(echo.Context) (web.Auth, error) {
        return models.GetCurrentUser(c) // Your functions
    },
})
handler.SetLoggingProvider(&log.Log)
handler.SetSessionFactory(x.NewSession)
```

## Preprocessing

### Pagination

The `ReadAll`-method has a number of parameters:

```go
ReadAll(auth Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfItems int64, err error)
```

The third parameter contains the requested page, the fourth parameter contains the number of items per page.
You should calculate the limits accordingly.

If the number of items per page are not set by the client, the web handler will pass the maximum number of items per page instead.
This makes items per page optional for clients.
Take a look at [the config section](#handler-config) for information on how to set that value.

You need to return a number of things:

* The result itself, usually a slice
* The number of items you return in `result`. Most of the time, this is just `len(result)`. You need to return this value to make the clients aware if they requested a number of items > max items per page.
* The total number of items available. We use the total number of items here and not the number of pages so implementations don't have to calculate the page count. The total number is returned to the client; it can be used to build client-side pagination or similar.
* An error.

The number of items and the total number of pages available will be returned in the `x-pagination-total-pages` and `x-pagination-result-count` response headers.
_You should put this in your api documentation._

### Search

When using the `ReadAll`-method, the first parameter is a search term which should be used to search items of your struct. 
You define the criteria inside of that function.

Users can then pass the `?s=something` parameter to the url to search, _thats something you should put in your api documentation_.

As the logic for "give me everything" and "give me everything where the name contains 'something'" is mostly the same, we made 
the decision to design the function like this, in order to keep the places with mostly the same logic as few as possible. 
Also just adding `?s=query` to the url one already knows and uses is a lot more convenient.

## Defining routes using the standard web handler

You can define routes for the standard web handler like so:

`models.List` needs to implement `web.CRUDable` and `web.Permissions`.

```go
listHandler := &crud.WebHandler{
    EmptyStruct: func() crud.CObject {
        return &models.List{}
    },
}
a.GET("/lists", listHandler.ReadAllWeb)
a.GET("/lists/:list", listHandler.ReadOneWeb)
a.POST("/lists/:list", listHandler.UpdateWeb)
a.DELETE("/lists/:list", listHandler.DeleteWeb)
a.PUT("/namespaces/:namespace/lists", listHandler.CreateWeb)
```

The handler will take care of everything like parsing the request, checking permissions, pretty-print errors and return appropriate responses.

## Errors

Error types with their messages and http-codes should be implemented by you somewhere in your application and then returned by 
the appropriate function when an error occurs. If the error type implements `HTTPError`, the server returns a user-friendly
error message when this error occurs. This means it returns a good HTTP status code, a message, and an error code. The error
code should be unique across all error codes and can be used on the client to show a localized error message or do other stuff 
based on the exact error the server returns. That way the client won't have to "guess" that the error message remains the same 
over multiple versions of your application.

An `HTTPError` is defined as follows:

```go
type HTTPError struct {
    HTTPCode int    `json:"-"` // Can be any valid HTTP status code, I'd recommend to use the constants of the http package.
    Code     int    `json:"code"` // Must be a unique int identifier for this specific error. I'd recommend defining a constant for this.
	Message  string `json:"message"` // A user-readable message what went wrong.
}
```

You can learn more about how exactly custom error types are created in the [vikunja docs](https://vikunja.io/docs/custom-errors/).

## How the url param binder works

The binder binds all values inside the url to their respective fields in a struct. Those fields need to have a tag
`param` with the name of the url placeholder which must be the same as in routes.

Whenever one of the standard CRUD methods is invoked, this binder is called, which enables one handler method
to handle all kinds of different urls with different parameters.

---

### Overview (`frontend/src/styles/README.md`)

# Frontend Styles

This directory contains all global styling for the Vikunja web client. Component-scoped
styles live next to their `.vue` file; what lives here is the material every component
relies on: the Bulma base, the design-token system (CSS custom properties), typography,
and a handful of cross-cutting theme overrides.

If you are wondering **"where do I add X?"** — jump to [Where do I add …?](#where-do-i-add-) at
the bottom.

## Directory map

```
/* Detailed source-code truncated for AI context efficiency. */
```

Files marked with a `FIXME:` comment are styles that *should* live in a component's
`<style scoped>` block but haven't been moved yet. Prefer fixing the component rather
than extending these files.

## The `common-imports.scss` contract

`common-imports.scss` is prepended to **every** SCSS stylesheet in the project — every
`*.scss` file and every `<style lang="scss">` block in a `.vue` component. This is wired up
in `vite.config.ts` via `css.preprocessorOptions.scss.additionalData`:

```ts
// vite.config.ts
const PREFIXED_SCSS_STYLES = `@use "sass:math";
@import "${pathSrc}/styles/common-imports.scss";`

css: {
  preprocessorOptions: {
    scss: { additionalData: PREFIXED_SCSS_STYLES, … },
  },
}
```

**Because of that, `common-imports.scss` must produce zero CSS output.** It only defines
SCSS variables (`$vikunja-font`, `$navbar-height`, `$transition`, …) and imports Bulma's
utilities partial — pure functions, mixins, and variables with no selectors. If you
accidentally add a selector here, its rules will be duplicated into every compiled
stylesheet. The file has a prominent header comment calling this out; keep it that way.

Add a variable to `common-imports.scss` when you need it available in multiple components'
`<style lang="scss">` blocks. For a one-off, keep it local to the component.

## Bulma: which variant and why

Vikunja uses [`bulma-css-variables`](https://www.npmjs.com/package/bulma-css-variables), a
fork of Bulma that emits CSS custom properties (`--primary`, `--text`, `--scheme-main`, …)
instead of inlining SCSS variables. This is what makes runtime theming (including dark
mode) possible without recompiling SCSS.

`global.scss` imports Bulma partials one-by-one rather than pulling in the whole
`bulma.sass`. Each intentionally-excluded partial has an explanatory comment. The current
exclusions fall into three buckets:

1. **Moved to Vue components** — e.g. `elements/button` lives in `Button.vue`,
   `components/dropdown` lives in the dropdown component.
2. **Not used** — `breadcrumb`, `level`, `panel`, `tabs`, `notification`, `progress`,
   `message`, `hero`, `section`, `footer`, `tiles`.
3. **Replaced by a custom implementation in `theme/`** — most notably
   `helpers/spacing` is replaced by `theme/logical-spacing.scss` so that spacing
   utilities use logical properties (margin-inline/block) for RTL support.

If you need a Bulma feature that is currently excluded, re-enable the import in
`global.scss` rather than duplicating the rules.

## CSS custom-property token system

All design tokens are defined as CSS custom properties on `:root` in
`custom-properties/colors.scss` and `custom-properties/shadows.scss`.

### The HSL-with-alpha pattern

Most colors are declared as HSL *components* — separate `-h`, `-s`, `-l`, `-a`
properties — plus a composed `hsla()` value:

```scss
--primary-h: 217deg;
--primary-s: 98%;
--primary-l: 53%;
--primary-a: 1;
--primary-hsl: var(--primary-h), var(--primary-s), var(--primary-l);
--primary: hsla(var(--primary-h), var(--primary-s), var(--primary-l), var(--primary-a));
```

This lets consumers change **one dimension** at a time. A hover state can soften the
same color without redefining it:

```scss
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
```

Dark mode then tweaks only `--primary-l` (to `58%`) to keep sufficient contrast — the
composed `--primary` re-derives automatically.

### Grey scale

`--grey-50` … `--grey-900` form a Tailwind-style neutral ramp. The `-hsl` companions
(`--grey-100-hsl`, `--grey-500-hsl`, `--grey-900-hsl`, …) expose the raw HSL tuple for
the alpha-composition trick above. Dark mode reverses the scale: `--grey-900` becomes the
light-mode `--grey-50` value, and so on down the ladder.

### Bulma `--scheme-*` tokens

The `--scheme-main`, `--scheme-main-bis`, `--scheme-invert`, … block at the top of
`colors.scss` mirrors Bulma's own defaults and exists as a workaround for a
`bulma-css-variables` scoping bug (see [vikunja/frontend#1064][1]). Don't touch those
lines except to update Bulma. The Vikunja-specific tokens and overrides start below the
`// Vikunja specific variables` comment.

[1]: https://kolaente.dev/vikunja/frontend/issues/1064

### Adding a new token

1. Decide whether it's a color, shadow, or something else — add it to the matching file.
2. If it's a color that should shift in dark mode, declare HSL components (or reference an
   existing `--grey-*` token) so the dark-mode override only has to change one dimension.
3. Add the dark-mode override inside the `&.dark { @media screen { … } }` block.
4. Consume it with `var(--token-name)` — never re-declare the token in a component.

## Tailwind's limited role

Tailwind v4 is loaded via `styles/tailwind.css` and imported exactly once in `App.vue`.
Every utility is **prefixed with `tw`** (e.g. `class="tw-flex tw-gap-2"`) to avoid
collisions with the Bulma class names used throughout the app:

```css
@import "tailwindcss/theme.css" layer(theme) prefix(tw);
@import "tailwindcss/utilities.css" layer(utilities) prefix(tw);
```

Tailwind is meant for quick layout fixes inside `.vue` templates. For anything reusable —
especially anything that needs dark mode — prefer a CSS custom property or a scoped
`<style>` block.

## Theming and dark mode

There is no SCSS-level light/dark split. Instead:

1. `custom-properties/colors.scss` defines the light-mode values on `:root`.
2. The same file defines overrides inside `:root.dark { @media screen { … } }`.
3. A `dark` class is toggled on `<html>` at runtime (see the color scheme composable).
4. All components reference the tokens via `var(--…)`, so switching the class is enough
   to retheme the entire UI without touching the stylesheet.

The `@media screen` wrapper exists so the dark-mode overrides don't apply to
`@media print`, where we always want a light background.

## Where do I add …?

| I want to …                                                   | Put it in                                          |
| ------------------------------------------------------------- | -------------------------------------------------- |
| A new color / shadow token                                     | `custom-properties/colors.scss` or `shadows.scss` |
| A tweak that only affects one component                        | That component's `<style scoped>` block            |
| A SCSS variable reused by multiple components                  | `common-imports.scss` (no selectors!)              |
| A new `@font-face`                                             | `fonts.scss`                                       |
| A new Vue `<Transition>` class pair                            | `transitions.scss`                                 |
| A global rule targeting a Bulma class we can't scope yet       | The matching file in `theme/`                      |
| Re-enabling a Bulma partial                                    | Uncomment the `@import` in `global.scss`           |
| A Tailwind utility                                             | Use it inline with the `tw-` prefix                |

---

### Overview (`frontend/tests/e2e/README.md`)

# E2E testing

## Email confirmation

Like the Dex login test, email confirmation needs external services. CI starts
Mailpit and a separate mail-enabled API before Playwright. Tests connect through
`MAILER_API_URL` (default `http://127.0.0.1:3457/api/v1`) and `MAILPIT_URL`
(default `http://127.0.0.1:8025`). The main test API keeps mail disabled.

For local runs, start Mailpit in one terminal:

```shell
docker run --rm --name vikunja-e2e-mailpit \
  -p 127.0.0.1:1025:1025 -p 127.0.0.1:8025:8025 axllent/mailpit:v1.31.1
```

From the repository root, start the mail-enabled API in another terminal:

```shell
mage build
mail_test_root=$(mktemp -d)
mkdir -p "$mail_test_root/files"
VIKUNJA_SERVICE_INTERFACE=127.0.0.1:3457 \
VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:4173/ \
VIKUNJA_SERVICE_ROOTPATH="$mail_test_root" \
VIKUNJA_FILES_BASEPATH="$mail_test_root/files" \
VIKUNJA_DATABASE_TYPE=sqlite VIKUNJA_DATABASE_PATH=memory \
VIKUNJA_AUTH_OPENID_ENABLED=0 VIKUNJA_REDIS_ENABLED=0 \
VIKUNJA_MAILER_ENABLED=1 VIKUNJA_MAILER_HOST=127.0.0.1 \
VIKUNJA_MAILER_PORT=1025 VIKUNJA_MAILER_FORCESSL=0 \
./vikunja web
rm -rf "$mail_test_root"
```

Then run the tests from the repository root, fixing the frontend port so the
confirmation links point to the test frontend:

```shell
VIKUNJA_E2E_FRONTEND_PORT=4173 mage test:e2e 'tests/e2e/user/registration.spec.ts'
```

Stop Mailpit and the mail-enabled API when finished.

---

### Overview (`frontend/originalMedia/images/migration/README.md`)

# Migration Logos

The images in this directory are used by Vikunja's import feature to represent third-party services. Each logo is owned by its respective trademark holder:

* **microsoft-todo.svg** – Microsoft To Do logo. See Microsoft's [Trademark and Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks) for permitted uses.
* **todoist.svg** – Todoist logo. Trademark of Doist Ltd. Use must follow Doist's [brand guidelines](https://todoist.com/brand) or press kit.
* **trello.svg** – Trello logo. Trademark of Atlassian Pty Ltd. Use must comply with Atlassian's [Trademark Guidelines](https://www.atlassian.com/legal/trademark).
* **wunderlist.png** – Wunderlist logo. Trademark of Microsoft Corporation. Covered by Microsoft's [Trademark and Brand Guidelines](https://www.microsoft.com/legal/intellectualproperty/trademarks).

These logos are provided only to show that Vikunja can import tasks from these services. Their inclusion does not imply endorsement by the trademark owners. Do not use the logos except as allowed by the respective brand policies.

---

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

# API design

## Version policy

- `/api/v1` (Echo, `pkg/routes/api/v1/`) is frozen. It keeps running and is supported, but does not grow. Touch it only to fix a bug or to port a resource to v2.
- `/api/v2` (Huma, `pkg/routes/api/v2/`) gets every new route: new entities, non-CRUD actions, new actions on existing resources. "Add an endpoint for X" without a version means v2.
- Before adding a v2 route, invoke the `api-v2-routes` skill.
- New v2 operations are not exposed over MCP unless you add their operation ID to the allow-list in `pkg/modules/mcp/exposure.go`, as either a typed tool or a catalog action (`find_action`/`do_action`).
- Models in `pkg/models/` are shared by both APIs. A new entity still gets a model with `Can*` methods (`crudable` skill); only the HTTP surface differs.

## Conventions

- v2 verbs: POST creates, PUT/PATCH update. (v1: PUT creates, POST updates.)
- kebab-case URLs, snake_case JSON.
- Standard CRUD in both versions goes through the `pkg/web/handler/` `Do*` functions, which call the model's `Can*` methods.
- Permissions live in the model (`CanRead`/`CanWrite`/`CanCreate`/`CanDelete`), never in CRUD handlers. One exception: non-CRUD v2 actions have no `Do*` wrapper, so the handler must load the entity and call `Can*` itself. The `api-v2-routes` skill shows the shape.

## Frontend clients

- Resource data goes through the generated SDK and types in `frontend/src/client/generated`, wrapped by the query modules in `frontend/src/client/queries/`. Bootstrap and session calls are the exception and call the SDK directly: `/info` in `stores/config.ts`, token refresh in `helpers/auth.ts`, login, register and logout in `stores/auth.ts`. Use the snake_case fields directly. Importing `axios` or any `models`, `modelTypes` or `services` path is a lint error (`no-restricted-imports` in `frontend/eslint.config.js`).
- After adding or changing a v2 route or schema, run `mage generate:frontend-client` and commit the generated output. Never hand-edit it. `mage check:frontend-client` verifies it is current and generation is repeatable.
- `client/http.ts` configures the shared client: base URL, bearer token, request-context fence, and a single refresh-and-retry on 401 code 11. Don't wrap the transport.
- The only other client is `client/publicClient.ts`, for calls that must skip the token and the 401 retry (`/info`, token refresh). It has no base URL; pass `baseUrl: canonicalApiBaseUrl(getApiBaseUrl())` per call. `throwOnError: false` has two callers. The settings PATCH in `client/queries/account.ts` uses it to accept the empty 304 AutoPatch sends for a patch that changes nothing. Token refresh throws a `RefreshTokenError` whose `failure.kind` (`network`, `server`, `rate-limited` with `retryAt` from `Retry-After`, `rejected`) tells an outage or rate limit from a rejection, and `refreshToken()` holds further refreshes until `retryAt`.
- Failed responses throw an `ApiError` (`client/problem.ts`): an `Error` carrying the problem body's fields (`VikunjaErrorModel`) with top-level `status` and `detail`, plus `code` when the server sets one. It also covers Echo-level bodies (rate limit, invalid JWT) that never reached Huma and non-JSON bodies (a proxy's "Bad Gateway", Go's "404 page not found"). Transport failures throw other `Error`s, so tell them apart with `instanceof ApiError`. Read `status` and `code` directly off the caught value.
- A component that catches a request error returns early on `isRequestContextAbort(e)` (`client/requestContext.ts`): the identity, session or API base changed mid-request, so there is nothing to show.
- Blob downloads pass `parseAs: 'blob'` and narrow with `expectBlob(data, label)` (`client/queries/blobResponse.ts`). The generated types claim the JSON schema, so an unchecked cast hides a JSON body.
- Display lists paginate: key by page and omit `per_page` so the server default applies; send a fixed size only when fewer are needed (`PICKER_PAGE_SIZE`). Sweep every page with `fetchAllPages()` only when the client needs the whole set (labels, gantt tasks, shares), and omit `per_page` so the server uses its `max_items_per_page` (reported by `/info`). The server silently caps any larger `per_page` at that value, so drive paging from the response's `total_pages`/`per_page`, never from the size you asked for.
- Every resource gets one `XResponse` type plus `normalizeX()` applied in each `queryFn` (`ProjectResponse`, `TaskResponse`). Guaranteed fields are exactly the backend fields without `omitempty`; date strings, nullable pointers and expand-only fields stay optional. Merge write responses raw and normalize the result; normalizing the response first replaces cached expansions with `[]`.

### Query cache (TanStack Query)

`frontend/src/client/queries/projects.ts` plus `frontend/src/composables/useProjects.ts` is the reference implementation. Pattern follows the TanStack docs and TkDodo's "Effective React Query Keys" / "Mastering Mutations": one key factory, option factories, one thin hook per operation.

Layering, per feature `foo`:

- `client/queries/foo.ts`: `fooKeys` factory, `foosQuery()` via `queryOptions()`, `createFooMutationOptions()` etc. via `mutationOptions()`, thin hooks `useCreateFooMutation()` = `useMutation(createFooMutationOptions())`, imperative readers `ensureFoos()` / `refreshFoos()`, and pure lookup helpers over `Foo[]`.
- `composables/useFoos.ts`: read side only. `useQuery(foosQuery())`, `data ?? []`, `isPending`, lookup helpers bound to the reactive list. Do not put mutations in here; a create-only view must not subscribe to the list.
- Components read through `useFoos()` and write through `useCreateFooMutation()` etc. They never touch `queryClient`.
- `*MutationOptions()` are exported for tests and for the outside-component case below. Components and composables must not pass them to `useMutation` themselves; use the matching `use*Mutation()` hook.
- Outside components (Pinia setup stores, router, plain modules) `use*` hooks have no inject context. Read via `ensureFoos()` / `refreshFoos()`; write via `useMutation(createFooMutationOptions(), queryClient)` in the store setup.

Rules:

- Mutations use `contextMutationOptions` (`client/queries/contextMutation.ts`). It drops `onSuccess`/`onError`/ `onSettled` once the request context changed, toasts failures unless `toastError(input)` returns false, and its `optimistic` option does the cancel, snapshot and rollback. `toastError` sees the input, not the error, so it cannot suppress a single status.
- Failed queries toast through `QueryCache.onError` in `client/queryClient.ts`; cancellations and request-context aborts are skipped. A query whose consumer renders its own error state sets `meta: {handlesError: true}`, or the user gets both. Mutations are not covered there.
- `mutationFn` shapes input, calls the generated client, returns the narrowed entity. No separate `createFoo` wrapper unless something else calls it. The client is configured with `throwOnError: true`, so failures throw; don't add `error` checks.
- All cache writes (`setQueryData`, `invalidateQueries`, `cancelQueries`) live in mutation option callbacks. Use the `client` passed in the callback context, not the `queryClient` singleton. Never write to the cache from a plain exported function, a store action, or a socket handler.
- Updaters must bail when the cached value is `undefined` (`current ? ... : current`, or `current?.map(...)`), so a query nobody mounted is never materialized.
- Every mutation invalidates the affected list key in `onSettled`. A targeted `setQueryData` in `onSuccess` alone is not enough: a refetch started during the request would overwrite it. For expensive lists that `onSuccess` already patched (the paginated project list, task lists, boards), pass `refetchType: 'none'` so the list is only marked stale instead of reloading every page.
- Also invalidate caches of other resources that derive from the changed relation, e.g. user search after a team membership or project share change (with `refetchType: 'none'`). Don't invalidate a parent detail that doesn't depend on the change.
- `cancelQueries` only as part of a full optimistic flow: `onMutate` cancels, snapshots and writes; `onError` restores the snapshot (only if one existed); `onSettled` invalidates. Never as a standalone guard around a request.
- `setQueryData` matches keys exactly; `invalidateQueries` matches by prefix. Pass the exact key of a live query to `setQueryData`, and add `exact: true` to `invalidateQueries` when a prefix would also hit detail keys.
- Only add keys the app has queries for. A `detail(id)` key without a detail query just creates orphan cache entries; list writes go to the list key with the id in the updater, not in the key.
- Key a query only by arguments a consumer actually passes. Add a key dimension when the consumer that needs it lands. The web frontend only sends HTML, so rich-text format is never a key dimension or request option.
- Use `placeholderData: keepPreviousData` only when the previous query shares the same parent key (e.g. the same project); otherwise the previous project's results show while the new one loads.
- Data embedded in a parent response (views in projects; labels, assignees and buckets in tasks) has no query of its own. Read it from the parent query and write it into the parent's list and detail caches; its query module holds mutations only.
- Id lookups must handle pseudo projects: `-1` is Favorites and other negative ids are saved filters. They only exist in the project list and have no detail endpoint. A project move leaves their lists and boards alone (membership is server-decided); the drag source removes its own card explicitly (`removeTaskFromBoard`).
- Kanban boards are patched, never refetched by task mutations: a refetch returns the first page per bucket and collapses scrolled buckets. `invalidateTaskMembership` forces `refetchType: 'none'` on board keys whatever the caller passes. The only board refetch is bucket delete, which also refetches the project because the server clears `default_bucket_id`/`done_bucket_id`.
- Paged lists are keyed by page number, not infinite queries. Removing an item rewrites `total` and `total_pages` on every cached page of that scope (key minus page segment, group by `hashKey`), not only the page holding it.
- A composable that snapshots data on first load (so an open edit form survives a background refetch) is a draft, not a read. Name it as one, e.g. `useProjectDraft`, and never use it for tables or lists that mutations must update; those read through a live `useQuery` composable. An edit form that needs a draft is a child component mounted once the data has loaded, keyed on the entity id, that seeds the draft once in setup; not watchers plus a stored draft id.
- Toasts for the mutation outcome go in the option callbacks; UI actions like redirects stay in the component. Call `mutate(x)` when nothing follows, `mutate(x, {onSettled})` for cleanup only, and `try { await mutateAsync(x) } catch { return }` with success-only code after the block. A `mutateAsync` rejection that escapes reaches the global error handler and toasts a second time.
- `useAuthStore().info` is the `GET /user` query (`currentUserQuery`, key `accountKeys.user(id, type)`), falling back to the JWT's id and username until it loads. `is_admin` and settings come only from the query. Write the account through the mutations in `client/queries/account.ts`, not the store. `setSession()` clears the whole cache when the identity changes.
- A mutation whose input or result holds a secret (password, token) is mounted with `useSecretMutation` instead of `useMutation`. It sets `gcTime: 0` and resets the mutation once it settles, so the component needs no `reset()`; `gcTime` alone does nothing while a mounted observer still holds the mutation. Read a returned secret from `mutateAsync`, never from `data`.

## OpenAPI

- v2 generates its spec from Go types. No annotations.
- v1 uses swaggo annotations. When you touch a v1 handler, keep its annotations accurate and add them if missing.
- `pkg/swagger/` is generated by CI after commit. Never edit it, and don't run `mage generate:swagger-docs` unless asked.

---

### .Agents Docs Code Style (`.agents/docs/code-style.md`)

# Code style

## General

- Before adding a file, function, or helper, `rg` for existing code that does the same thing, in Go and in `frontend/src/`. Extend it rather than duplicate.

## Go

- Write struct literals with multiple fields and slice/map literals with multiple entries across multiple lines, one field or entry per line, with trailing commas. Apply this to nested literals and tests too; do not pack fields or entries together just because `gofmt` permits it.
- Wrap errors: `fmt.Errorf("...: %w", err)`.
- No raw SQL anywhere, migrations and tests included. Use XORM's builder (`s.Where(...)`, `builder.In`, `.Cols().Update()`), never hand-built strings via `s.Exec`, `s.Query`, or `builder.Expr`.
  Gotcha: an argument-less `builder.In("col")` is silently dropped by `Where` and matches every row. Pass an empty typed slice (`[]int64{}`) to get `0=1`.
- High-entropy random tokens are stored as plain SHA-256 (`utils.Sha256Hex`), never a slow KDF — see `sessions.go`. User passwords stay bcrypt.
- Never log secrets.

## Frontend

- Same literal rule as Go: object and type literals with more than one property, long import lists and long argument lists go one entry per line with trailing commas, in tests too. Nothing over ~120 columns; split long ternaries and chains at the operator. `frontend/src/client/queries/projects.ts` is the reference.
- One ref per purpose; a delete target must not share the search selection's ref. After an awaited mutation, clear or close UI state only if it still equals the value captured when the action started, because users keep interacting while requests are pending.
- Never mutate a parent's `v-model` array or object in place; emit the new value. Editing a copied prop broke assignee removal: the removed user came back on the next save.
- Don't bind native `disabled` to a control that can hold focus during or after a mutation, including an empty state reached after success; focus drops to `<body>`. Use `aria-disabled` and guard the handler.

## Comments

Document the *why*, not the *what*. Default to no comment. Only a non-obvious gotcha, invariant, rejected alternative, or cross-file constraint earns one tight line. Cut comments that restate the code, a name, or a signature.

---

### .Agents Docs Databases (`.agents/docs/databases.md`)

# Supported databases

Nothing in the code checks the server version (`vikunja doctor` only prints it). The real minimums come from the hand-rolled SQL, not from xorm.

| DB | Minimum | Why |
|---|---|---|
| MySQL | 8.0 | `WITH RECURSIVE` + `ROW_NUMBER() OVER` in `pkg/models/project_access.go` and `pkg/models/subscription.go`, with no fallback. 5.7 breaks permission checks. |
| MariaDB | 10.2 | Same CTE and window functions. |
| PostgreSQL | 9.5 | `ON CONFLICT` upserts (`kanban_task_bucket.go`, `task_position.go`, `project_view.go`). `jsonb ?|` in `saved_filters.go` needs 9.4. |
| SQLite | 3.25 | Window functions, upsert with `excluded.` (3.24), `RENAME COLUMN` in `renameColumn`. The bundled go-sqlite3 ships a newer SQLite; only matters with the `libsqlite3` build tag. |

CI tests only the newest versions: MySQL 8, MariaDB 12, Postgres 18, bundled SQLite (`.github/workflows/test.yml`). The minimums above are not tested.

## Writing SQL

- Before using a SQL feature in raw SQL or a migration, check it exists on every minimum in the table. If not, branch on `db.Type()`.
- If raising a minimum is unavoidable: update the table here, call it out as a breaking change in the PR description, and flag that the website docs (supported databases section) need the same change.
- MySQL has no partial indexes and no `CREATE INDEX IF NOT EXISTS`. Skip it there (see `pkg/migration/20260906010000.go`).
- `ON DUPLICATE KEY UPDATE x = VALUES(x)` is deprecated since MySQL 8.0.20. Keep it anyway: the replacement row alias syntax (`AS new ... new.x`) doesn't exist on MariaDB, and both share the `mysql` dialect.
- Case-insensitive search goes through `db.ILIKE` (ILIKE on Postgres, LIKE elsewhere).
- ParadeDB `bm25` indexes (`pkg/db/db.go`) are optional and only created when the extension is present.

---

### .Agents Docs Dev Commands (`.agents/docs/dev-commands.md`)

# Dev commands and configuration

- `mage -l` lists targets; `pnpm run` in `frontend/` lists scripts.
- `pnpm dev` serves the frontend on port 4173 (override with `VIKUNJA_FRONTEND_PORT` or `--port`).
- Scaffolding: `mage dev:make-migration <StructName>` (prompts if omitted), `dev:make-event`, `dev:make-listener`, `dev:make-notification`. `make-listener` also adds the `events.RegisterListener(...)` call; a listener only fires if registered there.
- Invoke the `migration` skill before touching `pkg/migration/`.

## Configuration

`config.yml.sample` is generated from `config-raw.json` via `mage generate:config-yaml`. Edit the JSON, then regenerate. Environment variables override config file settings.

## Frontend API URL

- `window.API_URL` and `localStorage.API_URL` hold the server root without `/api/vN`; `''` means the frontend's own origin. `frontend/index.html` ships `''`; `pkg/routes/static.go` replaces it with `service.publicurl`.
- Build request URLs with `getApiBaseUrl()` (appends `/api/v2`) or `getApiRootUrl()` from `frontend/src/helpers/apiUrl.ts`, never from `window.API_URL`. `normalizeApiUrl()` strips a trailing `/api/vN` from any input.
- Change it only through `checkAndSetApiUrl()`: it probes `/info`, then reconfigures the client and clears the query cache.

---

### .Agents Docs Git Workflow (`.agents/docs/git-workflow.md`)

# Git, plans, worktrees

## Commits

- Conventional Commits (`feat: add foo`, `fix: correct bar`). Changelogs are generated from them.
- Lint before committing and fix what it reports:
  - backend changes: `mage lint:fix`
  - frontend changes: `cd frontend && pnpm lint:fix`
  - CSS/SCSS or Vue `<style>` changes: also `pnpm lint:styles:fix`
- Never commit edits to `pkg/swagger/`; CI regenerates it after commit.

## Plans

When asked to create a plan, write it to `plans/<kebab-case-name>.md` (e.g. `fix-position-healing.md`). `plans/` is gitignored; never force-add it.

## Worktrees

To implement a plan in isolation, invoke the `prepare-worktree` skill.

## Stacked PRs

- One session owns a stack and does every rebase from its own worktree. Don't check out a stack branch in another worktree: git refuses to rebase a branch checked out elsewhere, and `gh stack rebase` aborts.
- Cascade with recorded tips: note each layer's tip before a fix round, then `git rebase --onto <new lower tip> <recorded old lower tip>` per layer, and push with `--force-with-lease=<branch>:<old tip>`. Never derive the old base from `merge-base` once a lower layer was rebased; it replays the lower layer's commits into the upper one.
- After rebasing a stack onto `main`, search for imports of modules the stack deletes. New files on `main` can reference them without causing a conflict.
- When a stack merges as a whole, intermediate PRs may depend on later ones. Say so in each PR body so reviewers don't report those states as bugs.
- Add a layer with `gh stack add` or `gh stack link`, never plain `gh pr create`; that PR doesn't join the stack.
- A new helper shared across resources gets its own PR at the bottom of the stack, and that PR moves every existing copy onto it. Never add it inside a resource PR or leave the old and new pattern side by side.

## Reviewable PRs

- Keep mechanical changes and behaviour changes in separate commits. A mechanical commit (renames, `projectId` to `project_id`, swapping one type for its replacement) contains no logic change; a line that needs different behaviour waits for the next commit, even if that leaves a type error in between. Mark the subject: `refactor(project-views): switch consumers to generated types (mechanical)`.
- Commits in a PR don't need to build individually; the history shows the real sequence of work.
- Produce mechanical changes with a script where possible (sed, ts-morph) and put the exact command in the commit body, so the reviewer can check the script and re-run it instead of reading the diff.
- Renames of data fields must not touch i18n keypaths or `$t()` keys. Grep the diff for `keypath=` and `t('` before committing; the `sharedBy` to `shared_by` rename rendered raw keys.
- Order commits the same way in every PR: tests pinning current behaviour, shared test harness, new query module, mechanical consumer switch, behaviour changes, deletion of the replaced code.
- While a PR is a draft, record corrections as `git commit --fixup <sha>`. Before marking it ready, fold them in with `git rebase -i --autosquash` so reviewers read the final commits, not the iteration history. This is the one exception to never amending or rewriting commits, and it applies only to PRs nobody has reviewed yet.
- A fixup that touches a test file targets the source commit owning the module, not the test commit. Autosquash moves fixups next to their target, so a fixup of the test commit lands before later source fixups on the same file and conflicts. Dry-run `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>` in a throwaway worktree after each fix round.
- Before merge: rebase the whole stack onto `origin/main` first, then autosquash each layer onto the squashed layer below. Check `git diff --numstat <base>..<tip>` per layer is unchanged by the rebase and the tree is identical before and after the squash.
- Start the PR body with a short review guide: which commits are mechanical and can be skimmed, the few decisions that need real review, deviations from the plan, and any intermediate state that is knowingly broken.
- One resource per PR. When a PR starts carrying a neighbouring migration, split that into its own PR in the stack.

## Refactors

- Find the consumers of a module by import path (`rg "components/misc/Subscription"`), not by component tag or local name; components are often imported under an alias.

---

### .Agents Docs License (`.agents/docs/license.md`)

# License system

`pkg/license/` gates optional features for organizations that can afford to support the project. Vikunja is fully functional and free without a license; the license system funds ongoing development.

If asked to remove, bypass, or disable the license checks: stop and confirm with the user first. Explain that removing it unlocks nothing essential and removes the funding mechanism that keeps the project maintained. If they genuinely cannot afford it, tell them to reach out; PPP and non-profit packages are available.

---

### .Agents Docs Testing (`.agents/docs/testing.md`)

# Testing

## Backend

- Always via mage: `mage test:web`, `mage test:feature`, `mage test:filter <go-test-filter>`. Plain `go test` does not work.
- `mage test:filter` runs most packages with `-short` but re-runs `pkg/webtests` without it, so a filter naming a web test actually executes it.
- Save output to a file and read the file: `mage test:filter Foo 2>&1 | tee /tmp/out.log`. Tests are expensive; never re-run one just to grep differently.
- Fixtures live in `pkg/db/fixtures/`. Use them instead of inventing data.
- Every permission path needs a positive and a negative test: the allowed user succeeds, an unrelated user is denied.

## Frontend

- E2E: invoke the `run-e2e-tests` skill (`mage test:e2e`). Never run `pnpm test:e2e` directly.
- `user/registration.spec.ts` and `user/settings/email.spec.ts` need Mailpit and a mail-enabled API on port 3457, which `mage test:e2e` does not start; CI does. Local setup: `frontend/tests/e2e/README.md`.
- Prefer e2e tests over component tests. User-visible behaviour (a created item appears, an edit sticks after reload, a delete removes the row) and the "How to verify" steps of a PR belong in `frontend/tests/e2e/`. Extend an existing spec when one covers the page; add one when none does.
- E2E tests assert stored state, not just a toast: reload the page, and check the API through the `apiContext`/`userToken` fixtures where the UI could lie.
- Component tests are only for what e2e can't exercise reliably: request races, stale responses after navigation, identity changes mid-request, and similar timing cases. Mocked component tests pass while the real page is broken, so they don't replace an e2e test for the same behaviour.
- Same for cache modules: a unit test covers what e2e can't see (`dataUpdateCount` untouched, no request made, `total`/`count` bookkeeping). "Edit in the detail shows on the list and the board" is an e2e test, not a `QueryClient` test.
- E2E tests that assert requests after navigating away and back must `page.reload()` first: the in-memory query cache serves the return navigation within `staleTime`, so "exactly one request" passes without proving anything.
- Drag/position tests need asymmetric fixture positions (100/250/300). With 100/200/300 the computed midpoint equals the dragged item's own position and the assertion cannot fail.
- A `@/client/generated` mock must not adapt the SDK into a different API (a `getAll(scope, {...query, s: query.q}, page)` shim). Assertions then read fields the mock invented; assert the `path`/`query` passed to the SDK function.
- Unit tests: `pnpm vitest run <file>` in `frontend/`. Mock `@/client/generated` (`vi.mock('@/client/generated', () => sdk)`) and, when the code toasts, `@/message`; run the real query modules and `QueryClient` against them.
- `src/test-setup.ts` seeds `window.API_URL = ''`, so `getApiBaseUrl()` is `/api/v2` without stubbing `@/helpers/apiUrl`. Set `window.API_URL` in the test only when the base itself is under test.
- Test mutation options through the real lifecycle: `queryClient.getMutationCache().build(queryClient, options).execute(vars)`. To observe an optimistic write, assert inside the mocked request before throwing. Assert on our cache writes only; don't re-test TanStack's refetch or cancellation behaviour.
- When a component test is justified and reads server data, mount it against a real `QueryClient` seeded through the key factories, and mock only the generated client. Don't mock the composable that owns the behaviour under test; a mocked read hid a table that never updated after mutations. If a mutation invalidates a query, the client mock must answer the refetch with data matching the seeded state.
- A regression test must fail against the unfixed code. Check that before landing the fix.
- A test that can't fail is a defect. Examples: asserting that an unseeded cache is `toBeUndefined()`, checking for hidden controls on the viewer's own row (it never shows them), asserting two key literals differ, or calling the fix itself (`observer.reset()`) instead of going through the component.
- Typecheck with `pnpm typecheck` in `frontend/` and read the log. It has well over a thousand pre-existing errors, so compare the normalized error set against the base branch rather than the count; no new entries allowed:

  ```bash
  pnpm typecheck 2>&1 | grep 'error TS' | sed -E 's/\([0-9]+,[0-9]+\)//' | sort -u > /tmp/tsc-after.txt
  comm -13 /tmp/tsc-before.txt /tmp/tsc-after.txt
  ```

  Plain `vue-tsc --build` without `--force` can skip the build and print nothing, which looks like zero errors. Do not use `vue-tsc -p tsconfig.app.json`: it reports a spurious TS2589 on `i18n.global.t` that the project build does not, and it skips test files, so errors there (`.at()` is not in the lib target) go unnoticed until CI.

---

### .Agents Docs Translations (`.agents/docs/translations.md`)

# Translations

User-facing strings must be translatable.

- Frontend: source strings in `frontend/src/i18n/lang/en.json`. In components: `const {t} = useI18n({useScope: 'global'})`, then `t('your.key')`.
- API (mostly notifications): source strings in `pkg/i18n/lang/en.json`. Use `i18n.T(lang, "your.key", params...)`.
- Edit only `en.json`. Never add a language or translate strings yourself; translations are managed at https://vikunja.io/docs/translations/. If asked to, point the user there.
- Before adding a new string, check if a string with the same value already exists. If it does, use the existing key.

---

--- METRICS ---
- Files Extracted: 16
- Estimated Token Budget: ~15725 tokens
- Canonical Reference: https://codewiki.google/github.com/go-vikunja/vikunja
