@natjswenson/devlog 0.8.1 → 0.9.0
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/SKILL.md +24 -12
- package/bin/devlog.js +19 -6
- package/image-style/icons.md +234 -0
- package/image-style/style-guide.example.md +31 -0
- package/lib/cover_gen.mjs +20 -7
- package/lib/render_cover.mjs +99 -4
- package/package.json +1 -1
- package/skill-invariants.json +10 -2
package/SKILL.md
CHANGED
|
@@ -313,18 +313,30 @@ npx -y @natjswenson/devlog cover-context '<key>' '<version>' \
|
|
|
313
313
|
# publish on a missing style guide.
|
|
314
314
|
|
|
315
315
|
# 2. Compose the cover using ONLY this release's title/tags/summary/`## Shipped` text
|
|
316
|
-
# (never the raw draft file, never `## Changelog`) plus the returned style guide
|
|
317
|
-
# reference images. A cover that just re-renders the title in large text is a failure —
|
|
318
|
-
# find the one concrete mechanism this release is actually about
|
|
319
|
-
# not "a bug fix") and draw ONE custom inline-SVG illustration
|
|
320
|
-
# dominant visual element of the canvas; title/kicker stay
|
|
321
|
-
# releases should never produce visually similar covers
|
|
322
|
-
#
|
|
323
|
-
#
|
|
324
|
-
#
|
|
325
|
-
#
|
|
326
|
-
|
|
327
|
-
#
|
|
316
|
+
# (never the raw draft file, never `## Changelog`) plus the returned style guide,
|
|
317
|
+
# icon catalog, and reference images. A cover that just re-renders the title in large text is a failure —
|
|
318
|
+
# find the one concrete mechanism this release is actually about
|
|
319
|
+
# (not the project name, not "a bug fix") and draw ONE custom inline-SVG illustration
|
|
320
|
+
# of it, sized as the dominant visual element of the canvas; title/kicker stay
|
|
321
|
+
# secondary. Two different releases should never produce visually similar covers.
|
|
322
|
+
#
|
|
323
|
+
# Draw the illustration inside a `#hero-zone` container at exactly
|
|
324
|
+
# `x:150 y:425 width:1300 height:400` (render-cover mechanically checks this box and
|
|
325
|
+
# refuses to render otherwise) — pick ONE of two composition slots per post: single
|
|
326
|
+
# centered hero (one freehand mechanism, nothing else), or two-node before/after (a
|
|
327
|
+
# left node, a right node, a connecting line, all freehand). Snap interior key points
|
|
328
|
+
# to a 25px coordinate grid. Catalog icons (image-style/icons.md) are never placed
|
|
329
|
+
# inside `#hero-zone` — they may only appear as an optional small accent glyph near
|
|
330
|
+
# the kicker/title area, entirely outside the hero zone, its bottom edge no lower than
|
|
331
|
+
# y:400. See the style guide's hero-zone grid contract section before composing.
|
|
332
|
+
# Write the result with the Write tool to '<abs-scratch>/<key>/<version>.html' — a
|
|
333
|
+
# full document starting with `<!DOCTYPE html>`, sized
|
|
334
|
+
# `html, body { margin:0; width:1600px; height:900px; }`, referencing the bundled
|
|
335
|
+
# font only as `font-family: 'DevlogCoverFont', sans-serif`.
|
|
336
|
+
|
|
337
|
+
# 3. Rasterize it. On failure (render timeout / Chromium not installed / font missing /
|
|
338
|
+
# a #hero-zone problem — missing, duplicate, wrong position/size, or a catalog icon
|
|
339
|
+
# overlapping it),
|
|
328
340
|
# the .html is left in place for debugging — retry composing once with the error text
|
|
329
341
|
# fed back, or give up and proceed with no --cover flag.
|
|
330
342
|
npx -y @natjswenson/devlog render-cover '<abs-scratch>/<key>/<version>.html' \
|
package/bin/devlog.js
CHANGED
|
@@ -423,6 +423,12 @@ async function cmdInit() {
|
|
|
423
423
|
copyFileSync(fontSrc, fontDest);
|
|
424
424
|
log.ok(`Installed image-style/font.ttf → ${fontDest}`);
|
|
425
425
|
}
|
|
426
|
+
const iconsSrc = join(IMAGE_STYLE_SRC_DIR, 'icons.md');
|
|
427
|
+
const iconsDest = join(IMAGE_STYLE_DEST_DIR, 'icons.md');
|
|
428
|
+
if (existsSync(iconsSrc) && (await confirmOverwrite('image-style/icons.md', iconsDest))) {
|
|
429
|
+
copyFileSync(iconsSrc, iconsDest);
|
|
430
|
+
log.ok(`Installed image-style/icons.md → ${iconsDest}`);
|
|
431
|
+
}
|
|
426
432
|
|
|
427
433
|
// Cover-generation reachability checks. Informational only — neither failure blocks
|
|
428
434
|
// setup, since a missing Chromium/font only affects cover generation, not the rest of
|
|
@@ -675,7 +681,7 @@ function cmdPublishEntry(rest) {
|
|
|
675
681
|
function cmdBackfillCovers(rest) {
|
|
676
682
|
const sub = rest[0];
|
|
677
683
|
if (sub !== 'list') {
|
|
678
|
-
emitJSON({ error: 'unknown-subcommand', message: 'Usage: devlog backfill-covers list --clone <cloneDir> [--project <key>] [--out <staging-dir>]' }, 2);
|
|
684
|
+
emitJSON({ error: 'unknown-subcommand', message: 'Usage: devlog backfill-covers list --clone <cloneDir> [--project <key>] [--out <staging-dir>] [--all]' }, 2);
|
|
679
685
|
return;
|
|
680
686
|
}
|
|
681
687
|
const { values } = parseArgs({
|
|
@@ -684,6 +690,7 @@ function cmdBackfillCovers(rest) {
|
|
|
684
690
|
clone: { type: 'string' },
|
|
685
691
|
project: { type: 'string' },
|
|
686
692
|
out: { type: 'string' },
|
|
693
|
+
all: { type: 'boolean', default: false },
|
|
687
694
|
},
|
|
688
695
|
allowPositionals: false,
|
|
689
696
|
});
|
|
@@ -699,8 +706,11 @@ function cmdBackfillCovers(rest) {
|
|
|
699
706
|
return;
|
|
700
707
|
}
|
|
701
708
|
|
|
709
|
+
// Default (no --all): missing-cover-only, this command's original purpose. With --all,
|
|
710
|
+
// every manifest entry qualifies regardless of cover status — what a cover-quality
|
|
711
|
+
// backfill needs, since every real entry already has cover: true from a prior batch.
|
|
702
712
|
let candidates = merged
|
|
703
|
-
.filter((e) => e && !e.cover)
|
|
713
|
+
.filter((e) => e && (values.all || !e.cover))
|
|
704
714
|
.map((e) => ({ ...e, _slug: slugFromFile(e.file) }));
|
|
705
715
|
|
|
706
716
|
if (values.project) {
|
|
@@ -767,9 +777,12 @@ function cmdCoverContext(rest) {
|
|
|
767
777
|
|
|
768
778
|
const config = readValidConfigOrExit({ json: true });
|
|
769
779
|
|
|
770
|
-
let
|
|
780
|
+
// `let`, declared outside both try blocks below — NOT `const` inside the first one.
|
|
781
|
+
// Both blocks' emitJSON calls need text/iconCatalog, and a `const` destructure scoped to
|
|
782
|
+
// the first try alone would leave them unreachable (a ReferenceError) inside the second.
|
|
783
|
+
let text, iconCatalog;
|
|
771
784
|
try {
|
|
772
|
-
|
|
785
|
+
({ text, iconCatalog } = loadStyleGuide());
|
|
773
786
|
} catch (e) {
|
|
774
787
|
emitJSON({ error: 'style-guide-missing', message: e.message }, 1);
|
|
775
788
|
return;
|
|
@@ -782,12 +795,12 @@ function cmdCoverContext(rest) {
|
|
|
782
795
|
stagingDir: values.staging ? expandHome(values.staging) : null,
|
|
783
796
|
n: 3,
|
|
784
797
|
});
|
|
785
|
-
emitJSON({ styleGuide, references });
|
|
798
|
+
emitJSON({ styleGuide: text, references, iconCatalog });
|
|
786
799
|
} catch (e) {
|
|
787
800
|
// A configured project's manifest.json missing/unparseable: distinct, named error
|
|
788
801
|
// field — never collapsed into an empty references: [] array — but still does not
|
|
789
802
|
// block the rest of publish for the caller.
|
|
790
|
-
emitJSON({ styleGuide, references: [], error: 'reference-lookup-failed', message: e.message });
|
|
803
|
+
emitJSON({ styleGuide: text, references: [], error: 'reference-lookup-failed', message: e.message, iconCatalog });
|
|
791
804
|
}
|
|
792
805
|
}
|
|
793
806
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# Cover icon catalog
|
|
2
|
+
|
|
3
|
+
Twenty small, secondary/accent icons — **never the hero illustration itself**. Each is a
|
|
4
|
+
real inline SVG, 24×24 viewBox, stroke-only (`stroke="currentColor" fill="none"`), so it
|
|
5
|
+
recolors for free via CSS `color: #ededed` or `color: #fff503` (the accent yellow) —
|
|
6
|
+
never `fill`. Wrap every usage in a container carrying `data-catalog-icon="<name>"`
|
|
7
|
+
(the exact `<name>` from the table below) — `render-cover`'s geometry guard reads this
|
|
8
|
+
attribute to confirm no catalog icon has drifted into the hero zone.
|
|
9
|
+
|
|
10
|
+
**These are for the kicker-area accent glyph only** (see `style-guide.md`'s hero-zone
|
|
11
|
+
grid contract) — never for the hero illustration's own mechanism/nodes, which are always
|
|
12
|
+
freehand SVG the agent draws itself. Look up a concept below instead of re-deriving an
|
|
13
|
+
icon from scratch; if a post's concept doesn't map cleanly to any of these 20, that's a
|
|
14
|
+
signal the post doesn't need an accent icon at all (a terminal-glyph accent or no accent
|
|
15
|
+
is always a valid choice — see `style-guide.md`).
|
|
16
|
+
|
|
17
|
+
## Topic → icon cheat sheet
|
|
18
|
+
|
|
19
|
+
| Topic / keywords in title or summary | Icon |
|
|
20
|
+
|---|---|
|
|
21
|
+
| agent, LLM, Claude, subagent, prompt | `agents` |
|
|
22
|
+
| test, testing, assert, spec, coverage | `testing` |
|
|
23
|
+
| CI, CD, pipeline, workflow, build | `ci-cd` |
|
|
24
|
+
| git, commit, branch, merge, tag | `git` |
|
|
25
|
+
| a11y, accessibility, aria, screen reader | `accessibility` |
|
|
26
|
+
| debug, bug, fix, root cause, trace | `debugging` |
|
|
27
|
+
| CLI, command, terminal, flag, argv | `cli` |
|
|
28
|
+
| config, settings, options, flags | `config` |
|
|
29
|
+
| deploy, release, ship, publish, rollout | `deploy` |
|
|
30
|
+
| database, manifest, schema, storage | `database` |
|
|
31
|
+
| API, endpoint, request, response | `api` |
|
|
32
|
+
| search, filter, query, lookup | `search` |
|
|
33
|
+
| auth, login, token, credential, permission | `auth` |
|
|
34
|
+
| monitor, metric, telemetry, dashboard | `monitoring` |
|
|
35
|
+
| cover, image, render, screenshot, thumbnail | `cover-image` |
|
|
36
|
+
| performance, speed, latency, throughput | `performance` |
|
|
37
|
+
| parse, parser, tokenize, frontmatter | `parsing` |
|
|
38
|
+
| cache, staging, memoize, invalidate | `caching` |
|
|
39
|
+
| UI, layout, component, page, nav | `ui` |
|
|
40
|
+
| network, remote, fetch, clone, push/pull | `networking` |
|
|
41
|
+
|
|
42
|
+
## Icons
|
|
43
|
+
|
|
44
|
+
### `agents`
|
|
45
|
+
```svg
|
|
46
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
47
|
+
<rect x="5" y="8" width="14" height="11" rx="2"/>
|
|
48
|
+
<line x1="12" y1="8" x2="12" y2="4"/>
|
|
49
|
+
<circle cx="12" cy="3" r="1"/>
|
|
50
|
+
<circle cx="9" cy="13" r="1.2"/>
|
|
51
|
+
<circle cx="15" cy="13" r="1.2"/>
|
|
52
|
+
<line x1="9" y1="17" x2="15" y2="17"/>
|
|
53
|
+
</svg>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `testing`
|
|
57
|
+
```svg
|
|
58
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
59
|
+
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
|
60
|
+
<polyline points="8,12.5 11,15.5 16,9"/>
|
|
61
|
+
</svg>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `ci-cd`
|
|
65
|
+
```svg
|
|
66
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
67
|
+
<circle cx="4.5" cy="12" r="2"/>
|
|
68
|
+
<circle cx="12" cy="12" r="2"/>
|
|
69
|
+
<circle cx="19.5" cy="12" r="2"/>
|
|
70
|
+
<line x1="6.5" y1="12" x2="10" y2="12"/>
|
|
71
|
+
<line x1="14" y1="12" x2="17.5" y2="12"/>
|
|
72
|
+
<polyline points="15.5,10 17.5,12 15.5,14"/>
|
|
73
|
+
</svg>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `git`
|
|
77
|
+
```svg
|
|
78
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
79
|
+
<circle cx="6" cy="6" r="2"/>
|
|
80
|
+
<circle cx="6" cy="18" r="2"/>
|
|
81
|
+
<circle cx="18" cy="10" r="2"/>
|
|
82
|
+
<line x1="6" y1="8" x2="6" y2="16"/>
|
|
83
|
+
<path d="M6 8 C6 10, 8 10, 12 10 S18 10, 18 12"/>
|
|
84
|
+
</svg>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `accessibility`
|
|
88
|
+
```svg
|
|
89
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
90
|
+
<circle cx="12" cy="12" r="9"/>
|
|
91
|
+
<circle cx="12" cy="7.5" r="1.4"/>
|
|
92
|
+
<line x1="7" y1="11" x2="17" y2="11"/>
|
|
93
|
+
<line x1="12" y1="11" x2="12" y2="15"/>
|
|
94
|
+
<line x1="12" y1="15" x2="9" y2="18.5"/>
|
|
95
|
+
<line x1="12" y1="15" x2="15" y2="18.5"/>
|
|
96
|
+
</svg>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### `debugging`
|
|
100
|
+
```svg
|
|
101
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
102
|
+
<rect x="8" y="8" width="8" height="10" rx="4"/>
|
|
103
|
+
<line x1="12" y1="4" x2="12" y2="8"/>
|
|
104
|
+
<line x1="6" y1="10" x2="8" y2="11"/>
|
|
105
|
+
<line x1="6" y1="14" x2="8" y2="14"/>
|
|
106
|
+
<line x1="6" y1="18" x2="8" y2="17"/>
|
|
107
|
+
<line x1="18" y1="10" x2="16" y2="11"/>
|
|
108
|
+
<line x1="18" y1="14" x2="16" y2="14"/>
|
|
109
|
+
<line x1="18" y1="18" x2="16" y2="17"/>
|
|
110
|
+
</svg>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `cli`
|
|
114
|
+
```svg
|
|
115
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
116
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
117
|
+
<polyline points="7,10 10,12.5 7,15"/>
|
|
118
|
+
<line x1="12" y1="15" x2="16" y2="15"/>
|
|
119
|
+
</svg>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `config`
|
|
123
|
+
```svg
|
|
124
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
125
|
+
<circle cx="12" cy="12" r="3"/>
|
|
126
|
+
<path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.5 1.5M16.9 16.9l1.5 1.5M18.4 5.6l-1.5 1.5M7.1 16.9l-1.5 1.5"/>
|
|
127
|
+
</svg>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `deploy`
|
|
131
|
+
```svg
|
|
132
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
133
|
+
<line x1="12" y1="19" x2="12" y2="6"/>
|
|
134
|
+
<polyline points="6,12 12,6 18,12"/>
|
|
135
|
+
<line x1="5" y1="20" x2="19" y2="20"/>
|
|
136
|
+
</svg>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### `database`
|
|
140
|
+
```svg
|
|
141
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
142
|
+
<ellipse cx="12" cy="6" rx="7" ry="2.5"/>
|
|
143
|
+
<path d="M5 6v12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/>
|
|
144
|
+
<path d="M5 12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
|
145
|
+
</svg>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `api`
|
|
149
|
+
```svg
|
|
150
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
151
|
+
<polyline points="9,5 3,12 9,19"/>
|
|
152
|
+
<polyline points="15,5 21,12 15,19"/>
|
|
153
|
+
</svg>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### `search`
|
|
157
|
+
```svg
|
|
158
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
159
|
+
<circle cx="10.5" cy="10.5" r="6"/>
|
|
160
|
+
<line x1="15" y1="15" x2="20" y2="20"/>
|
|
161
|
+
</svg>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### `auth`
|
|
165
|
+
```svg
|
|
166
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
167
|
+
<rect x="5" y="11" width="14" height="9" rx="2"/>
|
|
168
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
|
169
|
+
<circle cx="12" cy="15" r="1.3"/>
|
|
170
|
+
</svg>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### `monitoring`
|
|
174
|
+
```svg
|
|
175
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
176
|
+
<polyline points="3,14 8,14 10,8 14,18 16,14 21,14"/>
|
|
177
|
+
</svg>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `cover-image`
|
|
181
|
+
```svg
|
|
182
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
183
|
+
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
|
184
|
+
<circle cx="8.5" cy="10" r="1.5"/>
|
|
185
|
+
<polyline points="4,17 9,12 13,16 16,13 20,17"/>
|
|
186
|
+
</svg>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### `performance`
|
|
190
|
+
```svg
|
|
191
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
192
|
+
<path d="M4 16a8 8 0 0 1 16 0"/>
|
|
193
|
+
<line x1="12" y1="16" x2="16" y2="10.5"/>
|
|
194
|
+
<circle cx="12" cy="16" r="1"/>
|
|
195
|
+
</svg>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### `parsing`
|
|
199
|
+
```svg
|
|
200
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
201
|
+
<path d="M9 4c-2 0-3 1-3 3v3c0 1-1 2-2 2 1 0 2 1 2 2v3c0 2 1 3 3 3"/>
|
|
202
|
+
<path d="M15 4c2 0 3 1 3 3v3c0 1 1 2 2 2-1 0-2 1-2 2v3c0 2-1 3-3 3"/>
|
|
203
|
+
</svg>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### `caching`
|
|
207
|
+
```svg
|
|
208
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
209
|
+
<rect x="5" y="4" width="14" height="4.5" rx="1"/>
|
|
210
|
+
<rect x="5" y="9.75" width="14" height="4.5" rx="1"/>
|
|
211
|
+
<rect x="5" y="15.5" width="14" height="4.5" rx="1"/>
|
|
212
|
+
</svg>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `ui`
|
|
216
|
+
```svg
|
|
217
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
218
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
219
|
+
<line x1="3" y1="8.5" x2="21" y2="8.5"/>
|
|
220
|
+
<line x1="8" y1="4" x2="8" y2="8.5"/>
|
|
221
|
+
</svg>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### `networking`
|
|
225
|
+
```svg
|
|
226
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
227
|
+
<circle cx="12" cy="5" r="2"/>
|
|
228
|
+
<circle cx="5" cy="18" r="2"/>
|
|
229
|
+
<circle cx="19" cy="18" r="2"/>
|
|
230
|
+
<line x1="12" y1="7" x2="5" y2="16"/>
|
|
231
|
+
<line x1="12" y1="7" x2="19" y2="16"/>
|
|
232
|
+
<line x1="7" y1="18" x2="17" y2="18"/>
|
|
233
|
+
</svg>
|
|
234
|
+
```
|
|
@@ -48,6 +48,37 @@ that's a sign to go back to step 2 and find the more specific concept — a post
|
|
|
48
48
|
retrying a flaky network call and a post about deduplicating bank transactions should not
|
|
49
49
|
end up with the same shape family.
|
|
50
50
|
|
|
51
|
+
## Hero-zone grid contract
|
|
52
|
+
|
|
53
|
+
The hero illustration renders inside a fixed bounding box: `x:150 y:425 width:1300 height:400` on the 1600×900 canvas (below the kicker/title area). Draw a container
|
|
54
|
+
element with `id="hero-zone"` at exactly this position and size — `render-cover`
|
|
55
|
+
mechanically checks the rendered `#hero-zone` rect against these numbers (within a 2px
|
|
56
|
+
tolerance for subpixel rounding) and refuses to render if it doesn't match, or if
|
|
57
|
+
`#hero-zone` is missing or duplicated. This is a hard requirement, not a suggestion —
|
|
58
|
+
every post's hero renders inside the identical box so covers stay comparable.
|
|
59
|
+
|
|
60
|
+
Every key point of the hero shape you draw *inside* `#hero-zone` should snap to a 25px coordinate grid (this is prose guidance, not mechanically checked — the guard verifies the outer box only) — pick coordinates as multiples of 25px from `#hero-zone`'s own top-left corner. This fixes near-misses and uneven spacing; it does not mean the shapes themselves must be simple, only that their key points land on a consistent rhythm.
|
|
61
|
+
|
|
62
|
+
**Two named composition slots** — pick one per post:
|
|
63
|
+
- **Single centered hero** — one freehand mechanism, nothing else, inside the hero zone.
|
|
64
|
+
- **Two-node before/after** — a left node, a right node, and a connecting line, all
|
|
65
|
+
three freehand shapes (never catalog icons) — for a post about a transformation or a
|
|
66
|
+
fix.
|
|
67
|
+
|
|
68
|
+
These are placement/proportion guidance, not literal templates — the actual shapes
|
|
69
|
+
inside each slot are still freehand per post.
|
|
70
|
+
|
|
71
|
+
**Catalog icons are never placed inside `#hero-zone`, in either slot.** The mechanism
|
|
72
|
+
and both two-node shapes are always freehand SVG you draw yourself. A catalog icon
|
|
73
|
+
(`image-style/icons.md`) may only appear as a small accent glyph in the kicker/title
|
|
74
|
+
area, entirely outside the hero zone.
|
|
75
|
+
|
|
76
|
+
**Optional kicker-area accent icon.** Independent of which of the two slots you picked, you may add one small accent glyph near the kicker/title area — either a catalog icon (`image-style/icons.md`) or a terminal/code aesthetic glyph (`$`, `>`, `//`, brackets). Never combine the catalog-icon accent and the terminal-glyph accent in the same cover.
|
|
77
|
+
|
|
78
|
+
If you use a catalog-icon accent, it must be positioned with its bottom edge no lower than y:400 — a 25px buffer above the hero zone's y:425 top edge — so it can never clip into the hero zone and trip the geometry guard.
|
|
79
|
+
|
|
80
|
+
For the two-node slot specifically: the accent icon's presence must not be read as belonging to either node; it sits in the kicker/title area purely as a page-level decoration, unrelated to the two-node layout below it.
|
|
81
|
+
|
|
51
82
|
## Technical requirements (non-negotiable)
|
|
52
83
|
|
|
53
84
|
- Start the document with a literal `<!DOCTYPE html>` declaration, always.
|
package/lib/cover_gen.mjs
CHANGED
|
@@ -7,19 +7,32 @@ import { CONFIG_DIR } from './core.mjs';
|
|
|
7
7
|
|
|
8
8
|
const IMAGE_STYLE_DIR = join(CONFIG_DIR, 'image-style');
|
|
9
9
|
const STYLE_GUIDE_PATH = join(IMAGE_STYLE_DIR, 'style-guide.md');
|
|
10
|
+
const ICON_CATALOG_PATH = join(IMAGE_STYLE_DIR, 'icons.md');
|
|
10
11
|
|
|
11
12
|
function slugFromFile(file) {
|
|
12
13
|
return String(file || '').replace(/\.md$/, '');
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
// Pure
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
export function
|
|
19
|
-
if (!existsSync(
|
|
20
|
-
throw new Error(`Cover style guide not found at ${
|
|
16
|
+
// Pure, given explicit paths — exported separately so tests can exercise the
|
|
17
|
+
// missing-icons.md degradation deterministically against a temp directory, without
|
|
18
|
+
// touching this machine's real installed state at CONFIG_DIR.
|
|
19
|
+
export function resolveStyleGuideAndCatalog(styleGuidePath, iconCatalogPath) {
|
|
20
|
+
if (!existsSync(styleGuidePath)) {
|
|
21
|
+
throw new Error(`Cover style guide not found at ${styleGuidePath} — run \`devlog init\` to install it.`);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
const text = readFileSync(styleGuidePath, 'utf8');
|
|
24
|
+
const iconCatalog = existsSync(iconCatalogPath) ? readFileSync(iconCatalogPath, 'utf8') : null;
|
|
25
|
+
return { text, iconCatalog };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reads the installed style guide (no graceful degradation — Claude has nothing to
|
|
29
|
+
// compose from without it; callers (devlog cover-context) catch the throw and surface it
|
|
30
|
+
// as a distinct error, never blocking the rest of publish) plus the installed icon catalog
|
|
31
|
+
// (graceful degradation here: iconCatalog: null when icons.md isn't installed, mirroring
|
|
32
|
+
// the missing-style-guide handling pattern, just one level down — composition proceeds
|
|
33
|
+
// without a catalog rather than blocking).
|
|
34
|
+
export function loadStyleGuide() {
|
|
35
|
+
return resolveStyleGuideAndCatalog(STYLE_GUIDE_PATH, ICON_CATALOG_PATH);
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
// Read one project's manifest.json out of an already-established clone.
|
package/lib/render_cover.mjs
CHANGED
|
@@ -16,6 +16,17 @@ export const DEFAULT_RENDER_TIMEOUT_MS = 15000;
|
|
|
16
16
|
const FONT_PATH = join(homedir(), '.claude', 'skills', 'devlog', 'image-style', 'font.ttf');
|
|
17
17
|
const QUANTIZE_TARGET_BYTES = 500 * 1024;
|
|
18
18
|
|
|
19
|
+
// Fixed hero-zone bounding box on the 1600x900 canvas — the single source of truth this
|
|
20
|
+
// design's prose (image-style/style-guide.example.md) must state identically, checked by
|
|
21
|
+
// tests/skill_contract.test.mjs's COVER-Q-2 invariant rather than trusted to manual review.
|
|
22
|
+
export const HERO_ZONE = { x: 150, y: 425, width: 1300, height: 400 };
|
|
23
|
+
export const HERO_GRID_UNIT = 25;
|
|
24
|
+
// getBoundingClientRect() subpixel/rounding tolerance — not a meaningful size/position
|
|
25
|
+
// allowance. Exact-match (within this tolerance), never containment: a containment check
|
|
26
|
+
// would let an agent draw a tiny #hero-zone in a corner and trivially clear the
|
|
27
|
+
// catalog-overlap check below, since a tiny box is still "contained" in the larger one.
|
|
28
|
+
const HERO_ZONE_TOLERANCE_PX = 2;
|
|
29
|
+
|
|
19
30
|
// Deterministic Node code, never agent-authored text: reads the installed font file and
|
|
20
31
|
// builds a base64 data URI. The font's bytes never pass through Claude's own text
|
|
21
32
|
// generation — a qualitatively different (and much less reliable, at this size) operation
|
|
@@ -59,15 +70,88 @@ async function quantize(pngBuffer) {
|
|
|
59
70
|
return best;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
// Two rects overlap only on positive-area intersection — rects that merely touch along an
|
|
74
|
+
// edge (zero-area overlap) do NOT count as intersecting.
|
|
75
|
+
function rectsOverlap(a, b) {
|
|
76
|
+
const left = Math.max(a.x, b.x);
|
|
77
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
78
|
+
const top = Math.max(a.y, b.y);
|
|
79
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
80
|
+
return right > left && bottom > top;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function withinTolerance(rect, fixed, toleranceExclusivePx) {
|
|
84
|
+
return (
|
|
85
|
+
Math.abs(rect.x - fixed.x) <= toleranceExclusivePx &&
|
|
86
|
+
Math.abs(rect.y - fixed.y) <= toleranceExclusivePx &&
|
|
87
|
+
Math.abs(rect.width - fixed.width) <= toleranceExclusivePx &&
|
|
88
|
+
Math.abs(rect.height - fixed.height) <= toleranceExclusivePx
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {import('playwright').Page} page
|
|
94
|
+
* @returns {Promise<{overlaps: boolean, offendingIcons: string[]}>}
|
|
95
|
+
*
|
|
96
|
+
* #hero-zone is structurally mandatory, not an opt-in marker: throws if querySelectorAll
|
|
97
|
+
* finds zero elements (missing) or more than one (duplicate) — rather than silently
|
|
98
|
+
* skipping the check or resolving to the first DOM match. Once exactly one #hero-zone is
|
|
99
|
+
* confirmed, its rect is compared against the fixed HERO_ZONE constant (exact-match within
|
|
100
|
+
* HERO_ZONE_TOLERANCE_PX, not containment — see the constant's own comment) and throws a
|
|
101
|
+
* distinct geometry-mismatch error if it's outside tolerance, BEFORE computing catalog-icon
|
|
102
|
+
* overlap. Only past both of those checks does this function resolve normally to
|
|
103
|
+
* { overlaps, offendingIcons } — overlap-found and overlap-not-found are both successful
|
|
104
|
+
* resolutions of the check; it is the caller (renderCoverImage) that decides whether
|
|
105
|
+
* overlaps: true itself becomes a thrown error.
|
|
106
|
+
*/
|
|
107
|
+
export async function checkHeroZoneOverlap(page) {
|
|
108
|
+
const heroZoneRects = await page.evaluate(() =>
|
|
109
|
+
[...document.querySelectorAll('#hero-zone')].map((el) => {
|
|
110
|
+
const r = el.getBoundingClientRect();
|
|
111
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (heroZoneRects.length === 0) {
|
|
116
|
+
throw new Error('renderCoverImage: composed HTML has no #hero-zone element — a hero zone marker is required, not optional.');
|
|
117
|
+
}
|
|
118
|
+
if (heroZoneRects.length > 1) {
|
|
119
|
+
throw new Error(`renderCoverImage: composed HTML has ${heroZoneRects.length} elements sharing the #hero-zone id — exactly one is required.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const heroZoneRect = heroZoneRects[0];
|
|
123
|
+
if (!withinTolerance(heroZoneRect, HERO_ZONE, HERO_ZONE_TOLERANCE_PX)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`renderCoverImage: #hero-zone rect (x:${heroZoneRect.x} y:${heroZoneRect.y} width:${heroZoneRect.width} height:${heroZoneRect.height}) ` +
|
|
126
|
+
`does not match the fixed HERO_ZONE box (x:${HERO_ZONE.x} y:${HERO_ZONE.y} width:${HERO_ZONE.width} height:${HERO_ZONE.height}) ` +
|
|
127
|
+
`within ${HERO_ZONE_TOLERANCE_PX}px tolerance.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const iconRects = await page.evaluate(() =>
|
|
132
|
+
[...document.querySelectorAll('[data-catalog-icon]')].map((el) => {
|
|
133
|
+
const r = el.getBoundingClientRect();
|
|
134
|
+
return { name: el.getAttribute('data-catalog-icon'), x: r.x, y: r.y, width: r.width, height: r.height };
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const offendingIcons = iconRects.filter((icon) => rectsOverlap(icon, heroZoneRect)).map((icon) => icon.name);
|
|
139
|
+
return { overlaps: offendingIcons.length > 0, offendingIcons };
|
|
140
|
+
}
|
|
141
|
+
|
|
62
142
|
/**
|
|
63
143
|
* @param {string} html full, self-contained HTML document (must start with <!DOCTYPE html>)
|
|
64
144
|
* @param {{width:number, height:number, timeoutMs?:number, fontPath?:string, executablePath?:string}} opts
|
|
65
145
|
* @returns {Promise<Buffer>} PNG bytes, exactly {width}x{height} pixels
|
|
66
146
|
*
|
|
67
|
-
* Throws on exactly
|
|
68
|
-
* installed; a missing/unreadable installed font file
|
|
69
|
-
*
|
|
70
|
-
*
|
|
147
|
+
* Throws on exactly four realistic failure modes: a render timeout; Chromium not being
|
|
148
|
+
* installed; a missing/unreadable installed font file; and (a deliberate widening of this
|
|
149
|
+
* already-documented throw contract) a #hero-zone structural/geometry problem — missing
|
|
150
|
+
* #hero-zone, duplicate #hero-zone, the #hero-zone rect not matching the fixed HERO_ZONE
|
|
151
|
+
* bounding box within tolerance, or a catalog icon overlapping the hero zone. Does NOT
|
|
152
|
+
* throw on malformed HTML — Chromium's HTML5 parser is deliberately fault-tolerant and
|
|
153
|
+
* recovers into some DOM regardless of input; a poorly composed document renders wrong, it
|
|
154
|
+
* doesn't fail to render.
|
|
71
155
|
*/
|
|
72
156
|
export async function renderCoverImage(html, opts = {}) {
|
|
73
157
|
const {
|
|
@@ -144,6 +228,17 @@ export async function renderCoverImage(html, opts = {}) {
|
|
|
144
228
|
),
|
|
145
229
|
]);
|
|
146
230
|
|
|
231
|
+
// Geometry-enforced hero-zone guard, immediately before the screenshot: throws on a
|
|
232
|
+
// missing/duplicate #hero-zone, a #hero-zone rect that doesn't match the fixed
|
|
233
|
+
// HERO_ZONE box, or (below) a catalog icon whose rect overlaps the hero zone.
|
|
234
|
+
const { overlaps, offendingIcons } = await checkHeroZoneOverlap(page);
|
|
235
|
+
if (overlaps) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`renderCoverImage: catalog icon(s) [${offendingIcons.join(', ')}] overlaps hero zone — ` +
|
|
238
|
+
'catalog icons may only appear as an accent glyph outside #hero-zone, never inside it.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
147
242
|
// Viewport-clipped screenshot (fullPage omitted/false, Playwright's default) — never
|
|
148
243
|
// fullPage: true, which would capture the whole scrollable page rather than just the
|
|
149
244
|
// viewport. This is what guarantees the output is always exactly {width, height}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
package/skill-invariants.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"comment": "Prose guardrails in SKILL.md that must survive edits
|
|
2
|
+
"comment": "Prose guardrails in SKILL.md that must survive edits (the `prose` array — each pattern is a case-insensitive regex tested against the full SKILL.md text) and code-level guards elsewhere in this skill's source (the `code` array — each pattern is tested against the file named by its own `file` field, not SKILL.md). Both are checked by tests/skill_contract.test.mjs. If you intentionally change one, update it here in the same commit and say why in the PR.",
|
|
3
3
|
"prose": [
|
|
4
4
|
{
|
|
5
5
|
"id": "immutable-entries",
|
|
@@ -84,7 +84,15 @@
|
|
|
84
84
|
{
|
|
85
85
|
"id": "cover-custom-illustration",
|
|
86
86
|
"pattern": "cover that just re-renders the title in large text is a failure",
|
|
87
|
-
"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."
|
|
87
|
+
"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. Independent of, not superseded by, the v0.9.0 geometry guard (cover-catalog-hero-overlap-guard, below): that guard is a mechanical check on an agent's rendered composition each time a cover is rendered; this line is a prose guardrail against a future SKILL.md/style-guide edit silently reintroducing the bland-template regression at the instruction level. Two different regression surfaces, both still worth guarding."
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"code": [
|
|
91
|
+
{
|
|
92
|
+
"id": "cover-catalog-hero-overlap-guard",
|
|
93
|
+
"file": "lib/render_cover.mjs",
|
|
94
|
+
"pattern": "(?:getBoundingClientRect[\\s\\S]{0,400}hero-zone|hero-zone[\\s\\S]{0,400}getBoundingClientRect)",
|
|
95
|
+
"rationale": "The catalog-icon/hero-zone overlap check must stay wired into renderCoverImage() — losing it silently reopens the gap where a catalog icon (or two, connected by a line) can stand in for the required bespoke hero illustration."
|
|
88
96
|
}
|
|
89
97
|
],
|
|
90
98
|
"cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover"]
|