From 40ab145d1b99472ae0b3579c60ad2c2e21568d91 Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 4 Nov 2026 10:21:16 +0100 Subject: [PATCH 1/4] =?UTF-8?q?Add=20Catppuccin=20Latte,=20Frapp=C3=A9,=20?= =?UTF-8?q?Macchiato=20and=20Mocha=20themes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guide/using-librislog/profile.md | 2 + docs/releases.md | 23 +++++++ frontend/e2e/specs/11-profile.spec.ts | 57 +++++++++++++++++ frontend/package-lock.json | 31 +++++++++ frontend/package.json | 1 + frontend/src/app.css | 6 ++ frontend/src/lib/stores/theme.test.ts | 64 ++++++++++++++++++- frontend/src/lib/stores/theme.ts | 51 +++++++++++++-- frontend/src/lib/themes/catppuccin-frappe.ts | 6 ++ frontend/src/lib/themes/catppuccin-latte.ts | 6 ++ .../src/lib/themes/catppuccin-macchiato.ts | 6 ++ frontend/src/lib/themes/catppuccin-mocha.ts | 6 ++ frontend/src/lib/themes/catppuccin-options.ts | 37 +++++++++++ frontend/src/lib/themes/catppuccin.test.ts | 26 ++++++++ frontend/src/routes/+layout.svelte | 4 +- frontend/src/routes/profile/+page.svelte | 15 +++-- frontend/vite.config.ts | 2 +- 17 files changed, 331 insertions(+), 12 deletions(-) create mode 100644 frontend/src/lib/themes/catppuccin-frappe.ts create mode 100644 frontend/src/lib/themes/catppuccin-latte.ts create mode 100644 frontend/src/lib/themes/catppuccin-macchiato.ts create mode 100644 frontend/src/lib/themes/catppuccin-mocha.ts create mode 100644 frontend/src/lib/themes/catppuccin-options.ts create mode 100644 frontend/src/lib/themes/catppuccin.test.ts diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 1c2fcb0b..81f35920 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -61,6 +61,8 @@ Set your preferred timezone for date/time displays (e.g., for the calendar heatm 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. +The dropdown 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 Set personal reading targets that are tracked on the dashboard's **Reading Streaks & Goals** section: diff --git a/docs/releases.md b/docs/releases.md index 038bf54e..0a1eba17 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) | 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 + + + +**Summary:** The profile theme picker gains the four Catppuccin flavors: **Latte** (light), **Frappé**, **Macchiato**, and **Mocha** (dark). They are registered as regular DaisyUI themes from the official Catppuccin daisyUI port, appear in their own group in the theme dropdown, and preview live like 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 +- 🎨 **Own group in the theme picker.** The four flavors are listed first under "Catppuccin" and labelled "Catppuccin Latte", "Catppuccin Frappé", "Catppuccin Macchiato", and "Catppuccin Mocha"; the built-in DaisyUI themes stay in a second group +- 🖌️ **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..8c26c7a9 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -43,6 +43,63 @@ 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('select[name="custom-theme"] optgroup[label="Catppuccin"] option') + .evaluateAll((options) => + options.map((option) => ({ + value: (option as HTMLOptionElement).value, + label: option.textContent?.trim() + })) + ); + expect(catppuccinOptions).toEqual([ + { value: 'latte', label: 'Catppuccin Latte' }, + { value: 'frappe', label: 'Catppuccin Frappé' }, + { value: 'macchiato', label: 'Catppuccin Macchiato' }, + { value: 'mocha', label: 'Catppuccin Mocha' } + ]); + + await page.locator('select[name="custom-theme"]').selectOption('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('select[name="custom-theme"]').selectOption('latte'); + 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'); + }); + 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/stores/theme.test.ts b/frontend/src/lib/stores/theme.test.ts index 08ec208f..4a286db9 100644 --- a/frontend/src/lib/stores/theme.test.ts +++ b/frontend/src/lib/stores/theme.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { setThemeMode, getThemeMode, setCustomTheme, getCustomTheme, getEffectiveTheme, cycleTheme, loadThemeFromStorage, saveThemeToStorage, - applyThemeToDocument, DAISYUI_THEMES + applyThemeToDocument, isDarkThemeName, isCatppuccinTheme, themeLabel, THEME_LABELS, + DAISYUI_THEMES, CATPPUCCIN_THEMES } from './theme'; describe('theme store', () => { @@ -67,4 +68,65 @@ describe('theme store', () => { expect(DAISYUI_THEMES).not.toContain('light'); expect(DAISYUI_THEMES).not.toContain('dark'); }); + + it('contains the four catppuccin flavors', () => { + expect(CATPPUCCIN_THEMES).toEqual(['latte', 'frappe', 'macchiato', 'mocha']); + for (const flavor of CATPPUCCIN_THEMES) { + expect(DAISYUI_THEMES).toContain(flavor); + } + }); + + it('accepts and previews a catppuccin flavor', () => { + setThemeMode('custom'); + setCustomTheme('mocha'); + expect(getCustomTheme()).toBe('mocha'); + expect(getEffectiveTheme()).toBe('mocha'); + }); + + it('labels catppuccin flavors with a prefixed display name', () => { + expect(themeLabel('latte')).toBe('Catppuccin Latte'); + expect(themeLabel('frappe')).toBe('Catppuccin Frappé'); + expect(themeLabel('macchiato')).toBe('Catppuccin Macchiato'); + expect(themeLabel('mocha')).toBe('Catppuccin Mocha'); + expect(themeLabel('dracula')).toBe('Dracula'); + expect(themeLabel('librislog')).toBe('Librislog'); + }); + + it('classifies latte as light and the other flavors as dark', () => { + expect(isDarkThemeName('latte')).toBe(false); + expect(isDarkThemeName('frappe')).toBe(true); + expect(isDarkThemeName('macchiato')).toBe(true); + expect(isDarkThemeName('mocha')).toBe(true); + expect(isDarkThemeName('dracula')).toBe(true); + expect(isDarkThemeName('dark')).toBe(true); + expect(isDarkThemeName('light')).toBe(false); + expect(isDarkThemeName('librislog')).toBe(false); + }); + + it('recognises the catppuccin group without claiming other themes', () => { + for (const flavor of CATPPUCCIN_THEMES) { + expect(isCatppuccinTheme(flavor)).toBe(true); + } + expect(isCatppuccinTheme('dracula')).toBe(false); + expect(isCatppuccinTheme('librislog')).toBe(false); + expect(isCatppuccinTheme('toString')).toBe(false); + }); + + it('only labels themes that exist and never inherited object members', () => { + for (const theme of Object.keys(THEME_LABELS)) { + expect(DAISYUI_THEMES).toContain(theme); + } + expect(themeLabel('constructor')).toBe('Constructor'); + expect(typeof themeLabel('__proto__')).toBe('string'); + expect(themeLabel('__proto__')).not.toBe('[object Object]'); + }); + + it('restores a catppuccin flavor from localStorage', () => { + setCustomTheme('frappe'); + saveThemeToStorage(); + setCustomTheme('librislog'); + + loadThemeFromStorage(); + expect(getCustomTheme()).toBe('frappe'); + }); }); diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts index 703b10d2..b55f5013 100644 --- a/frontend/src/lib/stores/theme.ts +++ b/frontend/src/lib/stores/theme.ts @@ -1,7 +1,7 @@ export type ThemeMode = 'light' | 'dark' | 'custom'; import { invalidateColorCache } from '#lib/chartjs/theme.js'; -const DAISYUI_THEMES = [ +const BUILTIN_THEMES = [ 'cupcake', 'bumblebee', 'emerald', 'corporate', 'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden', 'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black', 'luxury', 'dracula', @@ -10,8 +10,33 @@ const DAISYUI_THEMES = [ 'librislog' ] as const; +/** Catppuccin flavors, registered in app.css from the official daisyUI port. */ +export const CATPPUCCIN_THEMES = ['latte', 'frappe', 'macchiato', 'mocha'] as const; + +const DAISYUI_THEMES = [...BUILTIN_THEMES, ...CATPPUCCIN_THEMES] as const; + export type DaisyUITheme = (typeof DAISYUI_THEMES)[number]; +/** Whether a theme name belongs to the Catppuccin group in the picker. */ +export function isCatppuccinTheme(theme: string): boolean { + return (CATPPUCCIN_THEMES as readonly string[]).includes(theme); +} + +/** Display names for themes whose raw daisyUI name is not self-explanatory. */ +export const THEME_LABELS: Partial> = { + latte: 'Catppuccin Latte', + frappe: 'Catppuccin Frappé', + macchiato: 'Catppuccin Macchiato', + mocha: 'Catppuccin Mocha' +}; + +/** Human-readable label for a theme name, falling back to a capitalised name. */ +export function themeLabel(theme: string): string { + return Object.hasOwn(THEME_LABELS, theme) + ? (THEME_LABELS[theme as DaisyUITheme] as string) + : theme.charAt(0).toUpperCase() + theme.slice(1); +} + export const THEME_MODE_KEY = 'librislog_theme_mode'; export const CUSTOM_THEME_KEY = 'librislog_custom_theme'; @@ -99,11 +124,29 @@ import { writable } from 'svelte/store'; export const themeApplyCount = writable(0); -const DARK_THEMES: readonly string[] = ['synthwave', 'halloween', 'forest', 'dracula', 'black', 'luxury', 'night', 'coffee', 'dim', 'abyss', 'sunset', 'business']; +const DARK_THEMES: readonly string[] = ['synthwave', 'halloween', 'forest', 'dracula', 'black', 'luxury', 'night', 'coffee', 'dim', 'abyss', 'sunset', 'business', 'frappe', 'macchiato', 'mocha']; + +/** Fallback classification for custom daisyUI theme names (`dark` included). */ +export function isDarkThemeName(theme: string): boolean { + return theme === 'dark' || DARK_THEMES.includes(theme); +} + +/** + * Resolve whether the applied theme is dark from the theme's own + * `color-scheme`, so newly added themes need no entry in DARK_THEMES. + */ +function currentThemeIsDark(): boolean { + if (_themeMode === 'dark') return true; + if (_themeMode !== 'custom') return false; + if (typeof document !== 'undefined' && typeof getComputedStyle === 'function') { + const scheme = getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim(); + if (scheme === 'dark' || scheme === 'light') return scheme === 'dark'; + } + return isDarkThemeName(getEffectiveTheme()); +} function updateFavicon() { - const effective = getEffectiveTheme(); - const isDark = _themeMode === 'dark' || (_themeMode === 'custom' && DARK_THEMES.includes(effective)); + const isDark = currentThemeIsDark(); const href = isDark ? '/favicon/favicon-dark.svg' : '/favicon/favicon.svg'; const link = document.querySelector('link[rel="icon"][type="image/svg+xml"]') as HTMLLinkElement | null; if (link && link.href !== new URL(href, location.href).href) { diff --git a/frontend/src/lib/themes/catppuccin-frappe.ts b/frontend/src/lib/themes/catppuccin-frappe.ts new file mode 100644 index 00000000..e098ff02 --- /dev/null +++ b/frontend/src/lib/themes/catppuccin-frappe.ts @@ -0,0 +1,6 @@ +import { createCatppuccinPlugin } from '@catppuccin/daisyui'; + +import { CATPPUCCIN_OPTIONS } from './catppuccin-options'; + +/** Catppuccin Frappé (dark flavor). See catppuccin-options.ts for the tuning. */ +export default createCatppuccinPlugin('frappe', CATPPUCCIN_OPTIONS); diff --git a/frontend/src/lib/themes/catppuccin-latte.ts b/frontend/src/lib/themes/catppuccin-latte.ts new file mode 100644 index 00000000..04a4886b --- /dev/null +++ b/frontend/src/lib/themes/catppuccin-latte.ts @@ -0,0 +1,6 @@ +import { createCatppuccinPlugin } from '@catppuccin/daisyui'; + +import { CATPPUCCIN_LATTE_OPTIONS } from './catppuccin-options'; + +/** Catppuccin Latte (light flavor). See catppuccin-options.ts for the tuning. */ +export default createCatppuccinPlugin('latte', CATPPUCCIN_LATTE_OPTIONS); diff --git a/frontend/src/lib/themes/catppuccin-macchiato.ts b/frontend/src/lib/themes/catppuccin-macchiato.ts new file mode 100644 index 00000000..b3a38eaf --- /dev/null +++ b/frontend/src/lib/themes/catppuccin-macchiato.ts @@ -0,0 +1,6 @@ +import { createCatppuccinPlugin } from '@catppuccin/daisyui'; + +import { CATPPUCCIN_OPTIONS } from './catppuccin-options'; + +/** Catppuccin Macchiato (dark flavor). See catppuccin-options.ts for the tuning. */ +export default createCatppuccinPlugin('macchiato', CATPPUCCIN_OPTIONS); diff --git a/frontend/src/lib/themes/catppuccin-mocha.ts b/frontend/src/lib/themes/catppuccin-mocha.ts new file mode 100644 index 00000000..b7f92d64 --- /dev/null +++ b/frontend/src/lib/themes/catppuccin-mocha.ts @@ -0,0 +1,6 @@ +import { createCatppuccinPlugin } from '@catppuccin/daisyui'; + +import { CATPPUCCIN_OPTIONS } from './catppuccin-options'; + +/** Catppuccin Mocha (dark flavor). See catppuccin-options.ts for the tuning. */ +export default createCatppuccinPlugin('mocha', CATPPUCCIN_OPTIONS); diff --git a/frontend/src/lib/themes/catppuccin-options.ts b/frontend/src/lib/themes/catppuccin-options.ts new file mode 100644 index 00000000..762df0ab --- /dev/null +++ b/frontend/src/lib/themes/catppuccin-options.ts @@ -0,0 +1,37 @@ +/** + * Shared overrides for the Catppuccin daisyUI port. + * + * LibrisLog uses `secondary` for chart series, secondary buttons and the + * add-book stripe. The port maps it to a surface tone (surface0), which + * blends into the page background, so every flavor uses `mauve` instead. + */ +export const CATPPUCCIN_OPTIONS = { + secondary: 'mauve', + 'secondary-content': 'base' +} as const; + +/** + * Latte is the only light flavor. The upstream port paints the content colors + * light (mantle/base), which drops button and badge labels on the pale accents + * to roughly 2:1 in WCAG contrast. + * + * These overrides keep the palette but choose the higher-contrast token per + * accent, and `blue` instead of lavender for primary so the main action keeps + * a legible label. Resulting contrast for text on the accent: primary 4.3:1, + * secondary 4.8:1, neutral 3.4:1, accent 3.0:1, warning 3.1:1, info 2.9:1, + * success 3.0:1, error 4.8:1. Info stays just below 3:1 because that is the + * highest-contrast palette token available; the dark flavors keep the port + * defaults, which range from 4.1:1 to 13.9:1. + */ +export const CATPPUCCIN_LATTE_OPTIONS = { + ...CATPPUCCIN_OPTIONS, + primary: 'blue', + 'primary-content': 'base', + neutral: 'overlay2', + 'neutral-content': 'base', + 'accent-content': 'text', + 'success-content': 'base', + 'warning-content': 'text', + 'error-content': 'base', + 'info-content': 'text' +} as const; diff --git a/frontend/src/lib/themes/catppuccin.test.ts b/frontend/src/lib/themes/catppuccin.test.ts new file mode 100644 index 00000000..0227e982 --- /dev/null +++ b/frontend/src/lib/themes/catppuccin.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; + +import { CATPPUCCIN_THEMES } from '#lib/stores/theme.js'; + +const appCssPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../app.css'); +const appCss = readFileSync(appCssPath, 'utf8'); + +// The store accepts any name in CATPPUCCIN_THEMES, so a missing @plugin line +// would silently fall back to daisyUI's default theme instead of failing loudly. +describe('catppuccin theme registration', () => { + it('registers a daisyUI plugin for every catppuccin flavor', () => { + for (const flavor of CATPPUCCIN_THEMES) { + expect(appCss).toContain(`@plugin "./lib/themes/catppuccin-${flavor}.ts";`); + } + }); + + it('registers each flavor exactly once', () => { + for (const flavor of CATPPUCCIN_THEMES) { + const occurrences = appCss.split(`catppuccin-${flavor}.ts`).length - 1; + expect(occurrences).toBe(1); + } + }); +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 4491bc81..d02ba96d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -11,7 +11,7 @@ import { currentUser, csrfToken, loadAuthFromStorage, initAuthSync } from '#lib/stores/auth.js'; import { _, setupI18n } from '#lib/i18n/index.js'; import { setTimezone, setQuoteServiceEnabled } from '#lib/stores/timezone.js'; - import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY } from '#lib/stores/theme.js'; + import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY, CUSTOM_THEME_KEY } from '#lib/stores/theme.js'; import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, CloudDownload } from '@lucide/svelte'; import { version } from '#lib/version.js'; import VersionLink from '#lib/components/VersionLink.svelte'; @@ -133,7 +133,7 @@ if (settings.theme) { const dbMode = sanitizeThemeMode(settings.theme); const storedMode = localStorage.getItem(THEME_MODE_KEY); - const storedCustom = localStorage.getItem('custom_theme'); + const storedCustom = localStorage.getItem(CUSTOM_THEME_KEY); if (!storedMode || storedMode !== dbMode || storedCustom !== (settings.custom_theme ?? null)) { setThemeMode(dbMode); setCustomTheme(settings.custom_theme); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 0091f52e..31cd7e50 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -8,7 +8,7 @@ import { _, SUPPORTED_LOCALES, setLocale } from '#lib/i18n/index.js'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '#lib/password.js'; import { getTimezone, setTimezone, detectTimezone } from '#lib/stores/timezone.js'; - import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, clearRestorePoint, DAISYUI_THEMES } from '#lib/stores/theme.js'; + import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, clearRestorePoint, DAISYUI_THEMES, CATPPUCCIN_THEMES, isCatppuccinTheme, themeLabel } from '#lib/stores/theme.js'; import Alert from '#lib/components/Alert.svelte'; import SearchableSelect from '#lib/components/SearchableSelect.svelte'; import AdaptiveDateInput from '#lib/components/AdaptiveDateInput.svelte'; @@ -1066,9 +1066,16 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; {$_('settings.themeSelect')} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 89f80d28..23d54dae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json'], include: ['src/**/*.svelte', 'src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'src/lib/test/**', 'src/routes/**'] + exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'src/lib/test/**', 'src/lib/themes/**', 'src/routes/**'] } } }); From 50d1cfe92f37afbe8ad3f6970b272b346bb68acf Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 4 Nov 2026 12:38:58 +0100 Subject: [PATCH 2/4] Add a swatch-based theme picker to the profile settings --- docs/guide/using-librislog/profile.md | 4 +- docs/releases.md | 8 +- frontend/e2e/specs/11-profile.spec.ts | 39 +++++--- .../lib/components/ThemeSwatchPicker.svelte | 98 +++++++++++++++++++ .../lib/components/ThemeSwatchPicker.test.ts | 48 +++++++++ frontend/src/lib/components/UserMenu.svelte | 6 +- frontend/src/lib/themes/catppuccin-options.ts | 10 +- frontend/src/lib/themes/catppuccin.test.ts | 14 ++- frontend/src/routes/profile/+page.svelte | 58 +++++++---- 9 files changed, 238 insertions(+), 47 deletions(-) create mode 100644 frontend/src/lib/components/ThemeSwatchPicker.svelte create mode 100644 frontend/src/lib/components/ThemeSwatchPicker.test.ts diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 81f35920..bb133337 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -59,9 +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 dropdown 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. +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 0a1eba17..30112342 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -16,7 +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) | Unreleased | Feature release | +| [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 | @@ -40,15 +40,15 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's --- -## v1.13.0: Catppuccin Themes +## v1.13.0: Catppuccin Themes and Theme Picker -**Summary:** The profile theme picker gains the four Catppuccin flavors: **Latte** (light), **Frappé**, **Macchiato**, and **Mocha** (dark). They are registered as regular DaisyUI themes from the official Catppuccin daisyUI port, appear in their own group in the theme dropdown, and preview live like 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. +**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 -- 🎨 **Own group in the theme picker.** The four flavors are listed first under "Catppuccin" and labelled "Catppuccin Latte", "Catppuccin Frappé", "Catppuccin Macchiato", and "Catppuccin Mocha"; the built-in DaisyUI themes stay in a second group +- 🎨 **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 diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index 8c26c7a9..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); @@ -48,21 +48,26 @@ test.describe('Profile', () => { await page.waitForTimeout(1500); const catppuccinOptions = await page - .locator('select[name="custom-theme"] optgroup[label="Catppuccin"] option') - .evaluateAll((options) => - options.map((option) => ({ - value: (option as HTMLOptionElement).value, - label: option.textContent?.trim() + .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: 'Catppuccin Latte' }, - { value: 'frappe', label: 'Catppuccin Frappé' }, - { value: 'macchiato', label: 'Catppuccin Macchiato' }, - { value: 'mocha', label: 'Catppuccin Mocha' } + { 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('select[name="custom-theme"]').selectOption('mocha'); + 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); @@ -89,7 +94,7 @@ test.describe('Profile', () => { expect(reloadedBase).toMatch(/#1e1e2e|rgb\(30, 30, 46\)/i); // Light direction: Latte must keep the light favicon. - await page.locator('select[name="custom-theme"]').selectOption('latte'); + 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')); @@ -98,6 +103,16 @@ test.describe('Profile', () => { () => 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 }) => { 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 @@ + + +
+
+ {#each groups as group (group.label)} +
+

+ {group.label} +

+
+ {#each group.themes as theme (theme)} + {@const label = themeLabel(theme)} + {@const shortLabel = label.startsWith(`${group.label} `) + ? label.slice(group.label.length + 1) + : label} + + {/each} +
+
+ {/each} +
+
diff --git a/frontend/src/lib/components/ThemeSwatchPicker.test.ts b/frontend/src/lib/components/ThemeSwatchPicker.test.ts new file mode 100644 index 00000000..82f4783b --- /dev/null +++ b/frontend/src/lib/components/ThemeSwatchPicker.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ThemeSwatchPicker from './ThemeSwatchPicker.svelte'; + +const groups = [ + { label: 'Catppuccin', themes: ['latte', 'mocha'] }, + { label: 'DaisyUI', themes: ['dracula'] } +]; + +describe('ThemeSwatchPicker', () => { + it('renders one radio per theme and checks the selected one', () => { + render(ThemeSwatchPicker, { selected: 'latte', groups }); + + expect(screen.getAllByRole('radio')).toHaveLength(3); + expect(screen.getByRole('radio', { name: 'Latte' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Dracula' })).not.toBeChecked(); + }); + + it('marks nothing when no custom theme is active', () => { + render(ThemeSwatchPicker, { selected: null, groups }); + + expect(screen.getAllByRole('radio').some((radio) => (radio as HTMLInputElement).checked)).toBe(false); + }); + + it('names the groups so short labels keep their context', () => { + render(ThemeSwatchPicker, { selected: 'latte', groups }); + + expect(screen.getByRole('group', { name: 'Catppuccin' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'DaisyUI' })).toBeInTheDocument(); + }); + + it('previews every theme with its own theme scope', () => { + const { container } = render(ThemeSwatchPicker, { selected: 'latte', groups }); + + for (const theme of ['latte', 'mocha', 'dracula']) { + expect(container.querySelector(`[data-theme-option="${theme}"] [data-theme="${theme}"]`)).not.toBeNull(); + } + }); + + it('reports a newly picked theme', async () => { + const onSelect = vi.fn(); + render(ThemeSwatchPicker, { selected: 'latte', groups, onSelect }); + + await fireEvent.click(screen.getByRole('radio', { name: 'Dracula' })); + + expect(onSelect).toHaveBeenCalledWith('dracula'); + }); +}); diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index bd5df5b3..c8af0af6 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -4,7 +4,7 @@ import { broadcastLogout, currentUser, csrfToken } from '#lib/stores/auth.js'; import type { UpdateInfo } from '#lib/stores/updateCheck.js'; import { _ } from '#lib/i18n/index.js'; - import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '#lib/stores/theme.js'; + import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion, themeLabel as formatThemeLabel, clearRestorePoint } from '#lib/stores/theme.js'; import UserAvatar from '#lib/components/UserAvatar.svelte'; import { Sun, Moon, Palette, CloudDownload, ExternalLink } from '@lucide/svelte'; @@ -24,7 +24,7 @@ switch (themeMode) { case 'light': return $_('settings.themeLight'); case 'dark': return $_('settings.themeDark'); - case 'custom': return getCustomTheme() ? getCustomTheme()!.charAt(0).toUpperCase() + getCustomTheme()!.slice(1) : $_('settings.themeCustom'); + case 'custom': return getCustomTheme() ? formatThemeLabel(getCustomTheme()!) : $_('settings.themeCustom'); } }); @@ -55,6 +55,8 @@ themeIcon = getThemeIcon(); themeMode = getThemeMode(); themeVersion = getThemeVersion(); + // This change is persisted here, so the profile page must not revert it. + clearRestorePoint(); applyThemeToDocument(); saveThemeToStorage(); if ($currentUser) { diff --git a/frontend/src/lib/themes/catppuccin-options.ts b/frontend/src/lib/themes/catppuccin-options.ts index 762df0ab..05945755 100644 --- a/frontend/src/lib/themes/catppuccin-options.ts +++ b/frontend/src/lib/themes/catppuccin-options.ts @@ -17,11 +17,11 @@ export const CATPPUCCIN_OPTIONS = { * * These overrides keep the palette but choose the higher-contrast token per * accent, and `blue` instead of lavender for primary so the main action keeps - * a legible label. Resulting contrast for text on the accent: primary 4.3:1, - * secondary 4.8:1, neutral 3.4:1, accent 3.0:1, warning 3.1:1, info 2.9:1, - * success 3.0:1, error 4.8:1. Info stays just below 3:1 because that is the - * highest-contrast palette token available; the dark flavors keep the port - * defaults, which range from 4.1:1 to 13.9:1. + * a legible label. Resulting contrast for text on the accent: primary 4.34:1, + * secondary 4.79:1, neutral 3.49:1, accent 3.02:1, warning 3.05:1, + * error 4.80:1, info 2.86:1, success 2.96:1. Info and success stay just below + * 3:1 because that is the highest-contrast palette token available; the dark + * flavors keep the port defaults, which range from 4.09:1 to 13.86:1. */ export const CATPPUCCIN_LATTE_OPTIONS = { ...CATPPUCCIN_OPTIONS, diff --git a/frontend/src/lib/themes/catppuccin.test.ts b/frontend/src/lib/themes/catppuccin.test.ts index 0227e982..fb6378ff 100644 --- a/frontend/src/lib/themes/catppuccin.test.ts +++ b/frontend/src/lib/themes/catppuccin.test.ts @@ -7,20 +7,30 @@ import { CATPPUCCIN_THEMES } from '#lib/stores/theme.js'; const appCssPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../app.css'); const appCss = readFileSync(appCssPath, 'utf8'); +// Comments can mention a plugin line without registering it. +const appCssWithoutComments = appCss.replace(/\/\*[\s\S]*?\*\//g, ''); // The store accepts any name in CATPPUCCIN_THEMES, so a missing @plugin line // would silently fall back to daisyUI's default theme instead of failing loudly. describe('catppuccin theme registration', () => { it('registers a daisyUI plugin for every catppuccin flavor', () => { for (const flavor of CATPPUCCIN_THEMES) { - expect(appCss).toContain(`@plugin "./lib/themes/catppuccin-${flavor}.ts";`); + expect(appCssWithoutComments).toContain(`@plugin "./lib/themes/catppuccin-${flavor}.ts";`); } }); it('registers each flavor exactly once', () => { for (const flavor of CATPPUCCIN_THEMES) { - const occurrences = appCss.split(`catppuccin-${flavor}.ts`).length - 1; + const occurrences = appCssWithoutComments.split(`catppuccin-${flavor}.ts`).length - 1; expect(occurrences).toBe(1); } }); + + it('registers the flavors after the daisyui plugin so they are not overridden', () => { + const daisyui = appCssWithoutComments.indexOf('@plugin "daisyui"'); + expect(daisyui).toBeGreaterThan(-1); + for (const flavor of CATPPUCCIN_THEMES) { + expect(appCssWithoutComments.indexOf(`catppuccin-${flavor}.ts`)).toBeGreaterThan(daisyui); + } + }); }); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 31cd7e50..a8a4906e 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -8,8 +8,9 @@ import { _, SUPPORTED_LOCALES, setLocale } from '#lib/i18n/index.js'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '#lib/password.js'; import { getTimezone, setTimezone, detectTimezone } from '#lib/stores/timezone.js'; - import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, clearRestorePoint, DAISYUI_THEMES, CATPPUCCIN_THEMES, isCatppuccinTheme, themeLabel } from '#lib/stores/theme.js'; + import { getThemeMode, setThemeMode, getCustomTheme, setCustomTheme, getEffectiveTheme, applyThemeToDocument, saveThemeToStorage, sanitizeThemeMode, restoreFromPoint, saveRestorePoint, DAISYUI_THEMES, CATPPUCCIN_THEMES, isCatppuccinTheme } from '#lib/stores/theme.js'; import Alert from '#lib/components/Alert.svelte'; + import ThemeSwatchPicker from '#lib/components/ThemeSwatchPicker.svelte'; import SearchableSelect from '#lib/components/SearchableSelect.svelte'; import AdaptiveDateInput from '#lib/components/AdaptiveDateInput.svelte'; import UserAvatar from '#lib/components/UserAvatar.svelte'; @@ -59,8 +60,13 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; let cropperError = $state(null); let avatarInput: HTMLInputElement | undefined = $state(); let themeMode = $state(getThemeMode()); - let customTheme = $state(getCustomTheme() ?? 'dracula'); + let customTheme = $state(getCustomTheme() ?? getEffectiveTheme()); let themeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + + const themeGroups = [ + { label: 'Catppuccin', themes: CATPPUCCIN_THEMES }, + { label: 'DaisyUI', themes: [...DAISYUI_THEMES].sort().filter((t) => !isCatppuccinTheme(t)) } + ]; let goalPagesPerDayEnabled = $state(false); let goalPagesPerDay = $state(20); let goalPagesPerMonthEnabled = $state(false); @@ -160,9 +166,13 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; setTimezone(settings.timezone); themeMode = sanitizeThemeMode(settings.theme); setThemeMode(themeMode); - if (themeMode === 'custom' && settings.custom_theme) { - customTheme = settings.custom_theme; - setCustomTheme(customTheme); + const storedCustom = + settings.custom_theme && (DAISYUI_THEMES as readonly string[]).includes(settings.custom_theme) + ? settings.custom_theme + : null; + if (storedCustom) { + customTheme = storedCustom; + setCustomTheme(storedCustom); } goalPagesPerDayEnabled = settings.goal_pages_per_day_enabled; goalPagesPerDay = settings.goal_pages_per_day; @@ -404,21 +414,35 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; } }); + function previewTheme(theme: string) { + customTheme = theme; + themeMessage = null; + if (getThemeMode() !== 'custom') { + setThemeMode('custom'); + themeMode = 'custom'; + } + } + async function saveTheme() { themeMessage = null; setThemeMode('custom'); setCustomTheme(customTheme); themeMode = 'custom'; applyThemeToDocument(); - saveThemeToStorage(); - clearRestorePoint(); try { await api.profile.updateSettings({ theme: 'custom', custom_theme: customTheme, }); + saveThemeToStorage(); + // The saved theme becomes the new revert target for later previews. + saveRestorePoint(); themeMessage = { type: 'success', text: $_('common.saved') }; } catch (e: unknown) { + if (restoreFromPoint()) { + applyThemeToDocument(); + saveThemeToStorage(); + } themeMessage = { type: 'error', text: e instanceof Error ? e.message : $_('common.saveFailed') }; } } @@ -1063,20 +1087,14 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; >{themeMessage.text} {/if} - {$_('settings.themeSelect')} + {$_('settings.themeSelect')} - + From 43edf61dd07e825e6099a0a7bf6f1bca0a80c85e Mon Sep 17 00:00:00 2001 From: codebude Date: Wed, 4 Nov 2026 17:22:26 +0100 Subject: [PATCH 3/4] Add a collapsible sidebar with an icon-only rail --- docs/guide/using-librislog/library.md | 2 + docs/releases.md | 7 +- frontend/e2e/specs/02-dashboard.spec.ts | 39 ++++++++++ frontend/src/lib/i18n/locales/de.json | 5 ++ frontend/src/lib/i18n/locales/en.json | 5 ++ frontend/src/lib/i18n/locales/es.json | 5 ++ frontend/src/lib/i18n/locales/fr.json | 5 ++ frontend/src/lib/i18n/locales/zh.json | 5 ++ frontend/src/lib/stores/sidebar.test.ts | 80 +++++++++++++++++++++ frontend/src/lib/stores/sidebar.ts | 36 ++++++++++ frontend/src/lib/utils/nav.test.ts | 20 ++++++ frontend/src/lib/utils/nav.ts | 4 ++ frontend/src/routes/+layout.svelte | 91 ++++++++++++++++++++---- frontend/src/routes/profile/+page.svelte | 28 ++++++-- 14 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 frontend/src/lib/stores/sidebar.test.ts create mode 100644 frontend/src/lib/stores/sidebar.ts create mode 100644 frontend/src/lib/utils/nav.test.ts create mode 100644 frontend/src/lib/utils/nav.ts diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 2217f2b8..88819cca 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -26,6 +26,8 @@ Each book can also have an optional medium: Print, eBook, Audiobook, Comic / Gra ## Navigation - Switch between tabs using the bottom navigation bar on mobile or the sidebar on desktop +- Use the button under the logo to collapse the desktop sidebar to an icon-only rail; the choice is remembered in your browser +- The current page is highlighted in the sidebar and the mobile tab bar - Books are displayed as cards with cover images, titles, and authors - Click any book to open the detail view diff --git a/docs/releases.md b/docs/releases.md index 30112342..826e629d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -16,7 +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.13.0](#v1-13-0-catppuccin-themes-picker-and-collapsible-sidebar) | 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 | @@ -40,13 +40,14 @@ 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 +## v1.13.0: Catppuccin Themes, Picker and Collapsible Sidebar -**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. +**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. A new control under the logo also collapses the desktop sidebar to an icon rail and remembers the choice per browser. Existing themes, stored preferences, and the database stay unchanged. **Features** +- 🧭 **Collapsible sidebar.** A button under the logo shrinks the desktop sidebar from 224 px to an 80 px icon rail with tooltips, so small laptop screens gain content width. The current page stays highlighted in both states, and the choice is stored per browser - 🌿 **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 diff --git a/frontend/e2e/specs/02-dashboard.spec.ts b/frontend/e2e/specs/02-dashboard.spec.ts index 58e113fd..929ffbdb 100644 --- a/frontend/e2e/specs/02-dashboard.spec.ts +++ b/frontend/e2e/specs/02-dashboard.spec.ts @@ -141,4 +141,43 @@ test.describe('Dashboard', () => { // Restore the default so later tests keep the section visible. await updateProfileSettings(page, { gamification_enabled: true }); }); + + test('2.12 sidebar collapses to an icon rail and remembers it', async ({ page }) => { + const sidebar = page.locator('#app-sidebar'); + const toggle = page.locator('#app-sidebar button[aria-controls="app-sidebar"]'); + + await expect(sidebar).toBeVisible(); + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(224); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(page.locator('#app-sidebar a[href="/dashboard"]')).toHaveAttribute('aria-current', 'page'); + + await toggle.click(); + await expect.poll(async () => Math.round((await sidebar.boundingBox())?.width ?? 0)).toBe(80); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // Icon-only mode keeps an exact accessible name, without the tooltip text. + await expect(sidebar.locator('a[href="/library"]')).toHaveAccessibleName('Library'); + + // Navigating from the rail keeps it collapsed and moves the active marker. + await sidebar.locator('a[href="/library"]').click(); + await expect(page).toHaveURL('/library'); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(80); + await expect(page.locator('#app-sidebar a[href="/library"]')).toHaveAttribute('aria-current', 'page'); + + await page.reload(); + await page.waitForSelector('h1'); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(80); + + await page.locator('#app-sidebar button[aria-controls="app-sidebar"]').click(); + await expect.poll(async () => Math.round((await page.locator('#app-sidebar').boundingBox())?.width ?? 0)).toBe(224); + }); + + test('2.13 mobile keeps the bottom navigation with an active marker', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(500); + + await expect(page.locator('#app-sidebar')).toBeHidden(); + const mobileNav = page.locator('#mobile-nav'); + await expect(mobileNav).toBeVisible(); + await expect(mobileNav.locator('a[href="/dashboard"]')).toHaveAttribute('aria-current', 'page'); + }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 8fd660fa..09aee3b0 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -6,6 +6,11 @@ "language": "Sprache" }, "nav": { + "mainNavigation": "Hauptnavigation", + "collapseSidebar": "Seitenleiste einklappen", + "expandSidebar": "Seitenleiste ausklappen", + "collapse": "Einklappen", + "sidebar": "Seitenleiste", "dashboard": "Dashboard", "library": "Bibliothek", "timeline": "Zeitleiste", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 6714ca1f..6c24cee7 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -6,6 +6,11 @@ "language": "Language" }, "nav": { + "mainNavigation": "Main navigation", + "collapseSidebar": "Collapse sidebar", + "expandSidebar": "Expand sidebar", + "collapse": "Collapse", + "sidebar": "Sidebar", "dashboard": "Dashboard", "library": "Library", "timeline": "Timeline", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 350bf415..d0172393 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -6,6 +6,11 @@ "language": "Idioma" }, "nav": { + "mainNavigation": "Navegación principal", + "collapseSidebar": "Contraer barra lateral", + "expandSidebar": "Expandir barra lateral", + "collapse": "Contraer", + "sidebar": "Barra lateral", "dashboard": "Panel", "library": "Biblioteca", "timeline": "Cronología", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 2a49111a..a6b87c90 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -6,6 +6,11 @@ "language": "Langue" }, "nav": { + "mainNavigation": "Navigation principale", + "collapseSidebar": "Réduire la barre latérale", + "expandSidebar": "Développer la barre latérale", + "collapse": "Réduire", + "sidebar": "Barre latérale", "dashboard": "Tableau de bord", "library": "Bibliothèque", "timeline": "Chronologie", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 79f55be1..e865bc48 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -6,6 +6,11 @@ "language": "语言" }, "nav": { + "mainNavigation": "主导航", + "collapseSidebar": "收起侧边栏", + "expandSidebar": "展开侧边栏", + "collapse": "收起", + "sidebar": "侧边栏", "dashboard": "仪表盘", "library": "书库", "timeline": "时间线", diff --git a/frontend/src/lib/stores/sidebar.test.ts b/frontend/src/lib/stores/sidebar.test.ts new file mode 100644 index 00000000..08301ec4 --- /dev/null +++ b/frontend/src/lib/stores/sidebar.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { get } from 'svelte/store'; + +import { + SIDEBAR_COLLAPSED_KEY, + sidebarCollapsed, + loadSidebarFromStorage, + setSidebarCollapsed, + toggleSidebar +} from './sidebar'; + +describe('sidebar store', () => { + beforeEach(() => { + localStorage.clear(); + loadSidebarFromStorage(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('defaults to expanded', () => { + expect(get(sidebarCollapsed)).toBe(false); + }); + + it('toggles and persists the collapsed state', () => { + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(true); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('1'); + + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(false); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('0'); + }); + + it('persists an explicit state', () => { + setSidebarCollapsed(true); + expect(get(sidebarCollapsed)).toBe(true); + expect(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)).toBe('1'); + }); + + it('restores the stored state', () => { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, '1'); + loadSidebarFromStorage(); + expect(get(sidebarCollapsed)).toBe(true); + }); + + it('treats missing or unreadable values as expanded', () => { + for (const value of [null, '0', 'true', 'collapsed']) { + toggleSidebar(); + expect(get(sidebarCollapsed)).toBe(true); + if (value === null) { + localStorage.removeItem(SIDEBAR_COLLAPSED_KEY); + } else { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value); + } + loadSidebarFromStorage(); + expect(get(sidebarCollapsed)).toBe(false); + } + }); + + it('keeps the in-memory state when storage rejects writes', () => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage blocked'); + }); + + expect(() => setSidebarCollapsed(true)).not.toThrow(); + expect(get(sidebarCollapsed)).toBe(true); + }); + + it('keeps the in-memory state when storage rejects reads', () => { + setSidebarCollapsed(true); + vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('storage blocked'); + }); + + expect(() => loadSidebarFromStorage()).not.toThrow(); + expect(get(sidebarCollapsed)).toBe(true); + }); +}); diff --git a/frontend/src/lib/stores/sidebar.ts b/frontend/src/lib/stores/sidebar.ts new file mode 100644 index 00000000..80f89b29 --- /dev/null +++ b/frontend/src/lib/stores/sidebar.ts @@ -0,0 +1,36 @@ +import { get, writable, type Readable } from 'svelte/store'; + +export const SIDEBAR_COLLAPSED_KEY = 'librislog_sidebar_collapsed'; + +const state = writable(false); + +/** Whether the desktop sidebar is collapsed to an icon rail. */ +export const sidebarCollapsed: Readable = { subscribe: state.subscribe }; + +function persist(value: boolean): void { + try { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? '1' : '0'); + } catch { + // localStorage unavailable + } +} + +/** Restore the collapsed state saved for this browser. */ +export function loadSidebarFromStorage(): void { + try { + state.set(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'); + } catch { + // localStorage unavailable + } +} + +export function setSidebarCollapsed(value: boolean): void { + state.set(value); + persist(value); +} + +export function toggleSidebar(): void { + const next = !get(state); + state.set(next); + persist(next); +} diff --git a/frontend/src/lib/utils/nav.test.ts b/frontend/src/lib/utils/nav.test.ts new file mode 100644 index 00000000..537ac5ef --- /dev/null +++ b/frontend/src/lib/utils/nav.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; + +import { isActivePath } from './nav'; + +describe('isActivePath', () => { + it('matches the exact route', () => { + expect(isActivePath('/library', '/library')).toBe(true); + }); + + it('matches nested routes of the same section', () => { + expect(isActivePath('/library/42', '/library')).toBe(true); + expect(isActivePath('/library/42/edit', '/library')).toBe(true); + }); + + it('does not match other sections or prefix collisions', () => { + expect(isActivePath('/dashboard', '/library')).toBe(false); + expect(isActivePath('/libraryx', '/library')).toBe(false); + expect(isActivePath('/', '/library')).toBe(false); + }); +}); diff --git a/frontend/src/lib/utils/nav.ts b/frontend/src/lib/utils/nav.ts new file mode 100644 index 00000000..4fc2d22e --- /dev/null +++ b/frontend/src/lib/utils/nav.ts @@ -0,0 +1,4 @@ +/** Whether a nav href is the current page or a nested route below it. */ +export function isActivePath(pathname: string, href: string): boolean { + return pathname === href || pathname.startsWith(`${href}/`); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d02ba96d..93a7cb80 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,7 +12,9 @@ import { _, setupI18n } from '#lib/i18n/index.js'; import { setTimezone, setQuoteServiceEnabled } from '#lib/stores/timezone.js'; import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY, CUSTOM_THEME_KEY } from '#lib/stores/theme.js'; - import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, CloudDownload } from '@lucide/svelte'; + import { sidebarCollapsed, loadSidebarFromStorage, toggleSidebar, SIDEBAR_COLLAPSED_KEY } from '#lib/stores/sidebar.js'; + import { isActivePath } from '#lib/utils/nav.js'; + import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, PanelLeftClose, PanelLeftOpen } from '@lucide/svelte'; import { version } from '#lib/version.js'; import VersionLink from '#lib/components/VersionLink.svelte'; import { checkForUpdate, type UpdateInfo } from '#lib/stores/updateCheck.js'; @@ -28,6 +30,7 @@ if (typeof window !== 'undefined') { loadThemeFromStorage(); applyThemeToDocument(); + loadSidebarFromStorage(); } let addBookOpen = $state(false); @@ -62,6 +65,7 @@ } onMount(async () => { + window.addEventListener('storage', handleSidebarStorage); initAuthSync(() => { currentUser.set(null); csrfToken.set(null); @@ -183,7 +187,10 @@ }); }); - onDestroy(() => clearInterval(versionInterval)); + onDestroy(() => { + window.removeEventListener('storage', handleSidebarStorage); + clearInterval(versionInterval); + }); const NAV_ITEMS = $derived.by(() => { const items = [ @@ -198,6 +205,12 @@ return items; }); + function handleSidebarStorage(event: StorageEvent): void { + if (event.key === SIDEBAR_COLLAPSED_KEY) { + loadSidebarFromStorage(); + } + } + function pageTitle() { if (!i18nReady) return 'LibrisLog'; @@ -281,22 +294,52 @@ -