diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md
index 1c2fcb0b..bb133337 100644
--- a/docs/guide/using-librislog/profile.md
+++ b/docs/guide/using-librislog/profile.md
@@ -59,7 +59,9 @@ Set your preferred timezone for date/time displays (e.g., for the calendar heatm
## Theme
-Choose a custom DaisyUI theme from the dropdown. The theme previews in real-time as you browse the dropdown, and the selection is saved to your profile so it persists across sessions.
+Choose a custom DaisyUI theme from the swatch grid. Each tile previews the theme's surfaces and accent colors, and picking one applies it immediately. Press **Save** to keep it on your profile; leaving the page without saving restores the previous theme.
+
+The grid groups the four [Catppuccin](https://catppuccin.com/palette/) flavors (Latte, Frappé, Macchiato, Mocha) above the built-in DaisyUI themes. Latte is a light flavor, the other three are dark.
## Reading Goals
diff --git a/docs/releases.md b/docs/releases.md
index 038bf54e..30112342 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -16,6 +16,7 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's
| Version | Date | Type |
|---|---|---|
+| [v1.13.0](#v1-13-0-catppuccin-themes-and-theme-picker) | Unreleased | Feature release |
| [v1.12.1](#v1-12-1-dst-chart-timezone-fixes) | 2026-11-03 | Maintenance |
| [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | 2026-11-02 | Feature release |
| [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release |
@@ -39,6 +40,28 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's
---
+## v1.13.0: Catppuccin Themes and Theme Picker
+
+
+
+**Summary:** The profile settings gain the four Catppuccin flavors: **Latte** (light), **Frappé**, **Macchiato**, and **Mocha** (dark), and the theme selector becomes a swatch grid with live color previews. The flavors are registered as regular DaisyUI themes from the official Catppuccin daisyUI port, so preview, per-user persistence, chart colors, and the favicon work exactly as they do for the built-in themes. The port's `secondary` color is remapped to Catppuccin's mauve so chart series and secondary buttons stay visible, and Latte gets a contrast-tuned label palette. Existing themes, stored preferences, and the database stay unchanged.
+
+**Features**
+- 🌿 **Four Catppuccin flavors.** Latte is the light flavor; Frappé, Macchiato, and Mocha are dark. They are generated at build time by `@catppuccin/daisyui` and registered as ordinary DaisyUI themes, so live preview, per-user persistence, chart colors, and the favicon work exactly as they do for the built-in themes
+- 🎨 **Swatch-based theme picker.** Themes are now shown as tiles with a live color preview instead of a dropdown. The four Catppuccin flavors are listed first under a "Catppuccin" heading and the built-in DaisyUI themes follow in their own group. Picking a tile applies the theme immediately
+- 🖌️ **Readable secondary color.** The port maps `secondary` to a surface tone, which blends into the page in LibrisLog. It is remapped to Catppuccin's mauve, so chart series, secondary buttons, and the add-book stripe stay visible
+- ☀️ **Latte contrast tuning.** The light flavor keeps its palette but picks the higher-contrast label color per accent (light labels on the darker accents, dark labels on the pale ones) and uses blue instead of lavender for primary, so button and badge labels stay legible
+- 🖼️ **Correct favicon in every flavor.** The dark favicon now follows the applied theme's own `color-scheme`, so the three dark flavors get it automatically and Latte keeps the light one
+
+**Bug fixes**
+- 🔄 **Theme preferences are no longer re-applied on every page load.** The layout compared the stored custom theme against the wrong `localStorage` key, so each load wrote the theme back to storage and repainted the document even when nothing had changed
+
+**Breaking changes:** None. The new themes are additive, there are no API changes, and no database migration is needed (`custom_theme` already stores theme names). Downgrading removes the four themes from the picker; an account that selected one falls back to the default theme until it picks another.
+
+[Compare with v1.12.1](https://github.com/codebude/librislog/compare/v1.12.1...HEAD)
+
+---
+
## v1.12.1: DST Chart & Timezone Fixes
diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts
index 331d33bf..35d5b91e 100644
--- a/frontend/e2e/specs/11-profile.spec.ts
+++ b/frontend/e2e/specs/11-profile.spec.ts
@@ -31,7 +31,7 @@ test.describe('Profile', () => {
await page.goto('/profile');
await page.waitForTimeout(1500);
- await page.locator('select[name="custom-theme"]').selectOption('dracula');
+ await page.locator('[data-theme-option="dracula"]').click();
await page.locator('#section-theme button[class*="btn-primary"]').click();
await page.waitForTimeout(1000);
@@ -43,6 +43,78 @@ test.describe('Profile', () => {
expect(theme).toBe('dracula');
});
+ test('11.3b change theme to catppuccin flavors', async ({ page }) => {
+ await page.goto('/profile');
+ await page.waitForTimeout(1500);
+
+ const catppuccinOptions = await page
+ .locator('[data-theme-group="Catppuccin"] [data-theme-option]')
+ .evaluateAll((tiles) =>
+ tiles.map((tile) => ({
+ value: tile.getAttribute('data-theme-option'),
+ label: tile.textContent?.trim()
+ }))
+ );
+ expect(catppuccinOptions).toEqual([
+ { value: 'latte', label: 'Latte' },
+ { value: 'frappe', label: 'Frappé' },
+ { value: 'macchiato', label: 'Macchiato' },
+ { value: 'mocha', label: 'Mocha' }
+ ]);
+ await expect(page.locator('[data-theme-group="DaisyUI"] [data-theme-option="mocha"]')).toHaveCount(0);
+
+ await page.locator('[data-theme-option="mocha"]').click();
+ // Picking previews immediately, before Save is pressed.
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme')))
+ .toBe('mocha');
+
+ await page.locator('#section-theme button[class*="btn-primary"]').click();
+ await page.waitForTimeout(1000);
+
+ const saved = page.locator('#section-theme').getByText(/saved|gespeichert/i);
+ await expect(saved).toBeVisible({ timeout: 5000 });
+
+ const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
+ expect(theme).toBe('mocha');
+
+ const baseColor = await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim()
+ );
+ expect(baseColor).toMatch(/#1e1e2e|rgb\(30, 30, 46\)/i);
+
+ // The choice must survive a full reload from the stored profile settings.
+ await page.reload();
+ await page.waitForTimeout(1500);
+ const reloadedTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
+ expect(reloadedTheme).toBe('mocha');
+ const reloadedBase = await page.evaluate(() =>
+ getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim()
+ );
+ expect(reloadedBase).toMatch(/#1e1e2e|rgb\(30, 30, 46\)/i);
+
+ // Light direction: Latte must keep the light favicon.
+ await page.locator('[data-theme-option="latte"]').click();
+ await page.locator('#section-theme button[class*="btn-primary"]').click();
+ await page.waitForTimeout(1000);
+ const latteTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
+ expect(latteTheme).toBe('latte');
+ const favicon = await page.evaluate(
+ () => document.querySelector('link[rel="icon"][type="image/svg+xml"]')?.getAttribute('href') ?? ''
+ );
+ expect(favicon).not.toContain('favicon-dark');
+
+ // An unsaved pick is a preview only and must not survive leaving the page.
+ await page.locator('[data-theme-option="frappe"]').click();
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme')))
+ .toBe('frappe');
+ await page.goto('/library');
+ await expect
+ .poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme')))
+ .toBe('latte');
+ });
+
test('11.4 create api key', async ({ page }) => {
await page.goto('/profile');
await page.waitForTimeout(1000);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 8edae7fe..cc37f4c3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -25,6 +25,7 @@
"tailwindcss": "^4.3.0"
},
"devDependencies": {
+ "@catppuccin/daisyui": "^2.1.1",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-auto": "^8.0.0",
"@sveltejs/adapter-static": "^4.0.0",
@@ -136,6 +137,36 @@
"node": ">=18"
}
},
+ "node_modules/@catppuccin/daisyui": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@catppuccin/daisyui/-/daisyui-2.1.1.tgz",
+ "integrity": "sha512-PrZttjj8kwfDBJ34sR+DN25Xtjvxx4T5p8uu/iiGYZR8UOsNwzMlO/alYDBwwTOLzP1NKLNRax09kCT39+QM+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@catppuccin/palette": "^1.7.1"
+ },
+ "peerDependencies": {
+ "tailwindcss": "^4.0.17"
+ }
+ },
+ "node_modules/@catppuccin/palette": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@catppuccin/palette/-/palette-1.8.0.tgz",
+ "integrity": "sha512-qXhwKiLzQomUygUJYB36YAFgs+dET5bIocfkiaFIatQF5Pwc7L112TlF9P8J5Oqs3x3XTjYSucG0ncHXSCuk7Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/catppuccin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/catppuccin"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@cropper/element": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.3.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ac5cd654..a686fb8e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"test:e2e": "docker run --rm -v $(pwd)/../data-e2e:/data alpine sh -c 'rm -rf /data/*' 2>/dev/null; mkdir -p playwright-report test-results && docker compose -f ../docker-compose.e2e.yml up --build --abort-on-container-exit --attach test-runner --no-log-prefix"
},
"devDependencies": {
+ "@catppuccin/daisyui": "^2.1.1",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-auto": "^8.0.0",
"@sveltejs/adapter-static": "^4.0.0",
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 7c62b8cd..2ed8e771 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -61,6 +61,12 @@
--noise: 0;
}
+/* Catppuccin flavors, from the official daisyUI port */
+@plugin "./lib/themes/catppuccin-latte.ts";
+@plugin "./lib/themes/catppuccin-frappe.ts";
+@plugin "./lib/themes/catppuccin-macchiato.ts";
+@plugin "./lib/themes/catppuccin-mocha.ts";
+
/* Keep dark cards calm while giving the page and nested surfaces clearer depth. */
html[data-theme="dark"] {
--color-base-100: oklch(25.5% 0.018 255);
diff --git a/frontend/src/lib/components/ThemeSwatchPicker.svelte b/frontend/src/lib/components/ThemeSwatchPicker.svelte
new file mode 100644
index 00000000..3d096d27
--- /dev/null
+++ b/frontend/src/lib/components/ThemeSwatchPicker.svelte
@@ -0,0 +1,98 @@
+
+
+