Hell und Dunkel (docs/theme.md), Vorlese-Schnittstelle für die Playerleiste; sw v102
Some checks failed
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Has been cancelled

- Farbvariablen für beide Schemata in app.css (--bg --fg --muted --card --panel
  --hover --raised --border --border-strong --accent --on-accent --scrim
  --shadow), Hell als html[data-theme=light]. Umschalter im Kopf jeder Seite
  (base.html, vor dem ersten Zeichnen): Dunkel / Hell / Automatisch, ☾☀◐ in der
  Navigation; __dioraTheme / __dioraSetTheme / Ereignis diora-theme.
- Reader: neues Standard-Theme "Wie die App" (Dunkel/Hell folgt der App).
- ◑ (Kontrast zum Hintergrundbild) nur noch mit Hintergrundbild.
- home.js auf die Variablen.
- window.__dioraTts (state/toggle/stop/next/prev) und Ereignis diora-tts für
  die Playerleiste; mit __dioraTtsDock keine eigene Vorlese-Leiste mehr.
- Reader-Hinweise über der Playerleiste, wenn sie im Reader steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-14 18:02:33 +02:00
parent 080cdbde5a
commit 884a18e8ef
6 changed files with 298 additions and 52 deletions

44
docs/theme.md Normal file
View file

