PlotWeaver #ui #image-generation #css

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 new CoverPanel (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.png is 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.