Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/using-librislog/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/guide/using-librislog/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 26 additions & 2 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/

## Latest Release

::: tip ⭐ v1.12.1 - DST Chart & Timezone Fixes
LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's reading progress chart crossed a daylight-saving fall-back in the user's profile time zone, the chart's time axis could loop forever and lock up the page. Time steps now keep advancing across the transition, day, week, and month ticks stay on their local calendar boundaries, and an explicitly chosen UTC timezone is no longer replaced with the detected zone on the next login. The timezone card also has a **Use detected timezone** button to switch back to automatic detection. The new timezone flag is added by an automatic database migration.
::: tip ⭐ v1.13.0 - Catppuccin Themes, Theme Picker & Collapsible Sidebar
LibrisLog v1.13.0 adds the four Catppuccin color flavors (Latte, Frappé, Macchiato, Mocha) and replaces the theme dropdown with a swatch grid that previews every theme in place. The desktop sidebar can now be collapsed to an icon rail for smaller screens, and the current page is highlighted in both the sidebar and the mobile tab bar. Themes persist per user, the sidebar choice per browser. No database migration is needed.
:::

### All releases

| Version | Date | Type |
|---|---|---|
| [v1.13.0](#v1-13-0-catppuccin-themes-picker-and-collapsible-sidebar) | 2026-11-05 | 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 |
Expand All @@ -39,6 +40,29 @@ LibrisLog v1.12.1 fixes a browser freeze in the book detail view. When a book's

---

## v1.13.0: Catppuccin Themes, Picker and Collapsible Sidebar

<Badge type="tip" text="Feature release" /> <Badge type="info" text="2026-11-05" />

**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
- ☀️ **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...v1.13.0)

---

## v1.12.1: DST Chart & Timezone Fixes

<Badge type="info" text="Maintenance" /> <Badge type="info" text="2026-11-03" />
Expand Down
39 changes: 39 additions & 0 deletions frontend/e2e/specs/02-dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
74 changes: 73 additions & 1 deletion frontend/e2e/specs/11-profile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/lib/components/ThemeSwatchPicker.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script lang="ts">
import { Check } from '@lucide/svelte';

import { themeLabel } from '#lib/stores/theme.js';

interface ThemeGroup {
label: string;
themes: readonly string[];
}

let {
selected,
groups,
onSelect,
name = 'custom-theme',
labelledBy
}: {
selected: string | null;
groups: ThemeGroup[];
onSelect?: (theme: string) => void;
name?: string;
labelledBy?: string;
} = $props();

function headingId(label: string): string {
return `theme-group-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
}
</script>

<div
class="max-w-3xl p-1 sm:max-h-80 sm:overflow-y-auto"
role="radiogroup"
aria-labelledby={labelledBy}
>
<div class="flex flex-col gap-3">
{#each groups as group (group.label)}
<div
class="flex flex-col gap-1.5"
role="group"
aria-labelledby={headingId(group.label)}
data-theme-group={group.label}
>
<p id={headingId(group.label)} class="text-xs font-semibold tracking-wide text-base-content/60 uppercase">
{group.label}
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{#each group.themes as theme (theme)}
{@const label = themeLabel(theme)}
{@const shortLabel = label.startsWith(`${group.label} `)
? label.slice(group.label.length + 1)
: label}
<label
class="group relative flex cursor-pointer flex-col gap-1.5 rounded-xl border p-2 transition focus-within:ring-2 focus-within:ring-primary/60 {selected ===
theme
? 'border-primary bg-primary/5 ring-2 ring-primary/30'
: 'border-base-300 hover:border-primary/50'}"
data-theme-option={theme}
>
<input
type="radio"
{name}
value={theme}
checked={selected === theme}
class="sr-only"
onchange={() => onSelect?.(theme)}
/>
<span
class="pointer-events-none flex h-9 gap-1 rounded-md border border-base-300 p-1"
data-theme={theme}
aria-hidden="true"
>
<span class="flex flex-1 flex-col justify-center gap-1 rounded bg-base-100 px-1">
<span class="h-1 w-3/4 rounded-full bg-base-content/70"></span>
<span class="h-1 w-1/2 rounded-full bg-base-content/40"></span>
</span>
<span class="flex w-5 flex-col gap-1">
<span class="flex-1 rounded bg-primary"></span>
<span class="flex-1 rounded bg-secondary"></span>
</span>
<span class="w-1.5 rounded bg-accent"></span>
</span>
<span class="flex items-center justify-between gap-1">
<span
class="min-w-0 truncate text-[11px] leading-tight {selected === theme
? 'font-semibold text-base-content'
: 'text-base-content/80'}">{shortLabel}</span
>
{#if selected === theme}
<Check class="h-3.5 w-3.5 shrink-0 text-primary" />
{/if}
</span>
</label>
{/each}
</div>
</div>
{/each}
</div>
</div>
Loading
Loading