@ -0,0 +1,44 @@
# Light and dark
diora has two colour schemes, **dark** (the old look) and **light**, chosen by the reader:
"Dunkel", "Hell" or "Automatisch" (as the operating system says). This file is the contract
between diora-web (app.css, home.js, templates), diora-android (native.css, native-*.js,
podcast-ui.css, feeds.js) and diora-desktop (desktop.css).
## Switching
* `<html data-theme="dark|light">` is the scheme in effect. Set as early as possible
(an inline script in base.html's `<head>`, before any CSS paints) so nothing flashes.
* The choice is `localStorage.diora_theme` = `"dark"` (default) | `"light"` | `"auto"`.
* `window.__dioraTheme()` → the choice; `window.__dioraSetTheme(choice)` sets and applies it;
`window.__dioraThemeChanged` (event `diora-theme` on `window`, `detail` = the scheme in
effect) lets native code follow (Android status/navigation bars, Electron's window colour).
* "auto" follows `prefers-color-scheme` live.
* E-ink mode (`html.nui-eink`) keeps its own black-on-white rules and ignores this.
* The reader has its own themes; its default is now "Wie die App" (`readerSettings.theme = 'app'`):
dark → reader dark, light → reader bright. Sepia, Dunkel and Hell stay choosable.
* A wallpaper (the radio page's background image) keeps its own contrast handling
(`body.bright-bg`, the ◑ button); without a wallpaper the scheme decides.
## Colours: only through these variables
| variable | dark | light | for |
|---|---|---|---|
| `--bg` | `#000` | `#f6f5f1` | the page |
| `--fg` | `#fff` | `#17171a` | text |
| `--muted` | `#9a9a9a` | `#66666b` | secondary text, icons |
| `--card` | `#0b0b0b` | `#ffffff` | cards, rows that stand out |
| `--panel` | `#111` | `#ffffff` | sheets, menus, docks, bars |
| `--hover` | `#141414` | `#ecebe6` | hover / pressed |
| `--raised` | `#1a1a1a` | `#e4e2dc` | inputs, tracks of sliders, chips |
| `--border` | `#2a2a2a` | `#d9d6ce` | lines |
| `--border-strong` | `#444` | `#b4b0a6` | lines that must show (inputs, focus off) |
| `--accent` | `#e63946` | `#d0263a` | the red: brand, active, progress |
| `--on-accent` | `#fff` | `#fff` | text on the red |
| `--scrim` | `rgba(0,0,0,.6)` | `rgba(20,20,20,.35)` | behind sheets |
| `--shadow` | `none` | `0 1px 3px rgba(0,0,0,.08)` | cards in light (no lines needed in dark) |
Old names stay as aliases: `--nui-muted: var(--muted)`, `--nui-panel: var(--panel)`,
`--text: var(--fg)`, `--text-muted: var(--muted)`, `--bg-card: var(--card)`.
Images, album art, covers and Ambient's own picture keep their colours. Status colours
(`--green`, `--yellow`, `--red`) may stay as they are.

View file

@ -47,13 +47,13 @@
html, body {
height: 100%;
overflow: hidden;
scrollbar-color: #333 #000;
scrollbar-color: var(--raised) var(--bg);
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #000; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--raised); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
:root {
--bg: #000;
@ -77,6 +77,42 @@ html, body {
--outline: #000;
--text: var(--fg);
--text-muted: var(--fg);
/* The colour scheme (docs/theme.md): dark here, light below. Use only these. */
--muted: #9a9a9a;
--card: #0b0b0b;
--panel: #111;
--hover: #141414;
--raised: #1a1a1a;
--border-strong: #444;
--on-accent: #fff;
--scrim: rgba(0, 0, 0, 0.6);
--shadow: none;
--surface: var(--panel);
color-scheme: dark;
}
html[data-theme="light"] {
--bg: #f6f5f1;
--fg: #17171a;
--fg-muted: #17171a;
--muted: #66666b;
--card: #fff;
--panel: #fff;
--hover: #ecebe6;
--raised: #e4e2dc;
--border: #d9d6ce;
--border-strong: #b4b0a6;
--accent: #d0263a;
--accent-hover: #b51f31;
--on-accent: #fff;
--scrim: rgba(20, 20, 20, 0.35);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--bg-card: var(--card);
--bg-alt: var(--hover);
/* Outlined text is for wallpapers (body.bright-bg / a dark picture); on plain light, none. */
--outline: transparent;
color-scheme: light;
}
/* inverted: black on white */
@ -395,7 +431,7 @@ a:hover {
width: 100%;
height: 70vh;
border: 0;
background: #000;
background: var(--bg);
}
.sub-tabs {
@ -477,7 +513,7 @@ a:hover {
.data-table tbody tr:nth-child(odd) { background: transparent; }
.data-table tbody tr:nth-child(even) { background: transparent; }
.data-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); }
.data-table tbody tr:hover { background: var(--hover); }
.empty-msg {
color: var(--text-muted);
@ -508,14 +544,14 @@ a:hover {
white-space: nowrap;
}
.btn:hover {
background: rgba(255, 255, 255, 0.07);
border-color: #444;
background: var(--hover);
border-color: var(--border-strong);
}
.btn-play {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--on-accent);
font-size: 0.9rem;
padding: 0.4rem 1rem;
}
@ -524,14 +560,14 @@ a:hover {
border-color: var(--accent-hover);
}
.btn-play.playing {
background: #333;
border-color: #555;
background: var(--raised);
border-color: var(--border-strong);
color: var(--text);
}
.btn-save {
color: var(--yellow);
border-color: #444;
border-color: var(--border-strong);
}
.btn-save:hover {
background: #2a2a00;
@ -546,7 +582,7 @@ a:hover {
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--on-accent);
}
.btn-primary:hover {
background: var(--accent-hover);
@ -562,6 +598,9 @@ a:hover {
border-color: #a00;
color: #fff;
}
html[data-theme="light"] .btn-danger { background: #fdecee; border-color: #e7a3aa; color: #a3172a; }
html[data-theme="light"] .btn-danger:hover { background: #a3172a; border-color: #a3172a; color: #fff; }
html[data-theme="light"] .btn-save:hover { background: #fbf3d3; }
.btn-lastfm {
background: #d51007;
@ -688,6 +727,7 @@ a:hover {
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
}
html[data-theme="light"] .form-errors { background: #fdecee; border-color: #e7a3aa; }
.field-help {
color: var(--text-muted);
@ -929,7 +969,7 @@ a:hover {
.recommendations-section {
padding: 12px 16px 4px;
border-bottom: 1px solid #222;
border-bottom: 1px solid var(--border);
}
.recommendations-context {
font-size: 0.75rem;
@ -1009,7 +1049,7 @@ a:hover {
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid #1a1a1a;
border-bottom: 1px solid var(--border);
}
/* =========================================================
@ -1108,13 +1148,13 @@ body.dnd-mode .timer-display {
flex-wrap: wrap;
gap: 6px;
padding: 8px 16px;
border-bottom: 1px solid #1a1a1a;
border-bottom: 1px solid var(--border);
}
.mood-chip {
background: transparent;
border: 1px solid #333;
color: #ccc;
border: 1px solid var(--border);
color: var(--fg);
border-radius: 999px;
padding: 3px 12px;
font-size: 0.78rem;
@ -1123,8 +1163,8 @@ body.dnd-mode .timer-display {
}
.mood-chip:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #555;
background: var(--hover);
border-color: var(--border-strong);
}
.focus-today {
@ -1274,7 +1314,7 @@ body.dnd-mode .timer-display {
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
background: var(--scrim);
z-index: 299;
}
@ -1334,7 +1374,7 @@ body.dnd-mode .timer-display {
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
background: var(--scrim);
z-index: 500;
}
@ -1417,7 +1457,7 @@ body.dnd-mode .timer-display {
.book-drop-zone:hover,
.book-drop-zone.drag-over {
border-color: var(--accent);
background: rgba(255,255,255,0.04);
background: var(--hover);
}
.book-drop-zone label {
color: var(--accent);
@ -1791,13 +1831,13 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 20, 20, 0.85);
background: var(--scrim);
z-index: 200;
}
.pdf-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #333;
border: 3px solid var(--raised);
border-top-color: var(--accent, #e63946);
border-radius: 50%;
animation: pdf-spin 0.7s linear infinite;
@ -1814,7 +1854,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
/* Highlight popover */
.highlight-popover { position:fixed; z-index:500; display:flex; gap:6px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:6px 8px; box-shadow:0 4px 16px rgba(0,0,0,.5); }
.hl-color-btn { width:24px; height:24px; border-radius:50%; border:2px solid transparent; cursor:pointer; font-weight:700; font-size:12px; color:#000; line-height:1; }
.hl-color-btn:hover { border-color:#fff; }
.hl-color-btn:hover { border-color:var(--fg); }
.hl-note-btn { background:none; border:1px solid var(--border); color:var(--fg); padding:2px 6px; border-radius:var(--radius); cursor:pointer; }
/* Footnote popover */
@ -1908,7 +1948,7 @@ body.reader-immersive.reader-show-bottom .reader-overlay { bottom: var(--bar-h)
here. */
.note-bottom-sheet { position:fixed; inset:0; z-index:650; }
.note-bottom-sheet-backdrop {
position:absolute; inset:0; background:rgba(0,0,0,.5);
position:absolute; inset:0; background:var(--scrim);
opacity:0; transition:opacity .2s ease; pointer-events:none;
}
.note-bottom-sheet.open .note-bottom-sheet-backdrop { opacity:1; pointer-events:auto; }
@ -1951,7 +1991,9 @@ mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius:
@keyframes toast-fade { 0%,70%{opacity:1} 100%{opacity:0} }
.reader-toast-action { display:flex; align-items:center; gap:10px; padding:8px 8px 8px 14px; animation:toast-slide-up 0.2s ease forwards; pointer-events:auto; }
.reader-toast-btn { background:var(--accent,#e63946); color:#fff; border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
/* The apps: the player bar sits in the reader, above its footer; notices go above both. */
html.nui-reading .reader-toast { bottom:calc(var(--nui-dock-h, 0px) + 40px); }
.reader-toast-btn { background:var(--accent,#e63946); color:var(--on-accent); border:none; border-radius:calc(var(--radius) - 2px); padding:5px 12px; font-family:var(--font); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap; }
.reader-toast-btn:hover { filter:brightness(1.1); }
@keyframes toast-slide-up { 0%{opacity:0; transform:translate(-50%, 8px);} 100%{opacity:1; transform:translate(-50%, 0);} }
@ -1960,7 +2002,7 @@ mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius:
bottom: 4px;
right: 6px;
font-size: 0.65rem;
color: #444;
color: var(--border-strong);
pointer-events: none;
z-index: 1;
}

View file

@ -1629,6 +1629,12 @@ function applyAccent(entry) {
root.style.setProperty('--accent-hover', entry.hover);
}
// Light and dark: the switch is in base.html (every page has it). The reader's
// "Wie die App" follows it.
window.addEventListener('diora-theme', () => {
if (currentBookId) applyReaderSettings(!!currentPdfDoc);
});
function setScheme(bright) {
document.body.classList.toggle('bright-bg', bright);
const btn = document.getElementById('contrast-toggle');
@ -1736,6 +1742,8 @@ async function applyEncryptedBackground() {
const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'});
const url = URL.createObjectURL(blob);
document.body.style.backgroundImage = `url('${url}')`;
const contrastBtn = document.getElementById('contrast-toggle');
if (contrastBtn) contrastBtn.hidden = false; // only with a picture behind the page
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundAttachment = 'fixed';
@ -3770,6 +3778,8 @@ let ttsAudio = null;
let ttsCurrentMark = null;
let ttsBarEl = null;
let ttsLang = localStorage.getItem('diora_tts_lang') || 'de';
let _ttsBlocks = null; // the book's blocks while reading aloud
let _ttsCur = null; // what's being read: {bi, si, sentence, block}
function setTtsLang(lang) {
ttsLang = lang;
@ -3862,15 +3872,19 @@ function _ttsPlayBlob(blob) {
});
}
// Walks blocks/sentences from startBlockIndex onward, fetching one sentence
// ahead while the current one plays so there's no gap between them.
async function _ttsPlayLoop(blocks, startBlockIndex, runToken) {
// Walks blocks/sentences from startBlockIndex (and its sentence startSentence)
// onward, fetching one sentence ahead while the current one plays so there's
// no gap between them.
async function _ttsPlayLoop(blocks, startBlockIndex, runToken, startSentence = 0) {
function* sentenceStream() {
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
const block = blocks[bi];
const text = (block.textContent || '').trim();
if (!text) continue;
for (const sentence of _ttsSplitSentences(text)) yield {block, sentence};
const sentences = _ttsSplitSentences(text);
for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) {
yield {block, sentence: sentences[si], bi, si};
}
}
}
@ -3896,8 +3910,14 @@ async function _ttsPlayLoop(blocks, startBlockIndex, runToken) {
}
if (runToken !== ttsRunToken) return;
if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; }
if (block !== lastBlock) {
lastBlock = block;
// Started in the middle of a block (previous/next sentence): find it from there.
blockSearchOffset = _ttsOffsetOf(block, cur.value.si);
}
_ttsCur = {bi: cur.value.bi, si: cur.value.si, sentence, block};
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
_ttsNotify();
await _ttsPlayBlob(audioBlob);
if (runToken !== ttsRunToken) return;
@ -3915,8 +3935,90 @@ function _ttsTogglePause() {
else ttsAudio.play().catch(() => {});
}
_ttsUpdateBar();
_ttsNotify();
}
// ---- read-aloud for a player bar (the shared dock in native-podcast.js) ----------
// window.__dioraTts: state(), toggle(), stop(), next(), prev(); a 'diora-tts'
// event on window after every change. A dock that shows it sets
// window.__dioraTtsDock = true, and the reader's own little bar stays away.
function _ttsState() {
const meta = (currentBookId && bookMetaCache[currentBookId]) || {};
const cur = _ttsCur;
const chapterEl = cur && cur.block.closest('[data-epub-src]');
const blocks = _ttsBlocks || [];
return {
active: ttsActive,
paused: ttsPaused,
bookId: currentBookId,
title: meta.title || '',
author: meta.author || '',
chapter: chapterEl ? _tocTitleForChapterSrc(chapterEl.dataset.epubSrc) : '',
sentence: cur ? cur.sentence : '',
progress: cur && blocks.length ? cur.bi / blocks.length : 0,
};
}
function _ttsNotify() {
try { window.dispatchEvent(new CustomEvent('diora-tts', {detail: _ttsState()})); } catch (e) {}
}
// Read from another sentence on: the one before/after the current one.
function _ttsRestartAt(bi, si) {
const blocks = _ttsBlocks;
if (!ttsActive || !blocks) return;
ttsRunToken++;
const runToken = ttsRunToken;
if (ttsAudio) {
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
ttsAudio = null;
}
_ttsPlayLoop(blocks, bi, runToken, si);
}
// Where sentence si of a block starts in its text (for the highlight).
function _ttsOffsetOf(block, si) {
const full = block.textContent || '';
let off = 0;
_ttsSplitSentences(full.trim()).slice(0, si).forEach(s => {
const k = full.indexOf(s, off);
if (k >= 0) off = k + s.length;
});
return off;
}
function _ttsSentenceCount(block) {
const text = (block.textContent || '').trim();
return text ? _ttsSplitSentences(text).length : 0;
}
function _ttsStep(dir) {
const cur = _ttsCur;
const blocks = _ttsBlocks;
if (!cur || !blocks) return;
if (dir > 0) {
if (cur.si + 1 < _ttsSentenceCount(cur.block)) return _ttsRestartAt(cur.bi, cur.si + 1);
for (let bi = cur.bi + 1; bi < blocks.length; bi++) if (_ttsSentenceCount(blocks[bi])) return _ttsRestartAt(bi, 0);
stopReadAloud();
} else {
if (cur.si > 0) return _ttsRestartAt(cur.bi, cur.si - 1);
for (let bi = cur.bi - 1; bi >= 0; bi--) {
const n = _ttsSentenceCount(blocks[bi]);
if (n) return _ttsRestartAt(bi, n - 1);
}
_ttsRestartAt(cur.bi, 0);
}
}
window.__dioraTts = {
state: _ttsState,
toggle: () => (ttsActive ? _ttsTogglePause() : startReadAloud()),
stop: () => stopReadAloud(),
next: () => _ttsStep(1),
prev: () => _ttsStep(-1),
};
function _ttsUpdateBar() {
if (!ttsBarEl) return;
const btn = ttsBarEl.querySelector('.tts-pause-btn');
@ -3925,6 +4027,7 @@ function _ttsUpdateBar() {
function _ttsShowBar() {
_ttsRemoveBar();
if (window.__dioraTtsDock) return; // the player bar shows it
const bar = document.createElement('div');
bar.className = 'tts-bar';
bar.innerHTML = `
@ -3968,10 +4071,13 @@ function startReadAloud() {
ttsPaused = false;
ttsRunToken++;
const runToken = ttsRunToken;
_ttsBlocks = blocks;
_ttsCur = null;
const btn = $('reader-tts-btn');
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
_ttsShowBar();
_ttsNotify();
_ttsPlayLoop(blocks, startIndex, runToken);
}
@ -3986,8 +4092,11 @@ function stopReadAloud() {
}
_ttsClearHighlight();
_ttsRemoveBar();
_ttsBlocks = null;
_ttsCur = null;
const btn = $('reader-tts-btn');
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
_ttsNotify();
}
// ---------------------------------------------------------------------------
@ -4647,7 +4756,7 @@ function _readerTypeDefaults() {
function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { theme: 'dark', fontFamily: 'serif', noBold: false,
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false },
_readerTypeDefaults());
try {
@ -4659,6 +4768,8 @@ function loadReaderSettings(bookId) {
if (saved[key] === old) delete saved[key];
}
}
// Before v3 the reader's theme was always stored, "dark" by default: that now means "Wie die App".
if ((saved.v || 0) < 3 && saved.theme === 'dark') delete saved.theme;
delete saved.v;
Object.assign(readerSettings, saved);
if (saved.pdfPaginated === undefined) {
@ -4669,7 +4780,7 @@ function loadReaderSettings(bookId) {
function saveReaderSettings() {
if (currentBookId) {
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 2 }));
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 3 }));
}
}
@ -4708,8 +4819,11 @@ function applyReaderSettings(isPdf) {
// Theme
overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright');
if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia');
else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright');
const readerTheme = readerSettings.theme === 'app'
? (document.documentElement.getAttribute('data-theme') === 'light' ? 'bright' : 'dark')
: readerSettings.theme;
if (readerTheme === 'sepia') overlay.classList.add('reader-theme-sepia');
else if (readerTheme === 'bright') overlay.classList.add('reader-theme-bright');
// PDF invert
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
@ -4767,6 +4881,7 @@ function toggleSettingsPanel() {
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'mono' ? 'active' : ''}" data-rs-font="mono">Mono</button>
<button class="btn btn-sm ${readerSettings.noBold ? 'active' : ''}" id="rs-no-bold">Kein Fett</button>
<button class="btn btn-sm ${readerSettings.theme === 'app' ? 'active' : ''}" data-rs-theme="app">Wie die App</button>
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>

View file

@ -331,27 +331,27 @@
'#tab-home .home-brand { font-size: 26px; font-weight: 700; color: var(--accent, #e63946); margin: 4px 0 18px; letter-spacing: 0.05em; }',
'#tab-home .home-section { margin-bottom: 22px; }',
'#tab-home .home-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 8px; }',
'#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted, #9a9a9a); }',
'#tab-home .home-more { background: none; border: none; color: var(--nui-muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }',
'#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; text-align: left; }',
'#tab-home .home-head h2 { margin: 0; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
'#tab-home .home-more { background: none; border: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 12px; cursor: pointer; padding: 4px 0; }',
'#tab-home .home-card { display: flex; align-items: center; gap: 12px; width: 100%; box-sizing: border-box; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; text-align: left; }',
'#tab-home button.home-card, #tab-home .home-pod-text, #tab-home .home-row, #tab-home .home-station { cursor: pointer; }',
'#tab-home .home-main { flex-wrap: nowrap; }',
'#tab-home button.home-main { flex-direction: column; align-items: flex-start; gap: 3px; }',
'#tab-home .home-empty { color: var(--nui-muted, #9a9a9a); justify-content: space-between; }',
'#tab-home .home-empty { color: var(--muted, #9a9a9a); justify-content: space-between; }',
'#tab-home .home-kicker { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent, #e63946); }',
'#tab-home .home-title { font-size: 16px; font-weight: 700; line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }',
'#tab-home .home-sub { font-size: 13px; color: var(--nui-muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }',
'#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--nui-muted, #9a9a9a); }',
'#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; }',
'#tab-home .home-sub { font-size: 13px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }',
'#tab-home .home-state { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#tab-home .home-bar { display: inline-block; width: 160px; max-width: 40vw; height: 4px; border-radius: 2px; background: var(--raised, #2a2a2a); overflow: hidden; }',
'#tab-home .home-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#tab-home .home-pod-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }',
'#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: #1a1a1a; }',
'#tab-home .home-art { width: 56px; height: 56px; flex: none; border-radius: 4px; object-fit: cover; background: var(--raised, #1a1a1a); }',
'#tab-home .home-amb-icon { width: 56px; height: 56px; flex: none; border-radius: 4px; background: #14102a; color: #b9a7ff; display: inline-flex; align-items: center; justify-content: center; font-size: 26px; }',
'#tab-home .home-play { flex: none; min-height: 36px; padding: 0 14px; }',
'#tab-home .home-list { margin-top: 6px; }',
'#tab-home .home-row { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 10px 14px; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-row-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-row-side { flex: none; color: var(--nui-muted, #9a9a9a); font-size: 12px; }',
'#tab-home .home-row-side { flex: none; color: var(--muted, #9a9a9a); font-size: 12px; }',
'#tab-home .home-news { flex-direction: column; align-items: stretch; gap: 0; padding: 4px 0; }',
'#tab-home .home-story { align-items: baseline; }',
'#tab-home .home-news .home-story:last-child { border-bottom: none; }',
@ -360,12 +360,12 @@
'#tab-home .home-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }',
'#tab-home .home-chip { padding: 6px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 16px; background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-home .home-stations { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }',
'#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: #0b0b0b; color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-station { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 8px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); color: inherit; font: inherit; font-size: 14px; text-align: left; }',
'#tab-home .home-station-icon { color: var(--accent, #e63946); flex: none; }',
'#tab-home .home-station-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-home .home-on { border-color: var(--accent, #e63946); }',
'#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--nui-muted, #9a9a9a); }',
'@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: #141414; } }',
'#tab-home .home-note { margin-top: 8px; font-size: 13px; color: var(--muted, #9a9a9a); }',
'@media (hover: hover) { #tab-home .home-card:hover, #tab-home .home-station:hover, #tab-home .home-row:hover { background: var(--hover, #141414); } }',
'.reader-theme-bright #tab-home, body.eink #tab-home .home-card { background: #fff; }',
].join('\n');
document.head.appendChild(style);

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
const CACHE = 'diora-v101';
const CACHE = 'diora-v102';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',

View file

@ -11,6 +11,50 @@
{% if user.is_authenticated %}<meta name="username" content="{{ user.username }}">{% endif %}
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<script>
// Light and dark (docs/theme.md): the choice is "dark", "light" or "auto" (as the
// system says). Set before anything paints; every page has it, app.js or not.
// Plain ES5: old e-reader WebViews run this too.
(function () {
var KEY = 'diora_theme';
var LABELS = {dark: 'Dunkel', light: 'Hell', auto: 'Automatisch'};
var mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
function choice() {
var v = null;
try { v = localStorage.getItem(KEY); } catch (e) {}
return v === 'light' || v === 'auto' ? v : 'dark';
}
function scheme() {
var c = choice();
return c === 'light' || (c === 'auto' && mq && mq.matches) ? 'light' : 'dark';
}
function apply() {
var s = scheme();
document.documentElement.setAttribute('data-theme', s);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', s === 'light' ? '#f6f5f1' : '#000000');
var b = document.getElementById('theme-toggle');
if (b) {
var c = choice();
b.textContent = c === 'light' ? '\u2600' : c === 'auto' ? '\u25D0' : '\u263E';
b.title = 'Erscheinungsbild: ' + LABELS[c];
}
try { window.dispatchEvent(new CustomEvent('diora-theme', {detail: s})); } catch (e) {}
}
window.__dioraTheme = choice;
window.__dioraSetTheme = function (c) {
try { localStorage.setItem(KEY, LABELS[c] ? c : 'dark'); } catch (e) {}
apply();
};
window.cycleTheme = function () {
var order = ['dark', 'light', 'auto'];
window.__dioraSetTheme(order[(order.indexOf(choice()) + 1) % order.length]);
};
if (mq && mq.addEventListener) mq.addEventListener('change', function () { if (choice() === 'auto') apply(); });
document.addEventListener('DOMContentLoaded', apply);
document.documentElement.setAttribute('data-theme', scheme());
})();
</script>
<link rel="stylesheet" href="/static/css/app.css">
<title>{% block title %}diora{% endblock %}</title>
<script>const DIORA_CONFIG = { ebookMaxBytes: {{ EBOOK_MAX_BYTES }}, bgMaxBytes: {{ BG_MAX_BYTES }}, podcastInboxPageSize: {{ PODCAST_INBOX_PAGE_SIZE }} };</script>
@ -22,7 +66,8 @@
<nav class="navbar">
<a href="/" class="navbar-brand">diora</a>
<div class="navbar-links">
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Kontrastmodus an/aus">◑</button>
<button class="btn-icon theme-toggle" id="theme-toggle" onclick="cycleTheme()" title="Erscheinungsbild">☾</button>
<button class="btn-icon contrast-toggle" id="contrast-toggle" onclick="toggleContrast()" title="Kontrast zum Hintergrundbild" hidden>◑</button>
{% if user.is_authenticated %}
<span class="navbar-user">{{ user.username }}</span>
<a href="{% url 'settings' %}">Einstellungen</a>