Compare commits

...

3 Commits

Author SHA1 Message Date
root
faa4c42f20 fix: add missing Paraglide compiled message files for new themes + aria-label
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m39s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 2m53s
Release / Upload source maps (push) Successful in 1m27s
Release / Docker / ui (push) Successful in 2m53s
Release / Docker / caddy (push) Successful in 48s
Release / Gitea Release (push) Successful in 36s
CI svelte-check failed because Paraglide generates per-key .js files that
must be committed — the compiler runs at build time in CI (svelte-kit sync)
but the output directory is tracked in git, so new keys added to messages/*.json
require the corresponding generated files to be committed manually when node
is not available locally.

Added:
- messages/profile_theme_forest.js
- messages/profile_theme_mono.js
- messages/profile_theme_cyber.js
- messages/_index.js: three new export lines

Also fixed a11y warn: added aria-label='Close history' to the icon-only
close button in the discover page history drawer.
2026-04-07 11:40:38 +05:00
root
17fa913ba9 feat: add Forest, Mono, and Cyberpunk color themes
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 31s
Release / Upload source maps (push) Has been skipped
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m29s
Release / Docker / runner (push) Successful in 2m39s
Release / Gitea Release (push) Has been skipped
Forest — deep green surfaces (#0a130d base) with green-400 accent.
  Inspired by terminal emulators and dark mode IDE forest schemes.

Mono — near-black zinc-950 surface with pure white/zinc-100 accent.
  Minimal high-contrast monochromatic palette for distraction-free reading.

Cyber — near-black blue surface with neon cyan-400 accent and dracula-palette
  success/danger colors. Cyberpunk aesthetic with neon glow feel.

Changes:
- app.css: three new [data-theme] blocks with full token sets
- +layout.svelte: added forest/mono/cyber to THEMES array (before light themes)
- profile/+page.svelte: same additions with i18n label functions
- messages/*.json: translated names in all 5 locales (en/fr/ru/pt/id)
2026-04-07 11:27:30 +05:00
root
95f45a5f13 fix: chapter list modal full-screen clip + add clear close button
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 2m2s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 3m31s
Release / Docker / runner (push) Successful in 3m33s
Release / Upload source maps (push) Successful in 1m37s
Release / Docker / ui (push) Successful in 5m16s
Release / Gitea Release (push) Successful in 44s
Root cause: the chapter picker overlay (fixed inset-0) was rendered as a
descendant of the AudioPlayer's wrapping div, which is itself inside a
'rounded-b-lg overflow-hidden' container on the chapter page. Chrome clips
position:fixed descendants when a parent has overflow:hidden + border-radius,
so the overlay was constrained to the parent's bounding box instead of
covering the full viewport.

Fix: moved the {#if showChapterPanel} block from inside the standard player
div to a top-level sibling at the end of the component template. Svelte
components support multiple root nodes, so the overlay is now a sibling of
all player containers — no ancestor overflow-hidden can clip it.

Also replaced the subtle chevron-down close button with a clear X (×) button
in the top-right of the header, making it obvious how to dismiss the panel.
2026-04-07 11:00:58 +05:00
14 changed files with 276 additions and 72 deletions

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Slate",
"profile_theme_rose": "Rose",
"profile_theme_forest": "Forest",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Ambre",
"profile_theme_slate": "Ardoise",
"profile_theme_rose": "Rose",
"profile_theme_forest": "Forêt",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Amber",
"profile_theme_slate": "Abu-abu",
"profile_theme_rose": "Mawar",
"profile_theme_forest": "Hutan",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Âmbar",
"profile_theme_slate": "Ardósia",
"profile_theme_rose": "Rosa",
"profile_theme_forest": "Floresta",
"profile_theme_mono": "Mono",
"profile_theme_cyber": "Cyberpunk",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -161,6 +161,9 @@
"profile_theme_amber": "Янтарь",
"profile_theme_slate": "Сланец",
"profile_theme_rose": "Роза",
"profile_theme_forest": "Лес",
"profile_theme_mono": "Моно",
"profile_theme_cyber": "Киберпанк",
"profile_theme_light": "Light",
"profile_theme_light_slate": "Light Blue",
"profile_theme_light_rose": "Light Rose",

View File

@@ -97,6 +97,48 @@
--color-success: #16a34a; /* green-600 */
}
/* ── Forest theme — dark green ────────────────────────────────────────── */
[data-theme="forest"] {
--color-brand: #4ade80; /* green-400 */
--color-brand-dim: #16a34a; /* green-600 */
--color-surface: #0a130d; /* custom near-black green */
--color-surface-2: #111c14; /* custom dark green */
--color-surface-3: #1a2e1e; /* custom mid green */
--color-muted: #6b9a77; /* custom muted green */
--color-text: #e8f5e9; /* custom light green-tinted white */
--color-border: #1e3a24; /* custom green border */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Mono theme — pure dark with white accent ─────────────────────────── */
[data-theme="mono"] {
--color-brand: #f4f4f5; /* zinc-100 — white accent */
--color-brand-dim: #a1a1aa; /* zinc-400 */
--color-surface: #09090b; /* zinc-950 */
--color-surface-2: #18181b; /* zinc-900 */
--color-surface-3: #27272a; /* zinc-800 */
--color-muted: #71717a; /* zinc-500 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #27272a; /* zinc-800 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Cyberpunk theme — dark with neon cyan/magenta accents ────────────── */
[data-theme="cyber"] {
--color-brand: #22d3ee; /* cyan-400 — neon cyan */
--color-brand-dim: #06b6d4; /* cyan-500 */
--color-surface: #050712; /* custom near-black blue */
--color-surface-2: #0d1117; /* custom dark blue-black */
--color-surface-3: #161b27; /* custom dark blue */
--color-muted: #6272a4; /* dracula comment blue */
--color-text: #e2e8f0; /* slate-200 */
--color-border: #1e2d45; /* custom dark border */
--color-danger: #ff5555; /* dracula red */
--color-success: #50fa7b; /* dracula green */
}
html {
background-color: var(--color-surface);
color: var(--color-text);

View File

@@ -1166,78 +1166,7 @@
</div>
{/if}
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->
@@ -1318,3 +1247,79 @@
{/if}
</div>
{/if}
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
Rendered as a top-level sibling (outside all player containers) so that
the fixed inset-0 positioning is never clipped by overflow-hidden or
border-radius on any ancestor wrapping the AudioPlayer component. -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}

View File

@@ -150,6 +150,9 @@ export * from './profile_theme_label.js'
export * from './profile_theme_amber.js'
export * from './profile_theme_slate.js'
export * from './profile_theme_rose.js'
export * from './profile_theme_forest.js'
export * from './profile_theme_mono.js'
export * from './profile_theme_cyber.js'
export * from './profile_theme_light.js'
export * from './profile_theme_light_slate.js'
export * from './profile_theme_light_rose.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_CyberInputs */
const en_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const ru_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Киберпанк`)
};
const id_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const pt_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
const fr_profile_theme_cyber = /** @type {(inputs: Profile_Theme_CyberInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cyberpunk`)
};
/**
* | output |
* | --- |
* | "Cyberpunk" |
*
* @param {Profile_Theme_CyberInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_CyberInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_cyber(inputs)
if (locale === "ru") return ru_profile_theme_cyber(inputs)
if (locale === "id") return id_profile_theme_cyber(inputs)
if (locale === "pt") return pt_profile_theme_cyber(inputs)
return fr_profile_theme_cyber(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_ForestInputs */
const en_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Forest`)
};
const ru_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лес`)
};
const id_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Hutan`)
};
const pt_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Floresta`)
};
const fr_profile_theme_forest = /** @type {(inputs: Profile_Theme_ForestInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Forêt`)
};
/**
* | output |
* | --- |
* | "Forest" |
*
* @param {Profile_Theme_ForestInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_ForestInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_forest(inputs)
if (locale === "ru") return ru_profile_theme_forest(inputs)
if (locale === "id") return id_profile_theme_forest(inputs)
if (locale === "pt") return pt_profile_theme_forest(inputs)
return fr_profile_theme_forest(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Profile_Theme_MonoInputs */
const en_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const ru_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Моно`)
};
const id_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const pt_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
const fr_profile_theme_mono = /** @type {(inputs: Profile_Theme_MonoInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mono`)
};
/**
* | output |
* | --- |
* | "Mono" |
*
* @param {Profile_Theme_MonoInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Profile_Theme_MonoInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_profile_theme_mono(inputs)
if (locale === "ru") return ru_profile_theme_mono(inputs)
if (locale === "id") return id_profile_theme_mono(inputs)
if (locale === "pt") return pt_profile_theme_mono(inputs)
return fr_profile_theme_mono(inputs)
});

View File

@@ -38,6 +38,9 @@
{ id: 'amber', color: '#f59e0b' },
{ id: 'slate', color: '#818cf8' },
{ id: 'rose', color: '#fb7185' },
{ id: 'forest', color: '#4ade80' },
{ id: 'mono', color: '#f4f4f5' },
{ id: 'cyber', color: '#22d3ee' },
{ id: 'light', color: '#d97706', light: true },
{ id: 'light-slate', color: '#4f46e5', light: true },
{ id: 'light-rose', color: '#e11d48', light: true },

View File

@@ -458,6 +458,7 @@
<button
type="button"
onclick={() => (showHistory = false)}
aria-label="Close history"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -145,6 +145,9 @@
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
{ id: 'forest', label: () => m.profile_theme_forest(), swatch: '#4ade80' },
{ id: 'mono', label: () => m.profile_theme_mono(), swatch: '#f4f4f5' },
{ id: 'cyber', label: () => m.profile_theme_cyber(), swatch: '#22d3ee' },
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },