The cover studio: CSS 3D books, container-query type, and a shy shimmer
People judge books by covers, and they judge writing apps by how the covers look in the app. PlotDevice's book cards now show an actual book: a 3D volume with a page block, a spine shadow, and a gloss that shimmers when it thinks nobody's watching. Three days of commits, and not one polygon — the whole effect is CSS.
A book made of gradients
The 3D book is perspective: 1200px, transform-style: preserve-3d,
and a hover that tilts to rotateY(-16deg) rotateX(3deg). The page
block on the fore-edge is a repeating-linear-gradient faking a stack
of page edges: 38px wide on cards, 200px in the full-screen view. The
back cover is the front cover pushed to translateZ(-38px) with
filter: brightness(0.6). And the spine shadow is one gradient doing
the work of a lighting rig:
.book-cover-spine-shadow {
background: linear-gradient(to right,
oklch(0 0 0 / 0.5) 0%, oklch(0 0 0 / 0.16) 4%, transparent 12%);
}
None of it is physically justified; all of it triggers "book". The same trick as every fake material since the dawn of UI: accumulate enough cues and the brain ships the rest.
The detail I'm most attached to is the shimmer. A diagonal gloss
gradient sweeps across a cover on hover, but idle books also shimmer
occasionally: a module-level registry picks one book every 8–15 seconds
(8_000 + Math.random() * 7_000), never the same book twice in a row,
one at a time. It reads less like an animation and more like light
moving in a room. Also on the ledger: a 3D spine and bottom page block
were built, looked wrong, and shipped as display: none classes with
-unused suffixes — kept as a memorial to the hour they cost.
Overlay text: HTML beats canvas
Generated cover art is one thing; the title and author on top of it are another. The overlay system is deliberately not canvas. It's styled HTML rendered over the image, which means real fonts, real text editing, and free accessibility. The trick that makes it work at every size is container-query units:
fontSize: `${ts.fontSizePercent}cqh`
The cover is a size container, and text is sized in cqh — percent of
container height — so the same settings render identically on a 180px
card and a full-screen lightbox. Twenty-six curated Google Fonts in four
buckets (serif, sans, script, display), colour presets, letter-spacing,
and three text effects (shadow, outline, glow). Effect intensity is
implemented by stacking the same text-shadow N times, which is crude
and looks great. Favourite combinations persist globally, because a
series wants the same jacket design on every book.
Export rasterises the live DOM: html-to-image's toPng after
fontsReady(), then straight to a save dialog. What you see is
literally what you get, because what you get is what you're seeing.
There are actually two ways to get text on a cover, and they're mutually exclusive by convention: ask the image model to bake the title into the artwork (a checkbox that prepends "must prominently display the title... in large, legible text" to the prompt), or keep the art clean and use the overlay. Enabling overlay text auto-collapses the baked-in option's panel: a UI nudge doing the job of a constraint, which I've noted before ends one of two ways.
The generation pipeline
Cover art itself is a two-model relay: an LLM writes the image prompt from the book's atmosphere, setting, and characters (system prompt capped at "under 250 words", structured title-section-first), then a Fireworks image model renders it: synchronous for standard models, submit-and-poll for Flux-style workflows. Five book-shaped aspect ratios, from 832×1216 for a 5.5×8.5" trade paperback up to square. The client appends one standing instruction to every prompt: "Do not include a signature or watermark." Image models love signing their work; publishers disagree.
One table for every image
Under the visuals, the week's real work: artwork_images became the
single source of truth for covers. Until now cover images lived as
files on disk with a custom cover-image:// protocol and a
cover_image_path column — a second storage system with its own
failure modes. Now everything is base64 in SQLite with category, mime
type, and an is_selected flag; the EPUB, PDF, and preview renderers
all eat data URIs; and a bundled default-cover.png covers the
bookless. Deleting a storage system feels better than adding one, every
time.
It came with a casualty. Character portrait generation was still wired to the old disk-path pipeline. Rather than port it, today's commit deleted it (−400 lines). Portraits will come back on the new path or not at all; keeping a feature alive on a deprecated storage system is how you end up with two of everything forever.
Honest ledger
- Today shipped four rewrites of the same settings panel: compact popovers in the morning, a single collapsible panel by lunch, moved wholesale into the Define panel in the afternoon, resizable with tabs by evening. The commit log reads like someone rearranging furniture, because it was. The fourth layout is good. The third was also "good".
CoverPreviewDialog.tsx(733 lines) still exists alongside the newCoverPanel(668 lines), overlapping heavily: 2,343 lines of cover UI across five components, and every one of them duplicated nearly 1:1 in the web app, because the two codebases still don't share components. That bill is accruing interest weekly.default-cover.pngis 2MB, committed twice (once per app). The repo gains weight like a novelist on deadline.
The general lesson is the cheap-rendering one again, one level up: you don't need a 3D engine to sell a book, and you don't need canvas to set type on a cover. The DOM, three gradients, and a unit that scales with its container got all the way there — and everything stays editable, inspectable, and exportable, because it never stopped being HTML.