@natjswenson/devlog 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +479 -0
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/evals/baseline/published/devlog-v0.10.0.md +129 -0
- package/evals/baseline/published/devlog-v0.11.0.md +139 -0
- package/evals/baseline/published/devlog-v0.8.1.md +97 -0
- package/evals/baseline/published/devlog-v0.9.0.md +193 -0
- package/evals/baseline/published/ghostwriter-v0.11.0.md +211 -0
- package/evals/baseline/published/ghostwriter-v0.8.1.md +221 -0
- package/evals/baseline/published/local-fitness-v0.25.0.md +233 -0
- package/evals/baseline/published/resume-v1.0.1.md +129 -0
- package/package.json +4 -2
- package/skill-invariants.json +26 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Encoding the fix into the skill, not just the code: how one agent session backfilled 49 cover images"
|
|
3
|
+
date: 2026-07-16
|
|
4
|
+
project: devlog
|
|
5
|
+
version: v0.8.1
|
|
6
|
+
tags: [claude-code, skills, subagents, ai-agents, devlog, image-generation, playwright]
|
|
7
|
+
summary: "devlog v0.8.1 fixed bland, repetitive cover images, but the fix that stuck wasn't in the render code. It was a line added to the skill's own instructions and a machine-checked invariant, which is what let one Claude Code session regenerate all 49 backfilled covers in parallel and get them right the second time."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
devlog v0.7.0 through v0.8.1 added a `private` project type and a full cover-image pipeline: every post gets a rendered 1600x900 PNG from a headless browser, feeding the feed thumbnail, post hero, OG card, and RSS enclosure. The render pipeline (Playwright screenshotting an HTML/SVG document, `sharp` for palette quantization) is real code and it's covered further down, but it's not the interesting part of this release.
|
|
13
|
+
|
|
14
|
+
The interesting part is that the first batch of 49 backfilled covers all looked the same, one shared template, and the fix wasn't a code change at all. It was rewriting the skill's own instructions and adding a rule the skill checks for itself, then letting one agent session regenerate every cover from that new instruction, in parallel, without a human opening an image editor.
|
|
15
|
+
|
|
16
|
+
## The skill file is the product, not the render code
|
|
17
|
+
|
|
18
|
+
`devlog` is a Claude Code skill: a `SKILL.md` plus supporting files that Claude loads when composing a release. Skills exist for exactly this shape of problem. The Claude Code docs put it directly: "Unlike CLAUDE.md content, a skill's body loads only when it's used, so long reference material costs almost nothing until you need it" ([Claude Code docs: Extend Claude with skills](https://code.claude.com/docs/en/skills)). The cover-composition procedure, the palette, the font, the "never do this" list, all of it lives in files an agent reads at the moment it needs them, not in a prompt retyped every release.
|
|
19
|
+
|
|
20
|
+
The first version of that procedure described a palette and a font and left composition open: a title, a kicker, an optional shape. Every one of the 49 backfilled covers took that literally, big headline text, one of three rotating stock shapes in a corner. Technically correct, visually identical, and a waste of the space.
|
|
21
|
+
|
|
22
|
+
The fix went into `SKILL.md` itself, as an instruction the agent has to follow before it's allowed to compose a cover:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
# 2. Compose the cover using ONLY this release's title/tags/summary/`## Shipped` text
|
|
26
|
+
# (never the raw draft file, never `## Changelog`) plus the returned style guide and
|
|
27
|
+
# reference images. A cover that just re-renders the title in large text is a failure —
|
|
28
|
+
# find the one concrete mechanism this release is actually about (not the project name,
|
|
29
|
+
# not "a bug fix") and draw ONE custom inline-SVG illustration of it, sized as the
|
|
30
|
+
# dominant visual element of the canvas; title/kicker stay secondary. Two different
|
|
31
|
+
# releases should never produce visually similar covers...
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
And it went into `skill-invariants.json`, a second, independent list of rules the skill checks itself against, separate from the step-by-step instructions:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"id": "cover-custom-illustration",
|
|
39
|
+
"pattern": "cover that just re-renders the title in large text is a failure",
|
|
40
|
+
"rationale": "First shipped version of this feature produced a shared text-heavy template with a rotating stock shape — rejected as bland/repetitive. Losing this line reopens that regression."
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `rationale` field is the part worth noticing. It doesn't describe what the rule does, the pattern line already does that. It records why the rule exists, so the next time someone (or some agent) is tempted to trim that sentence from `SKILL.md` as boilerplate, the invariant file is right there explaining what regression comes back if they do. A skill without that second file just has good intentions that erode one edit at a time.
|
|
45
|
+
|
|
46
|
+
## What let one session redo 49 images: subagents, not a loop
|
|
47
|
+
|
|
48
|
+
Rewriting the style guide fixes new posts. It does nothing for 49 already-published ones sitting on disk with the old bland covers. Regenerating all of them serially, in the same conversation, one render-review-adjust cycle at a time, would have flooded that conversation with 49 rounds of image output long before it reached the last one.
|
|
49
|
+
|
|
50
|
+
Claude Code's subagents exist for that specific shape of problem. Per the docs: "Each subagent runs in its own context window with a custom system prompt, specific tool access, and independent permissions" ([Claude Code docs: Create custom subagents](https://code.claude.com/docs/en/sub-agents)). Seven of them ran in parallel, each working a batch of the 49 posts against the new style guide and invariant, each returning only the finished covers rather than the back-and-forth it took to get there. The main session stayed free to keep working on the render pipeline and the site-side bugs below while the batches came back.
|
|
51
|
+
|
|
52
|
+
That split, one thread holding the plan and doing the review, several isolated threads doing the repetitive regeneration, is what made "redo all 49, today" a reasonable thing to ask for instead of a multi-day queue of manual image tweaks.
|
|
53
|
+
|
|
54
|
+
## Build it: the part that's still ordinary code
|
|
55
|
+
|
|
56
|
+
The render step itself is unglamorous by comparison: build an HTML string with CSS and hand it to a headless browser to screenshot, the same approach Playwright's own docs describe, where "the browser handles fonts, gradients, flexbox, and every other CSS feature" for you ([Playwright: Screenshots](https://playwright.dev/docs/screenshots)). Launch the browser, set a fixed viewport, load the HTML, wait for the bundled font to actually finish loading before the screenshot (`document.fonts.ready`, which per MDN "will only resolve once the document has completed loading fonts... and no further font loads are needed" ([MDN: FontFaceSet.ready](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready))), then hand the screenshot to `sharp` with `palette: true` for quantization. Skip the wait and every cover silently renders in the fallback sans-serif; nothing throws.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
export async function renderCover(html, { width = 1600, height = 900 } = {}) {
|
|
60
|
+
const browser = await chromium.launch();
|
|
61
|
+
try {
|
|
62
|
+
const page = await browser.newPage({ viewport: { width, height } });
|
|
63
|
+
await page.setContent(html, { waitUntil: 'load' });
|
|
64
|
+
await page.addStyleTag({ content: fontFaceCss });
|
|
65
|
+
await page.evaluate((f) => document.fonts.load(`1em '${f}'`), FONT_FAMILY);
|
|
66
|
+
await page.evaluate(() => document.fonts.ready);
|
|
67
|
+
const png = await page.screenshot({ type: 'png' });
|
|
68
|
+
return await sharp(png).png({ palette: true }).toBuffer();
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Quantization is a real size win for flat illustrations specifically: the same 1600x900 test render came out at 27306 bytes with plain PNG encoding and 4989 bytes with `palette: true`. It's the wrong setting for anything with a smooth gradient, one more reason the style guide asks for flat, limited color instead.
|
|
76
|
+
|
|
77
|
+
## Gotchas
|
|
78
|
+
|
|
79
|
+
**A style guide that only describes the palette gets you a consistent palette and nothing else.** The part that needed to be explicit, in writing, in the file the agent actually reads, was the part everyone assumed went without saying: don't just render the title. Leaving it implicit is how the first 49 covers all ended up looking the same.
|
|
80
|
+
|
|
81
|
+
**A local content cache silently overrode the field that made covers work at all.** The site that consumes these covers keeps a local copy of already-published posts for editorial preview, and that local copy wins over the remote source when both exist, which is nearly all posts. It had no `cover` field, so the merge defaulted every cover-bearing post back to "no cover" the moment it also had a local copy. Only 1 of the first 49 backfilled covers actually rendered on the live site until this shipped, caught by rebuilding the site and checking real output instead of trusting the pipeline.
|
|
82
|
+
|
|
83
|
+
**Once the cover carried the title, the plain-text heading above it duplicated it.** A covered post showed the same headline twice, once in the image, once in the `<h1>` right above it. The fix drops the separate heading for covered posts and lets the card, image plus a caption line for date and read time, be the visible heading, while a screen-reader-only `<h1>` keeps one real heading in the page structure.
|
|
84
|
+
|
|
85
|
+
## Sources
|
|
86
|
+
|
|
87
|
+
- [Claude Code docs: Extend Claude with skills](https://code.claude.com/docs/en/skills) — confirms skills load reference material on demand rather than living in the always-loaded CLAUDE.md.
|
|
88
|
+
- [Claude Code docs: Create custom subagents](https://code.claude.com/docs/en/sub-agents) — confirms each subagent runs in an isolated context window, which is what made parallel batches of the 49-cover regeneration practical.
|
|
89
|
+
- [MDN: FontFaceSet.ready](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready) — the exact resolution condition for waiting on font loads before a screenshot.
|
|
90
|
+
- [Playwright: Screenshots](https://playwright.dev/docs/screenshots) — confirms the "build HTML, screenshot it" approach the render step relies on.
|
|
91
|
+
|
|
92
|
+
## Changelog
|
|
93
|
+
|
|
94
|
+
- fix(devlog): cover style guide mandates one custom illustration per post (0.8.1) ([ef46024](https://github.com/natejswenson/claude-skills/commit/ef460249b421ce9242ec121f982037e64e650ba0))
|
|
95
|
+
- feat(devlog): auto-generate cover images for every post (v0.8.0) (#72) ([88d4c96](https://github.com/natejswenson/claude-skills/commit/88d4c96a176458f7f30d23e1307ddacab2b86829))
|
|
96
|
+
- feat(devlog): add a private project type (v0.7.0) ([21f418a](https://github.com/natejswenson/claude-skills/commit/21f418a249f82869e66f27e38956dbc00044ae5b))
|
|
97
|
+
- feat(devlog): tag cap raise, validation, manifest write-through (#71) ([b699bc1](https://github.com/natejswenson/claude-skills/commit/b699bc1e8cdfd580a97b903896d357fe22f6dbe2))
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Enforcing a layout contract on AI-drawn images with a DOM geometry gate"
|
|
3
|
+
date: 2026-07-18
|
|
4
|
+
project: devlog
|
|
5
|
+
version: v0.9.0
|
|
6
|
+
tags: [playwright, llm-agents, image-generation, dom, getboundingclientrect, validation, svg, headless-chromium]
|
|
7
|
+
summary: "Style-guide prose can ask an agent to draw inside a box; it can't make it happen. How to measure the rendered DOM in headless Chromium and refuse to rasterize any composition that breaks the layout contract."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
devlog v0.9.0 gave cover generation two things: a 20-icon SVG catalog for small accent glyphs, and a fixed hero zone, a 1300x400 box every cover's illustration must occupy. The interesting part is the enforcement. The renderer now measures the composed page in headless Chromium and throws before taking the screenshot if the hero zone is missing, misplaced, or has a catalog icon drifting into it. This post is about that pattern: when an agent composes visual output, put the layout rules in a geometry check, not just in the prompt.
|
|
13
|
+
|
|
14
|
+
## Why prose rules weren't enough
|
|
15
|
+
|
|
16
|
+
My covers are HTML/SVG documents composed by an agent from a style guide, then rasterized to PNG. The style guide said things like "catalog icons are never the hero illustration." The old safeguard for that rule was a regex asserting the sentence still existed in the instructions. That checks the rule is *stated*; it says nothing about whether any given cover *follows* it. An agent could compose two catalog icons connected by a line, call that the hero, and nothing mechanical would object.
|
|
17
|
+
|
|
18
|
+
The fix is to make the rule checkable in the artifact itself. Two conventions do the work:
|
|
19
|
+
|
|
20
|
+
- The required region is an element with a well-known id, `#hero-zone`, at exact coordinates.
|
|
21
|
+
- Every restricted element carries a machine-readable marker, a [`data-*` attribute](https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Solve_HTML_problems/Use_data_attributes) like `data-catalog-icon="git"`, so the checker can find them with a `[data-catalog-icon]` selector.
|
|
22
|
+
|
|
23
|
+
Once the contract is addressable in the DOM, you can measure it.
|
|
24
|
+
|
|
25
|
+
## Build the gate: rect math that can't be gamed
|
|
26
|
+
|
|
27
|
+
Start with the contract and two helpers in `layout-gate.mjs`:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
// layout-gate.mjs
|
|
31
|
+
// The fixed box the composition must place its illustration container in,
|
|
32
|
+
// on a 1600x900 canvas. Single source of truth; the style guide states the
|
|
33
|
+
// same numbers as prose.
|
|
34
|
+
export const HERO_ZONE = { x: 150, y: 425, width: 1300, height: 400 };
|
|
35
|
+
|
|
36
|
+
// getBoundingClientRect() returns DOMRect values typed as unrestricted
|
|
37
|
+
// double, so subpixel results are legal; exact equality would flake.
|
|
38
|
+
// 2px absorbs rounding without becoming a real size allowance.
|
|
39
|
+
const TOLERANCE_PX = 2;
|
|
40
|
+
|
|
41
|
+
// Exact-match within tolerance, NOT containment. A containment check is
|
|
42
|
+
// gameable: a tiny box in a corner is still "inside" the target, and a
|
|
43
|
+
// tiny box trivially avoids overlapping anything.
|
|
44
|
+
function withinTolerance(rect, fixed) {
|
|
45
|
+
return (
|
|
46
|
+
Math.abs(rect.x - fixed.x) <= TOLERANCE_PX &&
|
|
47
|
+
Math.abs(rect.y - fixed.y) <= TOLERANCE_PX &&
|
|
48
|
+
Math.abs(rect.width - fixed.width) <= TOLERANCE_PX &&
|
|
49
|
+
Math.abs(rect.height - fixed.height) <= TOLERANCE_PX
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Positive-area intersection only: rects that merely touch along an edge
|
|
54
|
+
// do not count as overlapping.
|
|
55
|
+
function rectsOverlap(a, b) {
|
|
56
|
+
const left = Math.max(a.x, b.x);
|
|
57
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
58
|
+
const top = Math.max(a.y, b.y);
|
|
59
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
60
|
+
return right > left && bottom > top;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then the check itself. It runs inside the page via [`page.evaluate`](https://playwright.dev/docs/evaluating), which executes the callback in the browser context and serializes the result back to Node; DOM rects have to be copied into plain objects on the browser side:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
// layout-gate.mjs (continued)
|
|
68
|
+
export async function checkLayout(page) {
|
|
69
|
+
const zones = await page.evaluate(() =>
|
|
70
|
+
[...document.querySelectorAll('#hero-zone')].map((el) => {
|
|
71
|
+
const r = el.getBoundingClientRect();
|
|
72
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Structurally mandatory: zero matches and duplicates are both errors,
|
|
77
|
+
// never "skip the check" or "take the first one."
|
|
78
|
+
if (zones.length === 0) throw new Error('layout gate: no #hero-zone element');
|
|
79
|
+
if (zones.length > 1) throw new Error(`layout gate: ${zones.length} #hero-zone elements`);
|
|
80
|
+
|
|
81
|
+
const zone = zones[0];
|
|
82
|
+
if (!withinTolerance(zone, HERO_ZONE)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`layout gate: #hero-zone at x:${zone.x} y:${zone.y} ` +
|
|
85
|
+
`${zone.width}x${zone.height}, expected x:${HERO_ZONE.x} y:${HERO_ZONE.y} ` +
|
|
86
|
+
`${HERO_ZONE.width}x${HERO_ZONE.height}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const icons = await page.evaluate(() =>
|
|
91
|
+
[...document.querySelectorAll('[data-catalog-icon]')].map((el) => {
|
|
92
|
+
const r = el.getBoundingClientRect();
|
|
93
|
+
return { name: el.getAttribute('data-catalog-icon'), x: r.x, y: r.y, width: r.width, height: r.height };
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const offending = icons.filter((i) => rectsOverlap(i, zone)).map((i) => i.name);
|
|
98
|
+
if (offending.length > 0) {
|
|
99
|
+
throw new Error(`layout gate: catalog icons inside the hero zone: ${offending.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The measurement is [`getBoundingClientRect`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect), which reports each element's rendered position and size in pixels relative to the viewport. That's the point of the whole design: you're checking what Chromium actually laid out, not what the markup claims.
|
|
105
|
+
|
|
106
|
+
## Wire it in before the screenshot
|
|
107
|
+
|
|
108
|
+
You need the full `playwright` package and a Chromium build (`npm i playwright && npx playwright install chromium`). The gate goes between page load and rasterization, so a bad composition fails loudly instead of producing a wrong image:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
// render.mjs
|
|
112
|
+
import { chromium } from 'playwright';
|
|
113
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
114
|
+
import { checkLayout } from './layout-gate.mjs';
|
|
115
|
+
|
|
116
|
+
export async function render(htmlPath, outPath) {
|
|
117
|
+
const browser = await chromium.launch();
|
|
118
|
+
try {
|
|
119
|
+
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
|
120
|
+
await page.setContent(readFileSync(htmlPath, 'utf8'), { waitUntil: 'networkidle' });
|
|
121
|
+
await checkLayout(page); // throws on any contract violation
|
|
122
|
+
writeFileSync(outPath, await page.screenshot());
|
|
123
|
+
} finally {
|
|
124
|
+
await browser.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
In my pipeline the caller catches the throw, feeds the error text back to the agent for one recomposition attempt, and otherwise publishes without a cover. The gate's error messages carry the measured numbers for exactly that reason; "your box is at y:300, expected y:425" is a prompt the agent can act on.
|
|
130
|
+
|
|
131
|
+
## Try it against a cheating layout
|
|
132
|
+
|
|
133
|
+
Make one compliant page and one that breaks the contract:
|
|
134
|
+
|
|
135
|
+
```html
|
|
136
|
+
<!-- good.html -->
|
|
137
|
+
<!DOCTYPE html>
|
|
138
|
+
<html><head><style>html, body { margin: 0; width: 1600px; height: 900px; }</style></head>
|
|
139
|
+
<body>
|
|
140
|
+
<div id="hero-zone" style="position:absolute; left:150px; top:425px; width:1300px; height:400px;"></div>
|
|
141
|
+
</body></html>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```html
|
|
145
|
+
<!-- cheat.html: tiny hero zone parked in a corner -->
|
|
146
|
+
<!DOCTYPE html>
|
|
147
|
+
<html><head><style>html, body { margin: 0; width: 1600px; height: 900px; }</style></head>
|
|
148
|
+
<body>
|
|
149
|
+
<div id="hero-zone" style="position:absolute; left:20px; top:20px; width:80px; height:60px;"></div>
|
|
150
|
+
</body></html>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Run both:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
// verify.mjs
|
|
157
|
+
import { render } from './render.mjs';
|
|
158
|
+
|
|
159
|
+
await render('good.html', 'good.png');
|
|
160
|
+
console.log('good.html rendered');
|
|
161
|
+
try {
|
|
162
|
+
await render('cheat.html', 'cheat.png');
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.log('cheat.html rejected:', e.message);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Output from my run of these exact files:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
good.html rendered
|
|
172
|
+
cheat.html rejected: layout gate: #hero-zone at x:20 y:20 80x60, expected x:150 y:425 1300x400
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Add a `data-catalog-icon` element inside the zone to `good.html` and it flips to the overlap error, naming the icon.
|
|
176
|
+
|
|
177
|
+
## Gotchas
|
|
178
|
+
|
|
179
|
+
- **Containment instead of exact-match.** The tempting check is "is the zone inside the canvas region." Symptom: covers pass the gate with a postage-stamp hero zone, because a tiny box is contained by anything and overlaps nothing. Escape: compare all four rect values against the fixed contract, within tolerance; the `cheat.html` run above is the regression test.
|
|
180
|
+
- **Exact equality on measured pixels.** DOMRect fields are `unrestricted double` per the [geometry spec](https://drafts.csswg.org/geometry/), so layout can hand you 424.996 for your 425. Symptom: the gate rejects visually perfect compositions intermittently. Escape: a small absolute tolerance (2px here); keep it small enough that it never becomes a design allowance.
|
|
181
|
+
- **The gate validates the box, not what's in it.** This one bit me a day after shipping: a cover can place `#hero-zone` perfectly and still strand a thin band of marks in a mostly-empty rectangle, passing every mechanical check while failing as an image. The v0.10.0 style guide added an explicit fill floor for exactly this reason. Escape: know which rules are geometry (checkable here) and which are composition quality; the latter need a different gate, or eyes.
|
|
182
|
+
- **Duplicate ids fail open if you use `querySelector`.** `querySelector('#hero-zone')` silently returns the first match, so a stray second zone would be invisible to the check. Symptom: none, which is the problem. Escape: `querySelectorAll` and treat any count other than one as an error.
|
|
183
|
+
|
|
184
|
+
## Sources
|
|
185
|
+
|
|
186
|
+
- [MDN: Element.getBoundingClientRect()](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) — what the measured rect contains and that it reflects rendered position/size in pixels
|
|
187
|
+
- [CSSWG Geometry Interfaces spec](https://drafts.csswg.org/geometry/) — DOMRect fields are `unrestricted double`, so subpixel values are legal
|
|
188
|
+
- [Playwright: Evaluating JavaScript](https://playwright.dev/docs/evaluating) — `page.evaluate` runs in the browser context and serializes results back
|
|
189
|
+
- [MDN: Using data attributes](https://developer.mozilla.org/en-US/docs/Learn_web_development/Howto/Solve_HTML_problems/Use_data_attributes) — marking restricted elements so a checker can select them
|
|
190
|
+
|
|
191
|
+
## Changelog
|
|
192
|
+
|
|
193
|
+
- feat(devlog): icon catalog + geometry-enforced hero zone for cover quality (v0.9.0) ([8848307](https://github.com/natejswenson/claude-skills/commit/8848307e00d37e0a76d1a3a3a77f7d360e8ed7ff))
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Catching layout bugs before your eyes do: a render-time QA gate for generated visuals"
|
|
3
|
+
date: 2026-07-18
|
|
4
|
+
project: ghostwriter
|
|
5
|
+
version: v0.11.0
|
|
6
|
+
tags: [playwright, css, has-selector, visual-regression-testing, dom-testing, browser-automation, generative-content, layout-qa]
|
|
7
|
+
summary: "How to measure a rendered HTML page for overflow and truncation before a human ever sees it, and the CSS trick that lets one layout adapt itself to however much content lands in it."
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Shipped
|
|
11
|
+
|
|
12
|
+
I maintain a skill that turns a handful of bullet points into a LinkedIn-ready image: an HTML/CSS template gets filled in and rendered to a PNG through a headless browser. The same release also added a publish log and a self-repairing scheduler for a separate part of that skill, but the fix worth teaching is what happened to the card renderer. An 11-agent audit across all 13 card templates at sparse, typical, and stress content volumes turned up 40 first-render defects, 21 of them severe: clipped headlines, ellipses firing where nobody wanted truncation, dead bands of empty space. The root cause was simple: every template had been eyeballed with one content shape in mind, and nothing ever measured what rendered. The fix was a lint that runs at render time and asserts on the live DOM, paired with CSS that adapts a layout's proportions to whatever content count it's actually carrying. That combination is the technique this post walks through, generalized so it works on any HTML-to-image pipeline, not just mine.
|
|
13
|
+
|
|
14
|
+
## Why reviewing the template isn't enough
|
|
15
|
+
|
|
16
|
+
A card template looks fine in the editor because you're reading markup, not a render. The moment real content lands in it, the browser does layout math you didn't picture: a 40-character title becomes 60 characters and now it wraps to a third line, a 3-step list becomes a 7-step list and now something is 30 pixels below the fold. Functional and unit tests don't see any of this, because they check behavior, not what a browser painted onto a page ([Percy](https://percy.io/blog/visual-regression-testing/)). If nothing measures the rendered box model, a broken card reaches a human for the first time when it's already about to ship.
|
|
17
|
+
|
|
18
|
+
The fix is to treat the render itself as a test subject: load the real page in a real browser, measure the elements that matter, and fail loudly before a screenshot goes anywhere.
|
|
19
|
+
|
|
20
|
+
## Build a card that has to survive variable content
|
|
21
|
+
|
|
22
|
+
Here's a stripped-down version of the pattern (not the production template, just the shape of it): a fixed-size frame holding a variable number of "step" blocks, each with a title and a detail line.
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
/* style.css */
|
|
26
|
+
#canvas {
|
|
27
|
+
width: 800px;
|
|
28
|
+
height: 500px;
|
|
29
|
+
padding: 40px;
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
display: flex;
|
|
32
|
+
flex-direction: column;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
gap: 20px;
|
|
35
|
+
background: #10141c;
|
|
36
|
+
font-family: -apple-system, sans-serif;
|
|
37
|
+
overflow: hidden; /* content that doesn't fit gets clipped, not reflowed */
|
|
38
|
+
}
|
|
39
|
+
.step { display: flex; flex-direction: column; gap: 4px; }
|
|
40
|
+
.step .title {
|
|
41
|
+
color: #fff;
|
|
42
|
+
font-size: 32px;
|
|
43
|
+
font-weight: 700;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
text-overflow: ellipsis;
|
|
47
|
+
}
|
|
48
|
+
.step .detail { color: #9aa4b2; font-size: 20px; }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# make_card.py
|
|
53
|
+
import sys
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
CSS = Path(__file__).parent / "style.css"
|
|
57
|
+
TEMPLATE = """<!DOCTYPE html>
|
|
58
|
+
<html><head><style>{css}</style></head>
|
|
59
|
+
<body><div id="canvas">{steps}</div></body></html>"""
|
|
60
|
+
STEP = """<div class="step"><div class="title">{title}</div><div class="detail">{detail}</div></div>"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build(steps: list[tuple[str, str]]) -> str:
|
|
64
|
+
css = CSS.read_text()
|
|
65
|
+
body = "\n".join(STEP.format(title=t, detail=d) for t, d in steps)
|
|
66
|
+
return TEMPLATE.format(css=css, steps=body)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
out = Path(sys.argv[1])
|
|
71
|
+
steps = [
|
|
72
|
+
("Clone the repo", "git clone the project"),
|
|
73
|
+
("Install deps", "pip install -r requirements.txt"),
|
|
74
|
+
("Run the build", "make build"),
|
|
75
|
+
]
|
|
76
|
+
out.write_text(build(steps))
|
|
77
|
+
print(f"wrote {out}")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A fixed 800x500 frame with a variable list dropped into it is exactly the shape that breaks: it looks great with three steps and quietly overflows with seven.
|
|
81
|
+
|
|
82
|
+
## Give the layout room to adapt
|
|
83
|
+
|
|
84
|
+
Rather than pick one font size and hope every future caller respects it, let the CSS itself react to how many `.step` elements showed up. The `:has()` relational pseudo-class matches an element based on what's inside it, so a container can style itself differently depending on its own children ([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:has)):
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
/* count-adaptive: exactly 3 steps get more room to breathe */
|
|
88
|
+
#canvas:has(> .step:nth-child(3):last-child) { gap: 36px; }
|
|
89
|
+
#canvas:has(> .step:nth-child(3):last-child) .title { font-size: 40px; }
|
|
90
|
+
|
|
91
|
+
/* count-adaptive: 5 or more steps compress to keep everything on-frame */
|
|
92
|
+
#canvas:has(> .step:nth-child(5)) { gap: 10px; }
|
|
93
|
+
#canvas:has(> .step:nth-child(5)) .title { font-size: 24px; }
|
|
94
|
+
#canvas:has(> .step:nth-child(5)) .detail { font-size: 16px; }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A sparse 3-step card scales up and fills the frame instead of floating in whitespace; a dense 5+ step card compresses to stay on-frame instead of overflowing. One template, no per-caller tuning.
|
|
98
|
+
|
|
99
|
+
## Build the render-time lint
|
|
100
|
+
|
|
101
|
+
The CSS above narrows the failure modes, but it doesn't guarantee anything, so the lint is what verifies a given render. It loads the page in Playwright and reads real box-model numbers off the live DOM with `page.evaluate` ([Playwright docs](https://playwright.dev/docs/api/class-locator#locator-bounding-box)):
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
# lint.py
|
|
105
|
+
import sys
|
|
106
|
+
from playwright.sync_api import sync_playwright
|
|
107
|
+
|
|
108
|
+
STEP_BUDGET = (3, 5) # inclusive min/max steps a card is allowed to carry
|
|
109
|
+
|
|
110
|
+
LINT_JS = """
|
|
111
|
+
() => {
|
|
112
|
+
const findings = [];
|
|
113
|
+
const canvas = document.getElementById('canvas');
|
|
114
|
+
const cRect = canvas.getBoundingClientRect();
|
|
115
|
+
|
|
116
|
+
// 1. clip-overflow: anything escaping the frame.
|
|
117
|
+
for (const el of canvas.querySelectorAll('*')) {
|
|
118
|
+
const r = el.getBoundingClientRect();
|
|
119
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
120
|
+
if (r.bottom > cRect.bottom + 1 || r.right > cRect.right + 1) {
|
|
121
|
+
findings.push(['FAIL', 'clip-overflow',
|
|
122
|
+
el.className + ' extends past the frame (bottom ' + Math.round(r.bottom) +
|
|
123
|
+
'px vs frame ' + Math.round(cRect.bottom) + 'px)']);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 2. ellipsis-fired: truncation actually happened.
|
|
128
|
+
for (const el of canvas.querySelectorAll('.title')) {
|
|
129
|
+
if (el.scrollWidth > el.clientWidth + 1) {
|
|
130
|
+
findings.push(['FAIL', 'ellipsis-fired',
|
|
131
|
+
'"' + el.textContent + '" is truncated (' + el.scrollWidth + 'px of text in ' +
|
|
132
|
+
el.clientWidth + 'px)']);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
findings.push(['INFO', 'step-count', canvas.querySelectorAll('.step').length + ' steps']);
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def lint(html_path: str) -> list[tuple[str, str, str]]:
|
|
143
|
+
with sync_playwright() as p:
|
|
144
|
+
browser = p.chromium.launch()
|
|
145
|
+
page = browser.new_page(viewport={"width": 900, "height": 600})
|
|
146
|
+
page.goto(f"file://{html_path}")
|
|
147
|
+
findings = page.evaluate(LINT_JS)
|
|
148
|
+
browser.close()
|
|
149
|
+
|
|
150
|
+
lo, hi = STEP_BUDGET
|
|
151
|
+
count = next(int(m.split()[0]) for (_, code, m) in findings if code == "step-count")
|
|
152
|
+
if not (lo <= count <= hi):
|
|
153
|
+
findings.append(("FAIL", "count-budget", f"{count} steps, budget is {lo}-{hi}"))
|
|
154
|
+
return findings
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
findings = lint(sys.argv[1])
|
|
159
|
+
for level, code, message in findings:
|
|
160
|
+
print(f"{level} {code}: {message}")
|
|
161
|
+
sys.exit(2 if any(level == "FAIL" for level, _, _ in findings) else 0)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The `scrollWidth > clientWidth` check is the standard way to prove an ellipsis fired rather than just being legal CSS that never triggers: `scrollWidth` is how wide the content would need to be to show it all, `clientWidth` is what it got ([MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollWidth)).
|
|
165
|
+
|
|
166
|
+
## Run it against a good, a sparse, and a stressed card
|
|
167
|
+
|
|
168
|
+
A 3-step card renders and lints clean:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
$ python make_card.py good.html && python lint.py good.html
|
|
172
|
+
wrote good.html
|
|
173
|
+
INFO step-count: 3 steps
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Edit the `steps` list in `make_card.py` down to 2 entries and rerun the same two commands. The count budget catches it before anyone has to notice the card looks thin:
|
|
177
|
+
|
|
178
|
+
```text
|
|
179
|
+
INFO step-count: 2 steps
|
|
180
|
+
FAIL count-budget: 2 steps, budget is 3-5
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Grow the list back out to 7 entries, with one title long enough to overflow ("Configure the deeply nested production environment variable file"), and the lint catches the truncation directly, quoting the exact text that got cut:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
FAIL ellipsis-fired: "Configure the deeply nested production environment variable file" is truncated (744px of text in 720px)
|
|
187
|
+
INFO step-count: 7 steps
|
|
188
|
+
FAIL count-budget: 7 steps, budget is 3-5
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Every one of those lines is something a human would otherwise have had to spot by squinting at a PNG.
|
|
192
|
+
|
|
193
|
+
## Gotchas
|
|
194
|
+
|
|
195
|
+
**`:has(> .step:nth-child(3))` without `:last-child` fires on every card with 3 or more steps, not exactly 3.** `nth-child(3)` only asserts that a 3rd child exists; it says nothing about whether a 4th or 5th one follows. I proved this to myself in a scratch page: a 5-step card with the anchor-less rule still painted its 3rd title red, because the 5-step card also has a 3rd child. Anchoring with `:nth-child(3):last-child` is the fix: it means "exactly 3, and that one is the last."
|
|
196
|
+
|
|
197
|
+
**A lint that can crash your render pipeline is worse than no lint.** The moment render-time checks run inside the same process that produces the shippable output, a bug in the lint itself (a null element, an unexpected DOM shape) can take down every render, not just the bad ones. Wrap the lint call so a lint exception degrades to a logged warning, never a failed render; the checks should only ever add information, not a new way to go down.
|
|
198
|
+
|
|
199
|
+
**Count-adaptive rules fix font size and spacing, not structural imbalance.** Scaling text up or down for 3 versus 5 items works fine until the count lands somewhere a grid can't distribute evenly, like exactly 3 tiles in a 2-column layout: one tile ends up alone on its own row, off-center. That case needs an explicit structural rule (span the odd tile across the full row), not just a smaller font. Count-adaptive CSS has to cover both the aesthetic scaling and the structural edge cases, or the "edge count" just moves somewhere new.
|
|
200
|
+
|
|
201
|
+
## Sources
|
|
202
|
+
|
|
203
|
+
- [MDN: :has()](https://developer.mozilla.org/en-US/docs/Web/CSS/:has) — the relational pseudo-class semantics and combinator syntax used for count-adaptive rules
|
|
204
|
+
- [Playwright: Locator.boundingBox()](https://playwright.dev/docs/api/class-locator#locator-bounding-box) — measuring live DOM geometry from inside a headless browser
|
|
205
|
+
- [MDN: Element.scrollWidth](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollWidth) — the scrollWidth/clientWidth comparison used to detect a fired ellipsis
|
|
206
|
+
- [Percy: What is visual regression testing?](https://percy.io/blog/visual-regression-testing/) — why appearance bugs slip past tests that only check behavior
|
|
207
|
+
|
|
208
|
+
## Changelog
|
|
209
|
+
|
|
210
|
+
- feat(ghostwriter): graphics quality overhaul — render-time lint, count-adaptive cards, content-budget contract (#76) ([682884a](https://github.com/natejswenson/claude-skills/commit/682884a4fa8c624a467467978ae32ef70c3d56b1))
|
|
211
|
+
- feat(ghostwriter): v0.11.0 — outcome feedback loop, self-repairing radar, in-session UX (#75) ([eeae226](https://github.com/natejswenson/claude-skills/commit/eeae2260c4c97f944c778001fa5c8d309f635669))
|