Featured image of post Merging Two Cover Generators into One: How I Unified the Blog Cover Pipeline with LynxCard and Added Drag-and-Drop Layout

Merging Two Cover Generators into One: How I Unified the Blog Cover Pipeline with LynxCard and Added Drag-and-Drop Layout

The blog used to have two separate cover generators: the news pipeline produced landscape info cards via LynxCard, while hand-written articles used a standalone HTML+Playwright script. Two templates, two rendering logics — every style change meant touching both. This post covers porting the dark-terminal script into LynxCard as a dark_terminal template and adding drag-and-drop layout to the WebUI.

New post

The blog’s cover images used to run on two parallel tracks: AI news posts automatically called the LynxCard service to produce “landscape info cards,” while my hand-written deep-dive articles used a standalone script, gen_cover_dark_terminal.py, to render a “dark terminal” style cover — HTML template + Playwright screenshot + PIL quantization. The two tracks were completely independent, with different styles, parameters, and rendering logic. I finally merged them, and along the way solved an old pain point: having to edit code just to nudge a layout.

Why merge

The cost of maintaining two tracks became obvious the moment I needed to change a style. A few days ago, while fixing “excess blank space at the bottom of covers,” I had to edit the HTML template in gen_cover_dark_terminal.py, delete the old “keep the bottom 40% empty” rule, and then verify the LynxCard track was unaffected. A single concept — “cover image” — was scattered across two repositories, two template languages (one HTML/CSS, one SVG placeholders), and two parameter systems. Every unified adjustment had to be done twice, keeping track of which side used which logic.

The bigger annoyance was layout. In the dark-terminal cover, the positions of the title, subtitle, command line, and brand were hard-coded pixel values in CSS. Want to move the title down a bit? That meant editing code, re-rendering, screenshotting to check, and tweaking again. A purely visual tweak required a full code-change cycle.

The merge: unify templates, parameterize coordinates

The core of the merge was turning the dark-terminal style into a new LynxCard template, dark_terminal, sitting alongside the existing portrait quote card and landscape info card. Two design decisions mattered.

First, the template uses SVG placeholders. The original script used absolute positioning in HTML; I translated that into SVG: the deep-blue gradient background, grid, teal highlight, and monospace amber command line are all preserved, with each element wrapped in a <g transform="translate({{X}}, {{Y}})"> whose coordinates are placeholders injected by Python at render time.

Second, coordinates can be overridden via spec.layout_overrides. Default positions live in a LAYOUTS table, but each card’s spec can carry a layout_overrides dict that overrides any element’s x/y. This is what makes drag-and-drop layout possible later — dragging is essentially just writing coordinates into that dict.

Drag-and-drop: move things directly on the canvas

Parameterization alone wasn’t enough; I wanted true WYSIWYG dragging. LynxCard already had a FastAPI WebUI, so I added template switching and canvas dragging to it: after picking the “Blog Cover · Dark Terminal” template, hovering over any element in the preview shows a dashed outline, and you can drag it to reposition. On release it re-renders, writes the coordinates into layout_overrides, and saves them into the spec. Reload the card later and both the template and the positions come back exactly.

Hit a few real snags along the way: the brand field was initially left-aligned and crowded next to the kicker — switching to right-alignment pushed it to the top-right corner; and with a two-line title, the fixed-position subtitle and command line got run over by the large title text — making them shift dynamically based on the title’s actual height fixed that. These are the kinds of issues you can’t catch just by reading code; you have to actually render and stare at the pixels.

Wrap-up: one entry point, old script retired

After the merge, there’s only one way to make a dark-terminal cover: call lynxcard_client.render_dark_terminal_cover() in code, or open the WebUI, pick the template, and drag. The old gen_cover_dark_terminal.py is still in the repo but marked deprecated — it prints a warning when run — and the memory note that used to say “two tracks coexist” now says “merged.” One service, one WebUI, one spec store: the cover problem is finally consolidated.