diff --git a/docs/library.md b/docs/library.md
index e839a36..257e978 100644
--- a/docs/library.md
+++ b/docs/library.md
@@ -115,8 +115,18 @@ knot, a spiral), karte (Geschichte), kosmos (Science-Fiction), ornament
(Fantasy), raster (Politik, Soziologie), schatten (Krimi: a red line, a lit
doorway), grusel (Horror), klassiker (patterned paper with a title plate),
sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik),
-typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash
-of title and author picks palette, layout and every detail.
+typo, wellen, botanik, berge, zellen, and since 2026-10-25 funk (talk and radio: waveform,
+mast, speech bubbles), musik (record, staff), kunst (colour fields, brushstrokes), essen (plate
+on a checked cloth, citrus), sport (track, pitch), geld (price chart, coins), reise (road,
+route with compass) — and bauhaus when nothing matches (shapes, quarter-circle tiles, a
+circle crossed by diagonals). Most concepts have two or three motifs. A hash of title and
+author picks motif, palette, layout and every detail.
+
+**Square** (`{square: true}`, since 2026-10-25): audiobooks and podcasts without a picture, or
+whose picture doesn't load (audiobooks.js; diora-android native-podcast.js `art()`, before the
+initial letter). 300 × 300, three title lines at most; the band of the motif shown is centred
+in what the text leaves free. Podcasts (`podcast: true`) nothing matches get funk, not
+bauhaus; a Marx/Engels audiobook gets konstruktiv (the blue volume is a binding).
### Export as XML (since 2026-10-24)
diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js
index 871292d..63d07ea 100644
--- a/static/js/audiobooks.js
+++ b/static/js/audiobooks.js
@@ -36,6 +36,11 @@
const shelf = document.createElement('div');
shelf.id = 'audiobook-shelf';
tab.appendChild(shelf);
+ // A cover that doesn't load gives way to the drawn one (cover(): data-gen; shelf and book view).
+ document.addEventListener('error', e => {
+ const img = e.target;
+ if (img.tagName === 'IMG' && img.dataset.gen && img.getAttribute('src') !== img.dataset.gen) img.src = img.dataset.gen;
+ }, true);
// ---- helpers ------------------------------------------------------------------------
@@ -74,11 +79,12 @@
function cover(url, title, cls, author) {
const art = String(url || '').replace(/^http:/, 'https:');
- if (!art && window.DioraGenCover) { // none: one drawn from the title (gencover.js)
- return '
';
- }
+ // None, or one that doesn't load (the error listener on the shelf): one drawn from the title
+ // (gencover.js), square like the others.
+ const gen = window.DioraGenCover ? DioraGenCover.url({title: title || '', author: author || ''}, {square: true}) : '';
+ if (!art && gen) return '
';
return art
- ? '
'
+ ? '
'
: '' + esc((title || '?').trim().charAt(0).toUpperCase()) + '';
}
diff --git a/static/js/gencover.js b/static/js/gencover.js
index 095a5dd..53322cb 100644
--- a/static/js/gencover.js
+++ b/static/js/gencover.js
@@ -9,8 +9,11 @@
* the palette, the layout and every detail of the motif, so no two books look alike and
* each always looks the same. Nothing is stored or sent: it's an SVG, made when needed.
*
- * DioraGenCover.url(book, {gray}) a data: URL (book: {title, author, shelfTag, folders})
- * DioraGenCover.theme(book) the concept chosen (for tests)
+ * DioraGenCover.url(book, {gray, square, podcast}) a data: URL (book: {title, author, shelfTag, folders})
+ * DioraGenCover.theme(book, opts) the concept chosen (for tests)
+ *
+ * square: 300 × 300 for audiobooks and podcasts (the motif's middle, the text above or below);
+ * podcast: a show nothing else fits gets the radio concept rather than the plain shapes.
*
* Chrome-61-safe: no ?. / ??.
*/
@@ -141,6 +144,36 @@
'treasure', 'schatz', 'green gables', 'narnia', 'wardrobe', 'coraline', 'unendliche'],
palettes: [['#fdf3e1', '#2d2a26', '#f2a23a', '#4fa3a5'], ['#e8f4f1', '#23343a', '#ef7b5a', '#f4c54b'], ['#2f3e6b', '#fff6e6', '#f6c445', '#ef8a6f'],
['#f6ecf2', '#2c2433', '#e0607e', '#5bb08f']]},
+ // Talk and radio: what podcasts mostly are (and their default, see theme()).
+ {id: 'funk', font: SANS,
+ words: ['podcast', 'radio', 'talk', 'gespräch', 'interview', 'sendung', 'show$', 'funk$', 'rundfunk', 'deutschlandfunk',
+ 'hörspiel', 'feature', 'on air', 'live', 'plauder', 'unterhaltung', 'conversation'],
+ palettes: [['#f3ece0', '#20232a', '#e2573b', '#3f8fa0'], ['#1c2230', '#efe9df', '#f0a73c', '#7fb7c9'], ['#e9eef0', '#1f2a33', '#5a63c9', '#e9806a'],
+ ['#2a1f2d', '#f4e8dc', '#ef6f6c', '#f2c45a']]},
+ {id: 'musik', font: SERIF,
+ words: ['musik', 'music', 'jazz', 'song', 'lied', 'klang', 'sound', 'oper', 'opera', 'symphon', 'sinfon', 'konzert', 'concert',
+ 'rock', 'pop$', 'hip hop', 'hip-hop', 'rap$', 'techno', 'beethoven', 'mozart', 'bach$', 'album', 'vinyl', 'melod', 'harmon'],
+ palettes: [['#151515', '#efe9dd', '#e0a43a', '#c4473c'], ['#f1e9dc', '#1f1d1b', '#b6382f', '#2f6c8a'], ['#20263a', '#efe6d6', '#e78f6a', '#9ec3c7']]},
+ {id: 'kunst', font: SERIF,
+ words: ['kunst', 'art$', 'arts$', 'malerei', 'painting', 'maler', 'painter', 'ästhetik', 'aesthetic', 'museum', 'design',
+ 'foto', 'photo', 'film', 'kino', 'cinema', 'bild', 'image', 'skulptur', 'galerie', 'gallery', 'avantgarde', 'avant-garde'],
+ palettes: [['#efe7da', '#2a2622', '#c4492f', '#e2b04a'], ['#2a2230', '#efe5d8', '#d96a4a', '#5f8fb0'], ['#e6e9ea', '#232629', '#8a3d5c', '#e5a23e']]},
+ {id: 'essen', font: SERIF,
+ words: ['kochen', 'koch$', 'cook', 'küche', 'kitchen', 'rezept', 'recipe', 'essen', 'food', 'backen', 'bake', 'baking',
+ 'wein', 'wine', 'kaffee', 'coffee', 'genuss', 'vegan', 'gemüse', 'brot', 'bread', 'mahl', 'meal'],
+ palettes: [['#f6efe2', '#2b2520', '#c8493a', '#e7b347'], ['#eef1e6', '#23301f', '#d2643c', '#6f9a54'], ['#2d2420', '#f3e9da', '#e09a3b', '#b9473a']]},
+ {id: 'sport', font: NARROW, upper: true,
+ words: ['sport', 'fußball', 'fussball', 'football', 'soccer', 'laufen', 'running', 'marathon', 'fahrrad', 'cycling',
+ 'tennis', 'fitness', 'training', 'olymp', 'spieler', 'player', 'tor$', 'goal', 'liga', 'league', 'boxen', 'boxing'],
+ palettes: [['#e8efe6', '#1d2a22', '#d9483b', '#2f7a4f'], ['#1d2a3a', '#eef2f4', '#f2b23c', '#4f9fd0'], ['#f2ece2', '#22252a', '#c6462e', '#2e6aa0']]},
+ {id: 'geld', font: SANS,
+ words: ['geld', 'money', 'finanz', 'financ', 'börse', 'market', 'markt', 'invest', 'bank$', 'banken', 'schulden', 'debt',
+ 'kredit', 'credit', 'zins', 'interest', 'aktie', 'stock', 'krise', 'crisis', 'inflation', 'reichtum', 'wealth', 'kapitalis', 'wirtschaft'],
+ palettes: [['#eef0e8', '#1e2a24', '#2f8a5f', '#c9a23f'], ['#1f2722', '#e9ece2', '#d9b24a', '#5fae86'], ['#f1ece3', '#24221f', '#b5462f', '#3f6f5a']]},
+ {id: 'reise', font: SERIF,
+ words: ['reise', 'travel', 'journey', 'unterwegs', 'zug$', 'train', 'bahn$', 'eisenbahn', 'railway', 'straße', 'road', 'fahrt',
+ 'atlas', 'expedition', 'entdeck', 'discover', 'wander', 'pilger', 'pilgrim', 'odyssee', 'odyssey', 'kompass', 'compass', 'route'],
+ palettes: [['#f0e8d8', '#2a2a26', '#c0562f', '#3f7a8a'], ['#22303a', '#efe7d6', '#e3a04a', '#89b5bd'], ['#e7ece6', '#24302c', '#b0473a', '#d1a54a']]},
{id: 'code', font: MONO,
words: ['program', 'comput', 'informatik', 'software', 'algorithm', 'linux', 'unix', 'python', 'javascript',
'rhcsa', 'red hat', 'interpretation of computer'],
@@ -156,7 +189,9 @@
['informatik', 'code'], ['schreib', 'typo'], ['gegenwartslit', 'typo'], ['roman', 'typo'], ['zeitschrift', 'typo'],
['religion', 'sakral'], ['mytholog', 'sakral'], ['klassiker', 'klassiker'], ['science', 'kosmos'], ['sci-fi', 'kosmos'],
['fantasy', 'ornament'], ['krimi', 'schatten'], ['thriller', 'schatten'], ['warhammer', 'schatten'], ['horror', 'grusel'],
- ['kinder', 'spiel'], ['jugend', 'spiel'],
+ ['kinder', 'spiel'], ['jugend', 'spiel'], ['gespräch', 'funk'], ['podcast', 'funk'], ['radio', 'funk'], ['hörspiel', 'funk'],
+ ['comedy', 'funk'], ['musik', 'musik'], ['music', 'musik'], ['kunst', 'kunst'], ['film', 'kunst'], ['koch', 'essen'],
+ ['essen', 'essen'], ['sport', 'sport'], ['finanz', 'geld'], ['wirtschaft', 'geld'], ['reise', 'reise'],
];
const DEFAULT = {id: 'bauhaus', font: SANS,
palettes: [['#efe9dd', '#24262a', '#d0533f', '#3f7fb0'], ['#26282d', '#efe8da', '#e3b04b', '#5b9aa8'], ['#e8ecef', '#22272c', '#c8553d', '#6b8f71'],
@@ -198,10 +233,14 @@
const MARX_ENGELS = /(^|[^a-zäöü])(marx|engels)($|[^a-zäöü])/i;
const BLAU = THEMES.filter(t => t.id === 'blau')[0];
- function theme(book) {
+ const byId = id => THEMES.filter(t => t.id === id)[0];
+
+ function theme(book, opts) {
+ // The blue volume is a book's binding: a square (audiobook, podcast) gets the red of the movement.
+ if (opts && opts.square && (MARX_ENGELS.test(book.author || '') || /mew|marx.engels.werke/i.test(book.series || ''))) return byId('konstruktiv');
if (MARX_ENGELS.test(book.author || '') || /mew|marx.engels.werke/i.test(book.series || '')) return BLAU;
const s = scored(book).filter(x => x.t !== BLAU);
- return s.length ? s[0].t : DEFAULT;
+ return s.length ? s[0].t : opts && opts.podcast ? byId('funk') : DEFAULT;
}
// ---- motifs: each draws into the whole cover, the text gets its own space later -----------
@@ -285,6 +324,16 @@
return s;
},
wellen(r, p) {
+ if (r() < 0.4) { // the sun on the horizon, its light broken on the water
+ const hy = between(r, 250, 300), sx = between(r, 90, 210), sr = between(r, 40, 62);
+ let s = `` +
+ ``;
+ for (let y = hy + 10, i = 0; y < H - 10; y += 11 + i * 1.5, i++) {
+ const w = sr * 2 * (1 - i * 0.05) * between(r, 0.6, 1.1);
+ if (w > 6) s += ``;
+ }
+ return s;
+ }
let s = '';
const n = 6 + Math.floor(r() * 5);
const amp = between(r, 6, 16), len = between(r, 60, 120);
@@ -298,6 +347,20 @@
return s;
},
karte(r, p) {
+ if (r() < 0.4) { // a graticule, a coast, a route across it
+ let s = '';
+ for (let x = 30; x < W; x += 40) s += ``;
+ for (let y = 30; y < H; y += 40) s += ``;
+ const cx = between(r, 100, 200), cy = between(r, 260, 330), ph1 = r() * 6, ph2 = r() * 6;
+ let d = '';
+ for (let k = 0; k <= 48; k++) {
+ const a = k / 48 * Math.PI * 2, rad = 70 * (1 + 0.25 * Math.sin(a * 3 + ph1) + 0.12 * Math.sin(a * 7 + ph2));
+ d += (k ? ' L' : 'M') + f1(cx + Math.cos(a) * rad * 1.2) + ' ' + f1(cy + Math.sin(a) * rad);
+ }
+ s += ``;
+ return s + ``;
+ }
let s = '';
const cx = between(r, 80, 220), cy = between(r, 160, 320);
for (let i = 1; i <= 9; i++) {
@@ -313,6 +376,18 @@
return s;
},
kosmos(r, p) {
+ if (r() < 0.4) { // the moon's phases in a row, under a few stars
+ let s = '';
+ for (let i = 0; i < 40; i++) s += ``;
+ const y = between(r, 260, 320), rad = 22;
+ for (let i = 0; i < 5; i++) {
+ const x = 40 + i * 55;
+ s += ``;
+ if (i !== 2) s += ``;
+ s += ``;
+ }
+ return s;
+ }
let s = '';
for (let i = 0; i < 70; i++) s += ``;
const cx = between(r, 90, 210), cy = between(r, 170, 290), pr = between(r, 30, 55);
@@ -334,6 +409,22 @@
return s;
},
botanik(r, p) {
+ if (r() < 0.4) { // a fern frond, bending
+ const x0 = between(r, 110, 190), bend = between(r, -0.6, 0.6), n = 14, pts = [];
+ for (let i = 0; i <= n; i++) {
+ const t = i / n;
+ pts.push([x0 + Math.sin(t * 1.6) * bend * 90, H - 10 - t * 300]);
+ }
+ let s = ``;
+ pts.slice(1, -1).forEach((q, i) => {
+ const len = (1 - i / n) * between(r, 40, 60) + 8;
+ [-1, 1].forEach(side => {
+ s += ``;
+ });
+ });
+ return s;
+ }
let s = '';
const n = 5 + Math.floor(r() * 4);
for (let i = 0; i < n; i++) {
@@ -345,6 +436,17 @@
return s;
},
berge(r, p) {
+ if (r() < 0.4) { // one peak and its snow, the sky in bands
+ let s = '';
+ for (let i = 0; i < 5; i++) s += ``;
+ const px = between(r, 100, 200), py = between(r, 170, 220), base = H - 30;
+ const lx = px - between(r, 150, 220), rx = px + between(r, 150, 220);
+ s += ``;
+ const k = between(r, 0.22, 0.32), sl = px + (lx - px) * k, sr = px + (rx - px) * k, sy = py + (base - py) * k;
+ s += ``;
+ return s + ``;
+ }
let s = ``;
for (let i = 0; i < 4; i++) {
const base = 260 + i * 45;
@@ -356,6 +458,17 @@
return s;
},
zellen(r, p) {
+ if (r() < 0.4) { // the double helix, across
+ const cy = between(r, 250, 310), amp = between(r, 40, 60), len = between(r, 110, 150), ph = r() * 6;
+ let a = '', b = '', rungs = '';
+ for (let x = -10; x <= W + 10; x += 6) {
+ const y = Math.sin(x / len * Math.PI * 2 + ph) * amp;
+ a += (x > -10 ? ' L' : 'M') + x + ' ' + f1(cy + y);
+ b += (x > -10 ? ' L' : 'M') + x + ' ' + f1(cy - y);
+ if ((x + 10) % 18 === 0) rungs += ``;
+ }
+ return rungs + ``;
+ }
let s = '';
for (let i = 0; i < 16; i++) {
const x = between(r, 20, 280), y = between(r, 140, 430), rad = between(r, 12, 42);
@@ -401,6 +514,15 @@
``;
},
typo(r, p, book) {
+ if (r() < 0.45) { // a page of type with one line marked, a quotation mark
+ let s = `“`;
+ const mark = 4 + Math.floor(r() * 8);
+ for (let i = 0, y = 190; y < H - 30; i++, y += 15) {
+ const w = i % 7 === 6 ? between(r, 60, 160) : between(r, 200, 248);
+ s += ``;
+ }
+ return s;
+ }
const ch = (book.title || '?').replace(/^[^A-Za-zÄÖÜäöüß0-9]+/, '').charAt(0).toUpperCase() || '?';
return `${esc(ch)}` +
@@ -560,7 +682,184 @@
s += ``;
return s;
},
+ funk(r, p) {
+ const v = r();
+ if (v < 0.45) { // a voice as a waveform, mirrored about its line
+ const cy = between(r, 270, 320), n = 34, gap = (W - 30) / n;
+ let s = ``;
+ let level = between(r, 0.2, 0.6);
+ for (let i = 0; i < n; i++) {
+ level = Math.max(0.08, Math.min(1, level + between(r, -0.3, 0.3)));
+ const h = level * between(r, 60, 110), c = level > 0.75 ? p[2] : i % 9 === 4 ? p[3] : p[1];
+ s += ``;
+ }
+ return s;
+ }
+ if (v < 0.75) { // a mast, its waves going out
+ const x = between(r, 60, 240), y = between(r, 300, 360);
+ let s = '';
+ for (let i = 1; i <= 6; i++) {
+ s += ``;
+ }
+ return s + ``;
+ }
+ // two speech bubbles, one answering the other
+ const bubble = (x, y, w, h, left, fill, stroke) => ``;
+ const w1 = between(r, 150, 200), w2 = between(r, 130, 180);
+ return bubble(between(r, 20, 50), between(r, 190, 220), w1, between(r, 70, 90), true, p[2], 'none') +
+ bubble(W - 20 - w2 - between(r, 0, 30), between(r, 310, 330), w2, between(r, 60, 80), false, 'none', p[1]);
+ },
+ musik(r, p) {
+ if (r() < 0.5) { // a record, half out of its sleeve
+ const cx = between(r, 170, 230), cy = between(r, 280, 320), R = between(r, 110, 130);
+ let s = `` +
+ ``;
+ for (let g = R - 8; g > R * 0.4; g -= between(r, 4, 9)) {
+ s += ``;
+ }
+ return s + ``;
+ }
+ // a staff with notes on it
+ const y0 = between(r, 220, 280), gap = 11;
+ let s = '';
+ for (let k = 0; k < 5; k++) s += ``;
+ for (let x = 30; x < W - 20; x += between(r, 26, 44)) {
+ const y = y0 + 4 * gap - Math.floor(between(r, -3, 11)) * gap / 2, c = r() < 0.2 ? p[2] : p[1];
+ s += `` +
+ ``;
+ }
+ return s + ``;
+ },
+ kunst(r, p) {
+ if (r() < 0.5) { // colour fields one above the other, their edges soft
+ const top = between(r, 150, 180), gap = between(r, 10, 18), h1 = between(r, 110, 150);
+ const field = (y, h, c, o) => `` +
+ ``;
+ return field(top, h1, p[2], 0.95) + field(top + h1 + gap, H - 30 - (top + h1 + gap), pick(r, [p[3], p[1]]), 0.85);
+ }
+ // brushstrokes
+ let s = '';
+ for (let i = 0; i < 4; i++) {
+ const x0 = between(r, -20, 80), y0 = between(r, 180, 420), x1 = between(r, 220, 320), y1 = y0 + between(r, -80, 80);
+ s += ``;
+ }
+ return s;
+ },
+ essen(r, p) {
+ if (r() < 0.5) { // a plate on a checked cloth
+ const sz = pick(r, [20, 24, 28]), cx = between(r, 120, 180), cy = between(r, 280, 320);
+ return `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``;
+ }
+ // citrus slices
+ let s = '';
+ for (let i = 0; i < 5; i++) {
+ const x = between(r, 40, 260), y = between(r, 180, 410), R = between(r, 30, 52), n = pick(r, [8, 9, 10]), c = pick(r, [p[2], p[3]]);
+ s += ``;
+ for (let k = 0; k < n; k++) {
+ const a = k * Math.PI * 2 / n;
+ s += ``;
+ }
+ }
+ return s;
+ },
+ sport(r, p) {
+ if (r() < 0.5) { // the bend of a running track, its lanes
+ const cx = pick(r, [40, W - 40]), cy = between(r, 300, 340);
+ let s = ``;
+ for (let i = 0; i <= 6; i++) s += ``;
+ return s + ``;
+ }
+ // a pitch from above
+ const x0 = 30, x1 = W - 30, y0 = 150, y1 = H - 20, my = (y0 + y1) / 2;
+ return `` +
+ `` +
+ `` +
+ `` +
+ ``;
+ },
+ geld(r, p) {
+ if (r() < 0.55) { // a price over time, the area under it
+ let s = '';
+ for (let y = 170; y < H - 20; y += 40) s += ``;
+ let v = between(r, 300, 380);
+ const pts = [];
+ for (let x = 20; x <= W - 20; x += 10) { v = Math.max(170, Math.min(H - 30, v + between(r, -26, 22))); pts.push([x, v]); }
+ const d = pts.map((q, i) => (i ? 'L' : 'M') + q[0] + ' ' + f1(q[1])).join(' '), last = pts[pts.length - 1];
+ return s + `` +
+ `` +
+ ``;
+ }
+ // coins in stacks
+ let s = '';
+ const n = 4 + Math.floor(r() * 3), w = (W - 40) / n;
+ for (let i = 0; i < n; i++) {
+ const cx = 20 + w * (i + 0.5), k = 2 + Math.floor(r() * 12);
+ for (let j = 0; j < k; j++) {
+ const y = H - 40 - j * 11;
+ s += `` +
+ ``;
+ }
+ }
+ return s;
+ },
+ reise(r, p) {
+ if (r() < 0.5) { // a road to the horizon
+ const hy = between(r, 220, 260), vx = between(r, 110, 190);
+ let s = `` +
+ `` +
+ ``;
+ for (let t = 0.08; t < 1; t = t * 1.45 + 0.03) {
+ const y = hy + (H - hy) * t, x = vx + (W / 2 - vx) * t;
+ s += ``;
+ }
+ return s;
+ }
+ // a route across, its stops, a compass
+ const pts = [];
+ for (let i = 0; i < 5; i++) pts.push([between(r, 30, 270), 170 + i * 60 + between(r, -15, 15)]);
+ let s = ``;
+ pts.forEach((q, i) => {
+ const end = i === pts.length - 1;
+ s += ``;
+ });
+ return s + `` +
+ `` +
+ ``;
+ },
bauhaus(r, p) {
+ const v = r();
+ if (v < 0.3) { // tiles, each a quarter circle turned its own way
+ const sz = (W - 40) / 4;
+ let s = '';
+ for (let i = 0; i < 4; i++) for (let j = 0; 170 + (j + 1) * sz <= H - 10; j++) {
+ const x = 20 + i * sz, y = 170 + j * sz, corner = Math.floor(r() * 4);
+ const dx = corner % 2 ? -1 : 1, dy = corner > 1 ? -1 : 1, cx = dx > 0 ? x : x + sz, cy = dy > 0 ? y : y + sz;
+ s += `` +
+ ``;
+ }
+ return s;
+ }
+ if (v < 0.55) { // a big circle off the edge, crossed by thin diagonals
+ const cx = pick(r, [0, W]), a = cx ? -1 : 1, n = 7 + Math.floor(r() * 5);
+ let s = ``;
+ for (let i = 0, x = a > 0 ? -160 : 40; i < n; i++, x += between(r, 34, 44)) {
+ s += ``;
+ }
+ return s;
+ }
let s = '';
for (let i = 0; i < 4; i++) {
const kind = Math.floor(r() * 3), x = between(r, 30, 270), y = between(r, 150, 410), size = between(r, 40, 110);
@@ -608,7 +907,8 @@
return s.length >= 3 ? s : String(t || '?');
}
- function textBlock(r, th, p, book) {
+ // h: the cover's height (a square's is its width); max: lines of title at most.
+ function textBlock(r, th, p, book, h, max) {
const title = mainTitle(book.title);
const upper = th.upper;
const t = upper ? title.toUpperCase() : title;
@@ -619,7 +919,7 @@
const em = upper ? 0.74 : 0.58;
const size = Math.max(14, Math.min(len <= 10 ? 40 : len <= 18 ? 34 : len <= 30 ? 28 : len <= 48 ? 23 : 19,
Math.floor(248 / (longest * em))));
- const ls = lines(t, size, 5, upper ? 0.72 : 0.56);
+ const ls = lines(t, size, max, upper ? 0.72 : 0.56);
const lh = size * 1.12;
const layout = pick(r, th.layouts || ['top', 'top', 'bottom', 'plate']);
const author = String(book.author || '').split(/[;,]/)[0].trim();
@@ -629,8 +929,8 @@
y0 = 34;
s += ``;
} else if (layout === 'bottom') {
- y0 = H - blockH + 20;
- s += ``;
+ y0 = h - blockH + 20;
+ s += ``;
} else {
y0 = 70;
s += ``;
@@ -647,7 +947,8 @@
const asz = Math.max(8, Math.min(12, Math.floor((240 / author.length - spacing) / 0.7)));
s += `${esc(author.toUpperCase())}`;
}
- return s;
+ // free: the band the text leaves to the motif (a square centres the motif in it).
+ return {svg: s, free: layout === 'top' ? [blockH, h] : layout === 'bottom' ? [0, h - blockH] : [y0 - 34 + blockH, h]};
}
// ---- the cover ---------------------------------------------------------------------------------------
@@ -659,24 +960,32 @@
return '#' + h + h + h;
}
+ // A square shows a band of the 2:3 motif: its heart (about here, the motifs keep the top of the
+ // cover for the text) in the middle of what the text leaves free.
+ const MOTIF_HEART = 260;
+
function svg(book, opts) {
- const th = theme(book);
+ const square = !!(opts && opts.square);
+ const th = theme(book, opts);
const r = rng(hash(norm(book.title) + '|' + norm(book.author)));
let p = pick(r, th.palettes);
if (opts && opts.gray) p = p.map(gray);
const motif = (M[th.id] || M.bauhaus)(r, p, book);
- return ``;
+ const h = square ? W : H;
+ const text = th.ownText ? {svg: '', free: [0, h]} : textBlock(r, th, p, book, h, square ? 3 : 5);
+ const shift = Math.max(0, Math.min(H - h, Math.round(MOTIF_HEART - (text.free[0] + text.free[1]) / 2)));
+ return ``;
}
const cache = new Map();
function url(book, opts) {
const key = (book.title || '') + '|' + (book.author || '') + '|' + (book.shelfTag || '') + '|' +
((book.folders || []).join(',')) + '|' + (book.series || '') + '|' + (book.seriesIndex == null ? '' : book.seriesIndex) + '|' +
- (opts && opts.gray ? 1 : 0);
+ (opts && opts.gray ? 1 : 0) + (opts && opts.square ? 's' : '') + (opts && opts.podcast ? 'p' : '');
if (!cache.has(key)) cache.set(key, 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg(book, opts)));
return cache.get(key);
}
- window.DioraGenCover = {url, svg, theme: book => theme(book).id};
+ window.DioraGenCover = {url, svg, theme: (book, opts) => theme(book, opts).id};
})();
diff --git a/static/js/sw.js b/static/js/sw.js
index 945c4fa..e3547e3 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v151';
+const CACHE = 'diora-v152';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
index 07df488..70a6ab6 100644
--- a/static/podcasts/native-podcast.js
+++ b/static/podcasts/native-podcast.js
@@ -1,4 +1,4 @@
-// Copied from diora-android 9a6c059 2026-10-25: web-native/static/native-podcast.js
+// Copied from diora-android 6cfec49 2026-10-25: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js.
@@ -402,11 +402,14 @@
return f ? f.artwork_url : '';
}
- // A cover: the episode's own, else its podcast's, else a tile with the podcast's initial.
+ // A cover: the episode's own, else its podcast's, else one drawn from the podcast's name
+ // (diora-web gencover.js, square), else a tile with its initial.
function art(src, size, fallback, name) {
const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : ''));
const srcs = [src, fallback].filter(Boolean).map(s => s.replace(/^http:/, 'https:'))
.filter((s, i, all) => all.indexOf(s) === i);
+ const title = plain(name || '').trim();
+ if (title && window.DioraGenCover) srcs.push(DioraGenCover.url({title}, {square: true, podcast: true}));
const initial = () => {
if (box.querySelector('img') || box.firstChild) return;
const letter = (plain(name || '').replace(/^(the|der|die|das)\s+/i, '').match(/[0-9A-Za-zÀ-ÿ]/) || [''])[0];