feat: draw covers for books without one

Full-bleed serif title on a woven ground, drawn in the browser rather than
stored, sized with container queries so one component works from 36px to full
page. Colour is hashed into a list of bookcloth tones instead of the hue wheel.

The grid and search were falling back to default_cover.jpg because a missing
cover made the src "/api/undefined"; they now check for one first.
This commit is contained in:
2026-08-12 13:44:14 -04:00
parent 96789620bb
commit 7e04826fa5
4 changed files with 203 additions and 30 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Book } from '$lib/schema';
import GeneratedCover from './generated-cover.svelte';
let {
book,
@@ -10,12 +11,6 @@
let failed = $state(false);
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
// A stable hue per title, so a book with no artwork still gets its own
// colour rather than every placeholder looking identical.
const hue = $derived([...book.title].reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) % 360, 7));
const authors = $derived(book.authors.map((a) => a.name).join(', '));
</script>
<!--
@@ -65,20 +60,10 @@
{:else}
<!-- No cover on record, or the file is missing. Draw one. -->
<span
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg"
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue}
30% 34%), hsl({hue} 38% 18%));"
class="h-full overflow-hidden rounded-sm shadow-lg"
style="width: {Math.round(height * 0.66)}px;"
>
<span
class="line-clamp-4 font-serif text-xs leading-tight"
style="color: hsl({hue} 38% 95%);">{book.title}</span
>
{#if authors}
<span
class="line-clamp-2 font-mono text-[8px] tracking-wider uppercase"
style="color: hsl({hue} 24% 78%);">{authors}</span
>
{/if}
<GeneratedCover {book} />
</span>
{/if}
</span>
@@ -1,6 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import BookImage from './book-image.svelte';
import GeneratedCover from './generated-cover.svelte';
import { Progress } from '$lib/components/ui/progress/index';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
@@ -31,13 +32,30 @@
: ''}"
onclick={handleClick}
>
<BookImage
src="/api/{book.cover_image}"
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
darkened
? 'brightness-50'
: ''}"
/>
<!--
Checked rather than left to the image's onerror: with no cover_image the
src became "/api/undefined", 404'd, and fell back to the generic
default_cover.jpg — which is what made every coverless book look alike in
the grid.
-->
{#if book.cover_image}
<BookImage
src="/api/{book.cover_image}"
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
darkened
? 'brightness-50'
: ''}"
/>
{:else}
<div
class="h-full w-full transition-all duration-200 group-hover:brightness-50 {selected ||
darkened
? 'brightness-50'
: ''}"
>
<GeneratedCover {book} />
</div>
{/if}
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
follows the cover's corners instead of floating below it -->
@@ -0,0 +1,163 @@
<script lang="ts">
import type { Book } from '$lib/schema';
let { book, class: className = '' }: { book: Book; class?: string } = $props();
/**
* Bookcloth tones rather than a point on the hue wheel.
*
* The old placeholder derived a hue from the title with `hash % 360`, which
* eventually lands on acid green. Hashing into a fixed list keeps every
* generated cover looking like it came from the same library.
*/
const CLOTH = [
'#1f5f5b', // teal, the app's own accent
'#7a2e2a', // oxblood
'#2f4858', // slate
'#3c5148', // forest
'#6b4a6e', // plum
'#8a6a1f', // brass
'#2b3038', // graphite
'#5a4632' // tobacco
];
/** Weaves, as CSS gradients — an SVG <pattern> would need a unique id per cover. */
const WEAVE = [
// twill
`repeating-linear-gradient(45deg, transparent 0 4px, currentColor 4px 5px)`,
// horizontal ribbing
`repeating-linear-gradient(0deg, transparent 0 5px, currentColor 5px 6px)`,
// crosshatch
`repeating-linear-gradient(45deg, transparent 0 6px, currentColor 6px 7px),
repeating-linear-gradient(-45deg, transparent 0 6px, currentColor 6px 7px)`,
// vertical laid lines
`repeating-linear-gradient(90deg, transparent 0 5px, currentColor 5px 6px)`
];
/** Stable per title, so a book always looks the same. */
function hash(value: string) {
let out = 7;
for (const char of value) out = (out * 31 + char.charCodeAt(0)) % 100_000;
return out;
}
const seed = $derived(hash(book.title || 'Untitled'));
const cloth = $derived(CLOTH[seed % CLOTH.length]);
const weave = $derived(WEAVE[(seed >> 3) % WEAVE.length]);
const authors = $derived(book.authors?.map((author) => author.name).join(', ') ?? '');
/**
* Stepped by length rather than scaled continuously: a long title shrunk to
* fit would end up smaller than the author line, which reads as a mistake.
* Units are cqw so the whole thing works at any size the caller asks for.
*/
const titleSize = $derived.by(() => {
const length = (book.title ?? '').length;
if (length <= 14) return 17;
if (length <= 28) return 13;
if (length <= 46) return 10;
return 8.5;
});
</script>
<!--
Drawn, not stored. It costs nothing, follows the title when it is edited, and
is replaced the moment a real cover is uploaded. Colours are fixed rather than
themed: this is an object on a shelf beside real artwork, which does not
change with the theme either.
-->
<div
class="generated-cover {className}"
style="--cloth: {cloth};"
role="img"
aria-label="Generated cover for {book.title}"
>
<div class="weave" style="background-image: {weave};"></div>
<div class="type">
<span class="title" style="font-size: {titleSize}cqw;">{book.title}</span>
{#if authors}
<span class="author">{authors}</span>
{/if}
</div>
</div>
<style>
.generated-cover {
container-type: inline-size;
position: relative;
overflow: hidden;
height: 100%;
width: 100%;
background: var(--cloth);
color: #f4f1ec;
isolation: isolate;
}
/* currentColor drives the gradient, so one rule covers every weave. */
.weave {
position: absolute;
inset: 0;
color: #ffffff;
opacity: 0.14;
z-index: -1;
}
/* A little depth so it does not read as a flat swatch next to a photograph. */
.generated-cover::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(150deg, rgb(255 255 255 / 0.1), rgb(0 0 0 / 0.28));
z-index: -1;
}
.type {
display: flex;
height: 100%;
flex-direction: column;
justify-content: space-between;
gap: 6cqw;
padding: 10cqw 9cqw;
}
.title {
font-family: var(--app-font-serif, Georgia, serif);
line-height: 1.04;
letter-spacing: -0.02em;
text-wrap: balance;
/* Clip rather than spill: an overflowing title looks broken, a clipped one
just looks like a long title. */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
line-clamp: 6;
overflow: hidden;
}
.author {
font-family: var(--app-font-mono, ui-monospace, monospace);
font-size: 4.4cqw;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.75;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Below about a centimetre the author is unreadable, and the padding is
eating the title. Show the title alone. */
@container (max-width: 72px) {
.author {
display: none;
}
.type {
padding: 8cqw;
}
}
</style>