@natjswenson/devlog 0.8.1 → 0.10.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 +46 -18
- package/bin/devlog.js +19 -6
- package/image-style/icons.md +235 -0
- package/image-style/style-guide.example.md +194 -26
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +12 -0
- package/lib/cover_gen.mjs +20 -7
- package/lib/migrate_entry_numbers.mjs +116 -0
- package/lib/publish_entry.mjs +46 -2
- package/lib/render_cover.mjs +99 -4
- package/lib/scan.mjs +7 -3
- package/package.json +1 -1
- package/skill-invariants.json +10 -2
package/SKILL.md
CHANGED
|
@@ -41,7 +41,11 @@ Map the user's request onto the CLI — never hand-edit `config.json`:
|
|
|
41
41
|
| Show config | `npx -y @natjswenson/devlog config --json` |
|
|
42
42
|
| Add a project | `npx -y @natjswenson/devlog add-project --yes --path <abs-path> [--key K] [--remote O/R] [--label L] [--tag-prefix P] [--path-filter F] [--private]` |
|
|
43
43
|
| Remove a project | `npx -y @natjswenson/devlog remove-project <key> --yes` |
|
|
44
|
-
| Change a setting | `npx -y @natjswenson/devlog set <field> <value>` (settable: `targetRepo`, `branch`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
44
|
+
| Change a setting | `npx -y @natjswenson/devlog set <field> <value>` (settable: `targetRepo`, `branch`, `targetDir`, `gitAuthor`, `githubUser`, `voicePath`, `deepDive.minSources`, `deepDive.topicDomains`) |
|
|
45
|
+
|
|
46
|
+
`targetDir` is the subdirectory of `targetRepo` that holds the devlog content tree
|
|
47
|
+
(e.g. `content/devlog` when the target is the site repo itself); unset/empty means the
|
|
48
|
+
repo root. Set it with `set targetDir content/devlog`, clear it with `set targetDir ''`.
|
|
45
49
|
|
|
46
50
|
For **add-project**: resolve the path first (the repo the user named, or the cwd), then
|
|
47
51
|
detect what the CLI will use — key = directory basename, remote = `git -C '<path>' remote
|
|
@@ -291,8 +295,13 @@ critique, not a rubber stamp.
|
|
|
291
295
|
|
|
292
296
|
### Step 5: Publish
|
|
293
297
|
|
|
294
|
-
Clone once, publish each entry through the CLI, push once. `targetRepo
|
|
295
|
-
from validated config
|
|
298
|
+
Clone once, publish each entry through the CLI, push once. `targetRepo`, `branch`, and
|
|
299
|
+
`targetDir` come from validated config (all echoed in the scan output) — still
|
|
300
|
+
single-quote every interpolated value.
|
|
301
|
+
|
|
302
|
+
The `--clone` flag always points at the CONTENT ROOT: `<abs-tmp>/<repo-name>` when
|
|
303
|
+
`targetDir` is empty, `<abs-tmp>/<repo-name>/<targetDir>` when it's set. Git commands
|
|
304
|
+
always run against the clone root `<abs-tmp>/<repo-name>` regardless.
|
|
296
305
|
|
|
297
306
|
Each release also gets a cover image, composed inline in this same loop right before that
|
|
298
307
|
release's own `publish-entry` call — a self-contained HTML/CSS (or inline SVG) document,
|
|
@@ -301,30 +310,44 @@ rasterized locally, never sent to any external service:
|
|
|
301
310
|
```bash
|
|
302
311
|
mktemp -d # → record the absolute path, e.g. /var/folders/.../tmp.abc
|
|
303
312
|
git -C '<abs-tmp>' clone --depth=1 'https://github.com/<targetRepo>.git'
|
|
313
|
+
# <content-root> = '<abs-tmp>/<repo-name>/<targetDir>' if targetDir is set,
|
|
314
|
+
# '<abs-tmp>/<repo-name>' otherwise.
|
|
304
315
|
|
|
305
316
|
# Per release (refuses to overwrite an existing entry — on {"error": ...,
|
|
306
317
|
# "message": "... immutable ..."} skip that release and note it):
|
|
307
318
|
|
|
308
319
|
# 1. Style guide + up to 3 reference images of recently published covers.
|
|
309
320
|
npx -y @natjswenson/devlog cover-context '<key>' '<version>' \
|
|
310
|
-
--clone '<
|
|
321
|
+
--clone '<content-root>'
|
|
311
322
|
# On {"error": "style-guide-missing", ...}: skip cover composition for this release
|
|
312
323
|
# entirely — proceed straight to publish-entry with no --cover flag. Never block
|
|
313
324
|
# publish on a missing style guide.
|
|
314
325
|
|
|
315
326
|
# 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
|
-
#
|
|
327
|
+
# (never the raw draft file, never `## Changelog`) plus the returned style guide,
|
|
328
|
+
# icon catalog, and reference images. A cover that just re-renders the title in large text is a failure —
|
|
329
|
+
# find the one concrete mechanism this release is actually about
|
|
330
|
+
# (not the project name, not "a bug fix") and draw ONE custom inline-SVG illustration
|
|
331
|
+
# of it, sized as the dominant visual element of the canvas; title/kicker stay
|
|
332
|
+
# secondary. Two different releases should never produce visually similar covers.
|
|
333
|
+
#
|
|
334
|
+
# Draw the illustration inside a `#hero-zone` container at exactly
|
|
335
|
+
# `x:150 y:425 width:1300 height:400` (render-cover mechanically checks this box and
|
|
336
|
+
# refuses to render otherwise) — pick ONE of two composition slots per post: single
|
|
337
|
+
# centered hero (one freehand mechanism, nothing else), or two-node before/after (a
|
|
338
|
+
# left node, a right node, a connecting line, all freehand). Snap interior key points
|
|
339
|
+
# to a 25px coordinate grid. Catalog icons (image-style/icons.md) are never placed
|
|
340
|
+
# inside `#hero-zone` — they may only appear as an optional small accent glyph near
|
|
341
|
+
# the kicker/title area, entirely outside the hero zone, its bottom edge no lower than
|
|
342
|
+
# y:400. See the style guide's hero-zone grid contract section before composing.
|
|
343
|
+
# Write the result with the Write tool to '<abs-scratch>/<key>/<version>.html' — a
|
|
344
|
+
# full document starting with `<!DOCTYPE html>`, sized
|
|
345
|
+
# `html, body { margin:0; width:1600px; height:900px; }`, referencing the bundled
|
|
346
|
+
# font only as `font-family: 'DevlogCoverFont', sans-serif`.
|
|
347
|
+
|
|
348
|
+
# 3. Rasterize it. On failure (render timeout / Chromium not installed / font missing /
|
|
349
|
+
# a #hero-zone problem — missing, duplicate, wrong position/size, or a catalog icon
|
|
350
|
+
# overlapping it),
|
|
328
351
|
# the .html is left in place for debugging — retry composing once with the error text
|
|
329
352
|
# fed back, or give up and proceed with no --cover flag.
|
|
330
353
|
npx -y @natjswenson/devlog render-cover '<abs-scratch>/<key>/<version>.html' \
|
|
@@ -334,7 +357,7 @@ npx -y @natjswenson/devlog render-cover '<abs-scratch>/<key>/<version>.html' \
|
|
|
334
357
|
# the prose.
|
|
335
358
|
|
|
336
359
|
npx -y @natjswenson/devlog publish-entry \
|
|
337
|
-
--clone '<
|
|
360
|
+
--clone '<content-root>' --project '<key>' \
|
|
338
361
|
--version '<version>' --entry '<abs-draft-path>' \
|
|
339
362
|
--cover '<abs-scratch>/<key>/<version>.png'
|
|
340
363
|
# Omit --cover entirely if no cover was produced for this release (missing style guide,
|
|
@@ -356,9 +379,14 @@ Release dev log entries published
|
|
|
356
379
|
Project: <key>
|
|
357
380
|
Releases: <version>, ...
|
|
358
381
|
Judged weaknesses: <residuals from Step 4, or "none">
|
|
359
|
-
URL: https://github.com/<targetRepo>/blob/<branch>/<key>/<version>.md
|
|
382
|
+
URL: https://github.com/<targetRepo>/blob/<branch>/<targetDir-prefix><key>/<version>.md
|
|
360
383
|
```
|
|
361
384
|
|
|
385
|
+
(`<targetDir-prefix>` is `<targetDir>/` when set, empty otherwise.) When the target is
|
|
386
|
+
a site repo that auto-deploys on push (e.g. Cloudflare Pages watching `main`), the
|
|
387
|
+
publish push itself triggers the rebuild — mention that the entry goes live with the
|
|
388
|
+
next deploy.
|
|
389
|
+
|
|
362
390
|
## Security rules
|
|
363
391
|
|
|
364
392
|
The CLI validates all config fields and excludes unsafe tag names before they reach you,
|
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,235 @@
|
|
|
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: #181510` (ink) or `color: #6E675C` (dim) — never
|
|
6
|
+
`fill`, and never the signature orange: per the style guide, the accent icon is
|
|
7
|
+
structure, not the cover's one loud moment. Wrap every usage in a container carrying `data-catalog-icon="<name>"`
|
|
8
|
+
(the exact `<name>` from the table below) — `render-cover`'s geometry guard reads this
|
|
9
|
+
attribute to confirm no catalog icon has drifted into the hero zone.
|
|
10
|
+
|
|
11
|
+
**These are for the kicker-area accent glyph only** (see `style-guide.md`'s hero-zone
|
|
12
|
+
grid contract) — never for the hero illustration's own mechanism/nodes, which are always
|
|
13
|
+
freehand SVG the agent draws itself. Look up a concept below instead of re-deriving an
|
|
14
|
+
icon from scratch; if a post's concept doesn't map cleanly to any of these 20, that's a
|
|
15
|
+
signal the post doesn't need an accent icon at all (a terminal-glyph accent or no accent
|
|
16
|
+
is always a valid choice — see `style-guide.md`).
|
|
17
|
+
|
|
18
|
+
## Topic → icon cheat sheet
|
|
19
|
+
|
|
20
|
+
| Topic / keywords in title or summary | Icon |
|
|
21
|
+
|---|---|
|
|
22
|
+
| agent, LLM, Claude, subagent, prompt | `agents` |
|
|
23
|
+
| test, testing, assert, spec, coverage | `testing` |
|
|
24
|
+
| CI, CD, pipeline, workflow, build | `ci-cd` |
|
|
25
|
+
| git, commit, branch, merge, tag | `git` |
|
|
26
|
+
| a11y, accessibility, aria, screen reader | `accessibility` |
|
|
27
|
+
| debug, bug, fix, root cause, trace | `debugging` |
|
|
28
|
+
| CLI, command, terminal, flag, argv | `cli` |
|
|
29
|
+
| config, settings, options, flags | `config` |
|
|
30
|
+
| deploy, release, ship, publish, rollout | `deploy` |
|
|
31
|
+
| database, manifest, schema, storage | `database` |
|
|
32
|
+
| API, endpoint, request, response | `api` |
|
|
33
|
+
| search, filter, query, lookup | `search` |
|
|
34
|
+
| auth, login, token, credential, permission | `auth` |
|
|
35
|
+
| monitor, metric, telemetry, dashboard | `monitoring` |
|
|
36
|
+
| cover, image, render, screenshot, thumbnail | `cover-image` |
|
|
37
|
+
| performance, speed, latency, throughput | `performance` |
|
|
38
|
+
| parse, parser, tokenize, frontmatter | `parsing` |
|
|
39
|
+
| cache, staging, memoize, invalidate | `caching` |
|
|
40
|
+
| UI, layout, component, page, nav | `ui` |
|
|
41
|
+
| network, remote, fetch, clone, push/pull | `networking` |
|
|
42
|
+
|
|
43
|
+
## Icons
|
|
44
|
+
|
|
45
|
+
### `agents`
|
|
46
|
+
```svg
|
|
47
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
48
|
+
<rect x="5" y="8" width="14" height="11" rx="2"/>
|
|
49
|
+
<line x1="12" y1="8" x2="12" y2="4"/>
|
|
50
|
+
<circle cx="12" cy="3" r="1"/>
|
|
51
|
+
<circle cx="9" cy="13" r="1.2"/>
|
|
52
|
+
<circle cx="15" cy="13" r="1.2"/>
|
|
53
|
+
<line x1="9" y1="17" x2="15" y2="17"/>
|
|
54
|
+
</svg>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `testing`
|
|
58
|
+
```svg
|
|
59
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
60
|
+
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
|
61
|
+
<polyline points="8,12.5 11,15.5 16,9"/>
|
|
62
|
+
</svg>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `ci-cd`
|
|
66
|
+
```svg
|
|
67
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
68
|
+
<circle cx="4.5" cy="12" r="2"/>
|
|
69
|
+
<circle cx="12" cy="12" r="2"/>
|
|
70
|
+
<circle cx="19.5" cy="12" r="2"/>
|
|
71
|
+
<line x1="6.5" y1="12" x2="10" y2="12"/>
|
|
72
|
+
<line x1="14" y1="12" x2="17.5" y2="12"/>
|
|
73
|
+
<polyline points="15.5,10 17.5,12 15.5,14"/>
|
|
74
|
+
</svg>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `git`
|
|
78
|
+
```svg
|
|
79
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
80
|
+
<circle cx="6" cy="6" r="2"/>
|
|
81
|
+
<circle cx="6" cy="18" r="2"/>
|
|
82
|
+
<circle cx="18" cy="10" r="2"/>
|
|
83
|
+
<line x1="6" y1="8" x2="6" y2="16"/>
|
|
84
|
+
<path d="M6 8 C6 10, 8 10, 12 10 S18 10, 18 12"/>
|
|
85
|
+
</svg>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `accessibility`
|
|
89
|
+
```svg
|
|
90
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
91
|
+
<circle cx="12" cy="12" r="9"/>
|
|
92
|
+
<circle cx="12" cy="7.5" r="1.4"/>
|
|
93
|
+
<line x1="7" y1="11" x2="17" y2="11"/>
|
|
94
|
+
<line x1="12" y1="11" x2="12" y2="15"/>
|
|
95
|
+
<line x1="12" y1="15" x2="9" y2="18.5"/>
|
|
96
|
+
<line x1="12" y1="15" x2="15" y2="18.5"/>
|
|
97
|
+
</svg>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### `debugging`
|
|
101
|
+
```svg
|
|
102
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
103
|
+
<rect x="8" y="8" width="8" height="10" rx="4"/>
|
|
104
|
+
<line x1="12" y1="4" x2="12" y2="8"/>
|
|
105
|
+
<line x1="6" y1="10" x2="8" y2="11"/>
|
|
106
|
+
<line x1="6" y1="14" x2="8" y2="14"/>
|
|
107
|
+
<line x1="6" y1="18" x2="8" y2="17"/>
|
|
108
|
+
<line x1="18" y1="10" x2="16" y2="11"/>
|
|
109
|
+
<line x1="18" y1="14" x2="16" y2="14"/>
|
|
110
|
+
<line x1="18" y1="18" x2="16" y2="17"/>
|
|
111
|
+
</svg>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `cli`
|
|
115
|
+
```svg
|
|
116
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
117
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
118
|
+
<polyline points="7,10 10,12.5 7,15"/>
|
|
119
|
+
<line x1="12" y1="15" x2="16" y2="15"/>
|
|
120
|
+
</svg>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### `config`
|
|
124
|
+
```svg
|
|
125
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
126
|
+
<circle cx="12" cy="12" r="3"/>
|
|
127
|
+
<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"/>
|
|
128
|
+
</svg>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `deploy`
|
|
132
|
+
```svg
|
|
133
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
134
|
+
<line x1="12" y1="19" x2="12" y2="6"/>
|
|
135
|
+
<polyline points="6,12 12,6 18,12"/>
|
|
136
|
+
<line x1="5" y1="20" x2="19" y2="20"/>
|
|
137
|
+
</svg>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `database`
|
|
141
|
+
```svg
|
|
142
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
143
|
+
<ellipse cx="12" cy="6" rx="7" ry="2.5"/>
|
|
144
|
+
<path d="M5 6v12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/>
|
|
145
|
+
<path d="M5 12c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>
|
|
146
|
+
</svg>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `api`
|
|
150
|
+
```svg
|
|
151
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
152
|
+
<polyline points="9,5 3,12 9,19"/>
|
|
153
|
+
<polyline points="15,5 21,12 15,19"/>
|
|
154
|
+
</svg>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### `search`
|
|
158
|
+
```svg
|
|
159
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
160
|
+
<circle cx="10.5" cy="10.5" r="6"/>
|
|
161
|
+
<line x1="15" y1="15" x2="20" y2="20"/>
|
|
162
|
+
</svg>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `auth`
|
|
166
|
+
```svg
|
|
167
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
168
|
+
<rect x="5" y="11" width="14" height="9" rx="2"/>
|
|
169
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4"/>
|
|
170
|
+
<circle cx="12" cy="15" r="1.3"/>
|
|
171
|
+
</svg>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `monitoring`
|
|
175
|
+
```svg
|
|
176
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
177
|
+
<polyline points="3,14 8,14 10,8 14,18 16,14 21,14"/>
|
|
178
|
+
</svg>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `cover-image`
|
|
182
|
+
```svg
|
|
183
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
184
|
+
<rect x="3" y="5" width="18" height="14" rx="2"/>
|
|
185
|
+
<circle cx="8.5" cy="10" r="1.5"/>
|
|
186
|
+
<polyline points="4,17 9,12 13,16 16,13 20,17"/>
|
|
187
|
+
</svg>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### `performance`
|
|
191
|
+
```svg
|
|
192
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
193
|
+
<path d="M4 16a8 8 0 0 1 16 0"/>
|
|
194
|
+
<line x1="12" y1="16" x2="16" y2="10.5"/>
|
|
195
|
+
<circle cx="12" cy="16" r="1"/>
|
|
196
|
+
</svg>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### `parsing`
|
|
200
|
+
```svg
|
|
201
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
202
|
+
<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"/>
|
|
203
|
+
<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"/>
|
|
204
|
+
</svg>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `caching`
|
|
208
|
+
```svg
|
|
209
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
210
|
+
<rect x="5" y="4" width="14" height="4.5" rx="1"/>
|
|
211
|
+
<rect x="5" y="9.75" width="14" height="4.5" rx="1"/>
|
|
212
|
+
<rect x="5" y="15.5" width="14" height="4.5" rx="1"/>
|
|
213
|
+
</svg>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### `ui`
|
|
217
|
+
```svg
|
|
218
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
219
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
|
220
|
+
<line x1="3" y1="8.5" x2="21" y2="8.5"/>
|
|
221
|
+
<line x1="8" y1="4" x2="8" y2="8.5"/>
|
|
222
|
+
</svg>
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `networking`
|
|
226
|
+
```svg
|
|
227
|
+
<svg viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
228
|
+
<circle cx="12" cy="5" r="2"/>
|
|
229
|
+
<circle cx="5" cy="18" r="2"/>
|
|
230
|
+
<circle cx="19" cy="18" r="2"/>
|
|
231
|
+
<line x1="12" y1="7" x2="5" y2="16"/>
|
|
232
|
+
<line x1="12" y1="7" x2="19" y2="16"/>
|
|
233
|
+
<line x1="7" y1="18" x2="17" y2="18"/>
|
|
234
|
+
</svg>
|
|
235
|
+
```
|
|
@@ -36,8 +36,9 @@ Before writing any HTML, do this thinking step explicitly:
|
|
|
36
36
|
timestamp, two paths diverging and one being cut off.
|
|
37
37
|
3. Design ONE illustration — built from inline SVG shapes (lines, arcs, polygons,
|
|
38
38
|
simple geometric forms) — that depicts that concept. Not a photo, not a stock icon,
|
|
39
|
-
not a screenshot: a small original line-art scene,
|
|
40
|
-
|
|
39
|
+
not a screenshot: a small original line-art scene, **editorial line art in ink with
|
|
40
|
+
sparing orange**, in the spirit of a newspaper diagram or a technical schematic, using
|
|
41
|
+
only the palette below.
|
|
41
42
|
4. That illustration is the dominant visual element of the cover — roughly half the
|
|
42
43
|
canvas, not a thumbnail in the corner. Title, kicker, and summary text support it;
|
|
43
44
|
they do not replace it.
|
|
@@ -48,6 +49,67 @@ that's a sign to go back to step 2 and find the more specific concept — a post
|
|
|
48
49
|
retrying a flaky network call and a post about deduplicating bank transactions should not
|
|
49
50
|
end up with the same shape family.
|
|
50
51
|
|
|
52
|
+
## Hero-zone grid contract
|
|
53
|
+
|
|
54
|
+
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
|
|
55
|
+
element with `id="hero-zone"` at exactly this position and size — `render-cover`
|
|
56
|
+
mechanically checks the rendered `#hero-zone` rect against these numbers (within a 2px
|
|
57
|
+
tolerance for subpixel rounding) and refuses to render if it doesn't match, or if
|
|
58
|
+
`#hero-zone` is missing or duplicated. This is a hard requirement, not a suggestion —
|
|
59
|
+
every post's hero renders inside the identical box so covers stay comparable. It is also a
|
|
60
|
+
convenient coincidence worth using: the box's 150px left/right insets are the same
|
|
61
|
+
outer margin the masthead and headline above it should sit inside, so the hero zone, the
|
|
62
|
+
eyebrow row, and the headline all line up on one shared left edge.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
**Fill the zone — this is not optional.** `render-cover` only checks the outer `#hero-zone` box; nothing mechanically stops you from drawing something small inside a mostly-empty rectangle, so this rule is on you, checkable by eye. The illustration's own ink — the actual bounding box of the shapes you draw, not the container — must cover at least 70% of the zone's 1300px width and at least 60% of its 400px height. A thin horizontal band of marks sitting low in the zone, with a large empty margin above it, is a specific and common failure: it reads as top-heavy — headline, then a dead void, then a sliver of drawing — rather than as one composed image. Distribute the drawing's mass across the zone's full height, not just its width: let elements reach toward both y:0 and y:400 of the zone's own coordinate space (labels, connecting lines, secondary marks all count), not cluster near one edge.
|
|
67
|
+
|
|
68
|
+
**Bridge a short headline — the band above the zone is yours to fill.** The hero zone
|
|
69
|
+
starts at a fixed `y:425` no matter how tall the headline is. A two-line headline ends
|
|
70
|
+
near `y:270` and leaves a comfortable ~155px of paper; a **one-line headline ends near
|
|
71
|
+
`y:215` and leaves ~210px of dead air** that no amount of good drawing inside the zone
|
|
72
|
+
can close — the cover reads as a headline, a void, then an unrelated diagram. When your
|
|
73
|
+
headline renders on one line, you must bridge that band. In order of preference:
|
|
74
|
+
- a **standfirst** — one serif-italic line behind a 5px orange left rule, restating the
|
|
75
|
+
mechanism in plain words (this is the PRESS `.stand`, and it is the best answer);
|
|
76
|
+
- a **ledger strip** — two or three mono `label · value` pairs on one row, over a 2px ink
|
|
77
|
+
rule, carrying real numbers from `## Shipped`;
|
|
78
|
+
- letting the illustration's own **labels or axis titles** rise into the band, so the
|
|
79
|
+
drawing visually begins before the zone does.
|
|
80
|
+
|
|
81
|
+
Never leave the band empty under a one-line headline. Whitespace is part of the brand
|
|
82
|
+
only when it's *between* composed elements — a gap with nothing on either side of it is
|
|
83
|
+
just a hole.
|
|
84
|
+
|
|
85
|
+
**Two named composition slots** — pick one per post:
|
|
86
|
+
- **Single centered hero** — one freehand mechanism, nothing else, inside the hero zone.
|
|
87
|
+
- **Two-node before/after** — a left node, a right node, and a connecting line, all
|
|
88
|
+
three freehand shapes (never catalog icons) — for a post about a transformation or a
|
|
89
|
+
fix.
|
|
90
|
+
|
|
91
|
+
These are placement/proportion guidance, not literal templates — the actual shapes
|
|
92
|
+
inside each slot are still freehand per post.
|
|
93
|
+
|
|
94
|
+
**A third option, when the post is genuinely about code or a terminal session:** the
|
|
95
|
+
`.term` panel treatment (see Palette below) may fill some or all of `#hero-zone` instead
|
|
96
|
+
of a freehand mechanism — a real, believable command/output snippet drawn from the
|
|
97
|
+
`## Shipped` text, not invented. This is the one place the old dark palette survives, and
|
|
98
|
+
only here: never let the dark panel bleed to the canvas edges — keep at least the same
|
|
99
|
+
25px-grid margin of paper visible around it inside the hero zone so it reads as an object
|
|
100
|
+
floating on the page, not a background.
|
|
101
|
+
|
|
102
|
+
**Catalog icons are never placed inside `#hero-zone`, in either slot.** The mechanism
|
|
103
|
+
and both two-node shapes are always freehand SVG you draw yourself. A catalog icon
|
|
104
|
+
(`image-style/icons.md`) may only appear as a small accent glyph in the kicker/title
|
|
105
|
+
area, entirely outside the hero zone.
|
|
106
|
+
|
|
107
|
+
**Optional kicker-area accent icon — anchored, never floating.** 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). It needs a defined home: sit it directly against the eyebrow row, baseline-aligned, immediately before or after the eyebrow text, so it reads as part of that line rather than a shape adrift in empty space. If there's nowhere for it to attach this cleanly, leave it out — an omitted accent icon is a better cover than a floating one, which is exactly why this element is optional. Never combine the catalog-icon accent and the terminal-glyph accent in the same cover. Color it ink or dim (`#181510` / `#6E675C`), never the signature orange — the accent icon is a small piece of structure, not the cover's one loud moment.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
51
113
|
## Technical requirements (non-negotiable)
|
|
52
114
|
|
|
53
115
|
- Start the document with a literal `<!DOCTYPE html>` declaration, always.
|
|
@@ -56,8 +118,15 @@ end up with the same shape family.
|
|
|
56
118
|
simply never captured, so keep everything inside it.
|
|
57
119
|
- Reference the bundled font only by its fixed name, with a fallback:
|
|
58
120
|
`font-family: 'DevlogCoverFont', sans-serif;` — never embed font bytes yourself, never
|
|
59
|
-
reference any other font file. The renderer injects the real font
|
|
60
|
-
parsed.
|
|
121
|
+
reference any other font file. The renderer injects the real font (a monospace face)
|
|
122
|
+
after your markup is parsed. **This is the only font whose bytes are ever loaded** — but
|
|
123
|
+
it is not the only font-family value you're allowed to write. The Typography section
|
|
124
|
+
below asks for a serif voice and a display voice as well; get those from this rendering
|
|
125
|
+
host's own built-in system fonts (`Georgia`, `ui-serif`, `-apple-system`, generic
|
|
126
|
+
`serif`/`sans-serif`), the same way the `sans-serif` fallback above already works before
|
|
127
|
+
`DevlogCoverFont` finishes loading. That's resolving a name the browser already has, not
|
|
128
|
+
embedding or fetching a file — no different in kind from the fallback this rule already
|
|
129
|
+
requires.
|
|
61
130
|
- No external resources of any kind — no `<link>`, no `@import`, no remote `<img src>`,
|
|
62
131
|
no web fonts, no raster images. All artwork is inline SVG built from basic shapes
|
|
63
132
|
(`<path>`, `<circle>`, `<rect>`, `<line>`, `<polygon>`, `<polyline>`) — everything must
|
|
@@ -65,25 +134,98 @@ end up with the same shape family.
|
|
|
65
134
|
|
|
66
135
|
## Visual direction
|
|
67
136
|
|
|
68
|
-
The site (natejswenson.com)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
- **
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
137
|
+
The site (natejswenson.com) runs on **PRESS** — a warm-paper editorial-poster brand:
|
|
138
|
+
huge black type, a serif standfirst voice, one loud signature accent, heavy ink rules, and
|
|
139
|
+
a personal monogram stamp. Covers should read as an issue of the same publication as the
|
|
140
|
+
site, not a marketing graphic and not a repeated template.
|
|
141
|
+
|
|
142
|
+
### Palette
|
|
143
|
+
|
|
144
|
+
- **Paper** `#F5F0E6` — the canvas background. Flat. No gradient, no vignette, no texture.
|
|
145
|
+
- **Ink** `#181510` — headline, structural rules, the stamp's frame when not orange, most
|
|
146
|
+
line art.
|
|
147
|
+
- **Dim** `#6E675C` — secondary text: date/meta, captions, dim linework.
|
|
148
|
+
- **Tertiary** `#8A8272` — decorative use only inside the illustration (a faint
|
|
149
|
+
background element), never body text, never the headline.
|
|
150
|
+
- **Signature** `#E8501F` (orange) — the ONE loud accent. See The accent law below.
|
|
151
|
+
- **Term panel** (only inside a `.term` hero, see above) — background `#141A26`, text
|
|
152
|
+
`#EFE9DC`, dim text `#8A8478`, hot/verdict line `#FF8A5C`. This quartet never appears
|
|
153
|
+
outside a `.term` panel.
|
|
154
|
+
|
|
155
|
+
Prefer flat, limited color and solid/line fills over gradients or smooth shading — the
|
|
156
|
+
render is compressed with lossy PNG palette quantization afterward, and gradients band
|
|
157
|
+
visibly under that compression while flat fills don't.
|
|
158
|
+
|
|
159
|
+
### The accent law, carried to covers
|
|
160
|
+
|
|
161
|
+
Orange is a signature, not a color scheme. On one cover it may appear as:
|
|
162
|
+
- the **stamp** (border + `NS` letters) — always, every cover.
|
|
163
|
+
- **at most one pivot phrase** inside the headline (`<span>` colored orange) — optional,
|
|
164
|
+
use it when one word or short phrase is genuinely the point. **Cap it at two words.** A
|
|
165
|
+
three-or-more-word orange run stops reading as a pivot and starts reading as a second
|
|
166
|
+
headline, and it eats the budget the illustration needs. If the point can't be made in
|
|
167
|
+
two words, leave the headline entirely ink and let the drawing carry the signature.
|
|
168
|
+
- **at most one orange element in the illustration, and only one of these two kinds:**
|
|
169
|
+
either a *fill* (one payoff bar, one win-state shape) or *thin marks* (a cap line, a
|
|
170
|
+
pivot tick, a labelled dot) — never both, and never a fill larger than the payoff itself.
|
|
171
|
+
Count the headline pivot against this too: a cover with an orange pivot phrase AND a
|
|
172
|
+
large orange fill is over budget. One loud thing per cover, plus the stamp.
|
|
173
|
+
- **a numeral, only if it's real.** If the `## Shipped` text hands you an actual number
|
|
174
|
+
worth calling out (a count, a duration, a percentage), it may run in orange, large. Never
|
|
175
|
+
invent a sequential "No. 042"-style issue number to fill an eyebrow — devlog doesn't
|
|
176
|
+
pass covers a real published-count field the way the site's own pages do, and a
|
|
177
|
+
fabricated one is exactly the ornamental-prop failure mode in Never do, below.
|
|
178
|
+
- **thin marks or rules inside the illustration** — sparing use, one or two strokes, never
|
|
179
|
+
a fill.
|
|
180
|
+
- **`.hot` lines inside a `.term` panel** — panel-internal, doesn't count against the
|
|
181
|
+
budget above.
|
|
182
|
+
|
|
183
|
+
Everything else is ink, or dim for secondary weight. If you're not sure whether something
|
|
184
|
+
should be orange, it should be ink — when orange shows up as the fill of three different
|
|
185
|
+
shapes, the badge, and the border all on one cover, it stops being a signature and starts
|
|
186
|
+
being a rainbow.
|
|
187
|
+
|
|
188
|
+
### Typography — three voices
|
|
189
|
+
|
|
190
|
+
Match the site's own split: **display** = structure (headline, eyebrow, numerals, labels),
|
|
191
|
+
**serif italic** = commentary (a short standfirst/caption, if you use one), **mono** =
|
|
192
|
+
data (dates, tags, terminal text, inline code).
|
|
193
|
+
|
|
194
|
+
| Role | Font stack | Notes |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| Headline, eyebrow, numerals, labels, stamp letters | `-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif` | weight 800–900, tight tracking (`-0.02em` to `-0.03em` on the headline) |
|
|
197
|
+
| Standfirst / caption (optional, ≤1 short line) | `Georgia, 'Times New Roman', ui-serif, serif`, italic | secondary ink or dim |
|
|
198
|
+
| Dates, tags, terminal/code text | `'DevlogCoverFont', ui-monospace, 'SF Mono', Menlo, monospace` | the injected font is a monospace face, so this voice is where it actually renders as intended; everything else falls back to a system font gracefully |
|
|
199
|
+
|
|
200
|
+
Keep the title modest — it is not the main event, the illustration is. A short kicker
|
|
201
|
+
(project name, plus a date if you know it) is enough context; don't restate the whole
|
|
202
|
+
summary as on-image text.
|
|
203
|
+
|
|
204
|
+
### Composition
|
|
205
|
+
|
|
206
|
+
- The illustration occupies the dominant visual weight of the canvas — inside `#hero-zone`,
|
|
207
|
+
large enough to read at a glance, with the masthead/headline in the negative space above
|
|
208
|
+
it (never overlapping the artwork).
|
|
209
|
+
- Masthead row near the top: a small stamp (a square, ~2.5–3px orange border, `NS`
|
|
210
|
+
in orange, `transform: rotate(-4deg)`) plus an eyebrow — the project name in tracked
|
|
211
|
+
caps, ink. A heavy ink rule (6–8px) above this row, matching the site's own page-frame
|
|
212
|
+
weight, reads as the cover's top edge.
|
|
213
|
+
- Headline below the masthead, ink, up to two lines, sized to leave real air above
|
|
214
|
+
`#hero-zone` — don't let it crowd down into the illustration's territory.
|
|
215
|
+
- Optional thin ink rule (2px) at the very bottom of the canvas, under `#hero-zone`, is a
|
|
216
|
+
fine way to close the composition the way the site's own colophon closes a page — skip
|
|
217
|
+
it if the illustration already reads as complete without one; it's a finishing touch,
|
|
218
|
+
not a required element.
|
|
219
|
+
- **A caption, if you use one, anchors to the drawing, not to the canvas margin.** Center
|
|
220
|
+
it under the illustration's own visual mass, or align it flush to one edge of the
|
|
221
|
+
drawing's actual bounding box — never park it at the hero zone's left inset independent
|
|
222
|
+
of where the artwork itself sits. A caption is a caption *for* the drawing; its position
|
|
223
|
+
should say so, and it exists to reinforce a concept the drawing already communicates on
|
|
224
|
+
its own, not to explain a drawing that doesn't communicate anything by itself.
|
|
225
|
+
- Breathing room belongs between the composed image and the canvas edges, and between the
|
|
226
|
+
image and the masthead/headline above it — not inside the hero zone as empty space
|
|
227
|
+
around an undersized drawing. Don't crowd the composition with unrelated text or
|
|
228
|
+
decoration; do fill the zone the drawing is given (see the fill requirement above).
|
|
87
229
|
- **Restraint in execution, not in ambition:** the illustration should be a real, specific
|
|
88
230
|
scene (multiple shapes composed together to depict one concept), not a single
|
|
89
231
|
primitive. But avoid clutter — every shape in the illustration should serve the one
|
|
@@ -100,8 +242,34 @@ editorial illustrations, not marketing graphics and not a repeated template:
|
|
|
100
242
|
- Don't fall back to a generic circle/square/checkmark/arrow when stuck — that's the
|
|
101
243
|
exact failure mode this guide exists to prevent. Spend the extra step finding the
|
|
102
244
|
concrete mechanism the post describes.
|
|
103
|
-
- Don't
|
|
245
|
+
- Don't draw a row of repeated, near-identical marks along one baseline — a tick row, a
|
|
246
|
+
barcode, evenly spaced dashes — as the whole illustration. It reads as a barcode, not a
|
|
247
|
+
concept. A reader must be able to infer the concept from the shapes and their
|
|
248
|
+
arrangement alone, before reading any caption; if the caption is doing the work of
|
|
249
|
+
explaining what the drawing is, the drawing failed, and adding a caption to compensate
|
|
250
|
+
doesn't fix it.
|
|
251
|
+
- Don't leave the hero zone mostly empty around a small drawing. `render-cover` only
|
|
252
|
+
checks the outer `#hero-zone` box, not what's inside it, so a thin band of marks in an
|
|
253
|
+
otherwise bare 1300×400 area passes the mechanical check and still fails this guide —
|
|
254
|
+
see the fill requirement in Hero-zone grid contract.
|
|
255
|
+
- Don't let the kicker-area accent icon float in empty space with nothing beside it —
|
|
256
|
+
anchor it to the eyebrow line, or leave it out entirely.
|
|
257
|
+
- Don't use a gradient as a full-bleed background, and don't add grain, noise, torn
|
|
258
|
+
edges, fold lines, or any texture overlay — the paper is a flat hex, not a vintage
|
|
259
|
+
poster prop.
|
|
260
|
+
- Don't round any corner or drop any shadow, on the illustration or anywhere else on the
|
|
261
|
+
cover — PRESS structure comes from rules and whitespace, never rounded chrome.
|
|
262
|
+
- Don't let orange spread across the cover — one signature moment (see The accent law),
|
|
263
|
+
never orange as a fill color for multiple shapes, never a background wash.
|
|
264
|
+
- Don't set the headline, eyebrow, or numerals in the serif voice — that voice is for
|
|
265
|
+
commentary only; structure is always the display face.
|
|
266
|
+
- Don't invent editorial props that aren't backed by real data: no fabricated issue
|
|
267
|
+
numbers, no barcodes, no pull-quotes that aren't an actual quote from the post.
|
|
268
|
+
- Don't leave old terminal furniture lying around outside a `.term` panel — no blinking
|
|
269
|
+
cursor, no bare `_` suffix, no stray `$` prompt as decoration. The dark palette now
|
|
270
|
+
belongs to exactly one place, the `.term` panel, and only when it's real code.
|
|
104
271
|
- Don't embed a photograph, stock image, or anything requiring an external fetch — the
|
|
105
272
|
illustration is drawn from inline SVG primitives, not sourced from anywhere.
|
|
106
|
-
- Don't reference any font other than `'DevlogCoverFont'`
|
|
107
|
-
|
|
273
|
+
- Don't reference any font file other than the bundled `'DevlogCoverFont'` — the serif
|
|
274
|
+
and display voices lean on this rendering host's own system fonts, never a file you
|
|
275
|
+
fetch or embed yourself.
|
package/lib/config_ops.mjs
CHANGED
|
@@ -31,6 +31,7 @@ export function removeProject(config, key) {
|
|
|
31
31
|
const SETTERS = {
|
|
32
32
|
targetRepo: (c, v) => ({ ...c, targetRepo: v }),
|
|
33
33
|
branch: (c, v) => ({ ...c, branch: v }),
|
|
34
|
+
targetDir: (c, v) => (v === '' ? omit(c, 'targetDir') : { ...c, targetDir: v }),
|
|
34
35
|
gitAuthor: (c, v) => ({ ...c, gitAuthor: v }),
|
|
35
36
|
githubUser: (c, v) => ({ ...c, githubUser: v }),
|
|
36
37
|
voicePath: (c, v) => (v === '' ? omit(c, 'voicePath') : { ...c, voicePath: expandHome(v) }),
|
package/lib/core.mjs
CHANGED
|
@@ -113,6 +113,18 @@ export function validateConfig(config) {
|
|
|
113
113
|
throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
if ('targetDir' in config) {
|
|
117
|
+
// Optional: subdirectory of targetRepo holding the devlog content tree — e.g.
|
|
118
|
+
// `content/devlog` when the target is a site repo that renders the entries
|
|
119
|
+
// itself. Relative, slash-separated, no traversal, no leading/trailing slash.
|
|
120
|
+
// Interpolated into shell commands (the publish clone path) and the gh api
|
|
121
|
+
// contents path, so the charset is deliberately tight.
|
|
122
|
+
if (typeof config.targetDir !== 'string'
|
|
123
|
+
|| !/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(config.targetDir)
|
|
124
|
+
|| config.targetDir.split('/').some((s) => s === '.' || s === '..')) {
|
|
125
|
+
throw new Error(`targetDir must be a relative path like "content/devlog" (no leading/trailing slash, no '..'): got ${JSON.stringify(config.targetDir)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
116
128
|
if ('voicePath' in config) {
|
|
117
129
|
// Optional: directory holding the voice profile used to write entries. Read by
|
|
118
130
|
// the skill with the Read tool only — never shell-interpolated — so the only
|
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.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// One-time (but idempotent, safely re-runnable) migration that backfills the
|
|
2
|
+
// frozen `no` field onto every pre-existing manifest row that lacks one. Run
|
|
3
|
+
// once against the published corpus after `publishEntry` started emitting
|
|
4
|
+
// `no` for new entries (see publish_entry.mjs) — everything published before
|
|
5
|
+
// that point needs a number assigned retroactively.
|
|
6
|
+
//
|
|
7
|
+
// `no` is a single global sequence across ALL projects (issue numbers of one
|
|
8
|
+
// publication), so this walks every project directory under corpusDir
|
|
9
|
+
// together rather than numbering each project's manifest independently.
|
|
10
|
+
//
|
|
11
|
+
// Tiebreak for entries sharing a date (common in this corpus — multiple
|
|
12
|
+
// projects, and multiple releases of one project, often ship the same day):
|
|
13
|
+
// date ascending, then project name ascending, then filename ascending. Both
|
|
14
|
+
// of those are stable fields already on the row, so the order is
|
|
15
|
+
// deterministic and reproducible from the data alone, with no external
|
|
16
|
+
// input (e.g. "whichever I published first today") required to redo it.
|
|
17
|
+
//
|
|
18
|
+
// Mutates ONLY the `no` field. Never touches `.md` files. Never touches
|
|
19
|
+
// `cover`. Preserves every other field's value and the manifest's existing
|
|
20
|
+
// key order — `no` is inserted immediately before `cover` (matching where
|
|
21
|
+
// publishEntry places it on a fresh row) or appended at the end when the row
|
|
22
|
+
// has no cover, so old and newly-migrated rows end up shaped the same way.
|
|
23
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { atomicWriteJSON } from './core.mjs';
|
|
26
|
+
|
|
27
|
+
function compareRows(a, b) {
|
|
28
|
+
return String(a.date).localeCompare(String(b.date))
|
|
29
|
+
|| a.project.localeCompare(b.project)
|
|
30
|
+
|| String(a.file).localeCompare(String(b.file));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function insertNo(entry, no) {
|
|
34
|
+
const out = {};
|
|
35
|
+
let inserted = false;
|
|
36
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
37
|
+
if (key === 'cover' && !inserted) {
|
|
38
|
+
out.no = no;
|
|
39
|
+
inserted = true;
|
|
40
|
+
}
|
|
41
|
+
out[key] = value;
|
|
42
|
+
}
|
|
43
|
+
if (!inserted) out.no = no;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// dryRun: compute and return the assignment without writing anything —
|
|
48
|
+
// useful to preview before committing to a run.
|
|
49
|
+
export function migrateEntryNumbers(corpusDir, { dryRun = false } = {}) {
|
|
50
|
+
if (!existsSync(corpusDir)) throw new Error(`Corpus directory not found: ${corpusDir}`);
|
|
51
|
+
|
|
52
|
+
const projects = readdirSync(corpusDir, { withFileTypes: true })
|
|
53
|
+
.filter((d) => d.isDirectory())
|
|
54
|
+
.map((d) => d.name)
|
|
55
|
+
.sort();
|
|
56
|
+
|
|
57
|
+
const manifestsByProject = new Map();
|
|
58
|
+
let maxNo = 0;
|
|
59
|
+
const unnumbered = [];
|
|
60
|
+
|
|
61
|
+
for (const project of projects) {
|
|
62
|
+
const manifestPath = join(corpusDir, project, 'manifest.json');
|
|
63
|
+
if (!existsSync(manifestPath)) continue;
|
|
64
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
65
|
+
if (!manifest || !Array.isArray(manifest.entries)) {
|
|
66
|
+
throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
|
|
67
|
+
}
|
|
68
|
+
manifestsByProject.set(project, manifest);
|
|
69
|
+
|
|
70
|
+
manifest.entries.forEach((entry, index) => {
|
|
71
|
+
if (!entry) return;
|
|
72
|
+
if (Number.isInteger(entry.no)) {
|
|
73
|
+
if (entry.no > maxNo) maxNo = entry.no;
|
|
74
|
+
} else {
|
|
75
|
+
unnumbered.push({ project, index, date: String(entry.date), file: String(entry.file) });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
unnumbered.sort(compareRows);
|
|
81
|
+
|
|
82
|
+
const assigned = [];
|
|
83
|
+
let next = maxNo + 1;
|
|
84
|
+
for (const row of unnumbered) {
|
|
85
|
+
const manifest = manifestsByProject.get(row.project);
|
|
86
|
+
manifest.entries[row.index] = insertNo(manifest.entries[row.index], next);
|
|
87
|
+
assigned.push({ project: row.project, file: row.file, date: row.date, no: next });
|
|
88
|
+
next += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const touchedProjects = [...new Set(unnumbered.map((r) => r.project))].sort();
|
|
92
|
+
if (!dryRun) {
|
|
93
|
+
for (const project of touchedProjects) {
|
|
94
|
+
atomicWriteJSON(join(corpusDir, project, 'manifest.json'), manifestsByProject.get(project));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { assigned, touchedProjects, startingNo: maxNo + 1, endingNo: next - 1 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// CLI entry point: `node migrate_entry_numbers.mjs <corpusDir> [--dry-run]`
|
|
102
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
103
|
+
const corpusDir = process.argv[2];
|
|
104
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
105
|
+
if (!corpusDir) {
|
|
106
|
+
console.error('Usage: node migrate_entry_numbers.mjs <corpusDir> [--dry-run]');
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
const result = migrateEntryNumbers(corpusDir, { dryRun });
|
|
110
|
+
console.log(JSON.stringify(result, null, 2));
|
|
111
|
+
if (result.assigned.length === 0) {
|
|
112
|
+
console.error('Nothing to do — every entry already has `no`.');
|
|
113
|
+
} else {
|
|
114
|
+
console.error(`${dryRun ? '[dry run] would assign' : 'Assigned'} ${result.assigned.length} numbers (${result.startingNo}..${result.endingNo}).`);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/publish_entry.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// manifest. This is the code-enforced immutability guard: a cut release's
|
|
3
3
|
// entry is never overwritten, and manifest mutation is no longer done by
|
|
4
4
|
// hand-editing JSON in the agent loop.
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, statSync, openSync, readSync, closeSync, readdirSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { RE_PROJECT_KEY, RE_FINAL_RELEASE, atomicWriteJSON } from './core.mjs';
|
|
8
8
|
import { parseFrontmatter } from './lint_post.mjs';
|
|
@@ -55,6 +55,43 @@ function sortEntries(entries) {
|
|
|
55
55
|
String(b.date).localeCompare(String(a.date)) || compareVersionsDesc(a, b));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// `no` is a single sequence across ALL projects (issue numbers of one
|
|
59
|
+
// publication, not per-project counters), but manifests are stored one per
|
|
60
|
+
// project — so "next" means "scan every project's manifest under cloneDir and
|
|
61
|
+
// take the highest `no` seen, plus one." Pre-migration rows with no `no` field
|
|
62
|
+
// are simply skipped, not treated as 0; a manifest a sibling agent is mid-write
|
|
63
|
+
// on is skipped rather than thrown on, since a transient parse failure on
|
|
64
|
+
// ANOTHER project must never block publishing to THIS one.
|
|
65
|
+
// NOT safe against two publishEntry calls racing in separate processes at the
|
|
66
|
+
// same instant (read-then-write with no lock) — acceptable for this single-
|
|
67
|
+
// operator CLI; a real lock is not worth the complexity until that changes.
|
|
68
|
+
function nextEntryNumber(cloneDir) {
|
|
69
|
+
let dirents;
|
|
70
|
+
try {
|
|
71
|
+
dirents = readdirSync(cloneDir, { withFileTypes: true });
|
|
72
|
+
} catch {
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let max = 0;
|
|
77
|
+
for (const dirent of dirents) {
|
|
78
|
+
if (!dirent.isDirectory()) continue;
|
|
79
|
+
const manifestPath = join(cloneDir, dirent.name, 'manifest.json');
|
|
80
|
+
if (!existsSync(manifestPath)) continue;
|
|
81
|
+
let manifest;
|
|
82
|
+
try {
|
|
83
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!manifest || !Array.isArray(manifest.entries)) continue;
|
|
88
|
+
for (const entry of manifest.entries) {
|
|
89
|
+
if (entry && Number.isInteger(entry.no) && entry.no > max) max = entry.no;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return max + 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
export function publishEntry({ cloneDir, project, version, entryPath, coverImageBuffer }) {
|
|
59
96
|
if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
|
|
60
97
|
throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
|
|
@@ -105,7 +142,13 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
|
|
|
105
142
|
// when the .md was missing — never duplicate an index row.
|
|
106
143
|
const already = manifest.entries.some((e) => e && (e.file === file || e.version === version));
|
|
107
144
|
let manifestUpdated = false;
|
|
145
|
+
// Frozen at publish, never recomputed: a backdated entry published later must
|
|
146
|
+
// never shift a number already baked into a live published social image.
|
|
147
|
+
// Computed only on the write path — a repeat/idempotent call that hits
|
|
148
|
+
// `already` above must not burn a number on a publish that's a no-op.
|
|
149
|
+
let no = null;
|
|
108
150
|
if (!already) {
|
|
151
|
+
no = nextEntryNumber(cloneDir);
|
|
109
152
|
manifest.entries.push({
|
|
110
153
|
date: String(data.date),
|
|
111
154
|
file,
|
|
@@ -113,6 +156,7 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
|
|
|
113
156
|
summary: String(data.summary),
|
|
114
157
|
version,
|
|
115
158
|
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
159
|
+
no,
|
|
116
160
|
...(coverFile ? { cover: { file: coverFile, bytes: coverImageBuffer.length } } : {}),
|
|
117
161
|
});
|
|
118
162
|
manifest.entries = sortEntries(manifest.entries);
|
|
@@ -120,7 +164,7 @@ export function publishEntry({ cloneDir, project, version, entryPath, coverImage
|
|
|
120
164
|
manifestUpdated = true;
|
|
121
165
|
}
|
|
122
166
|
|
|
123
|
-
return { written: destPath, manifestUpdated, coverWritten: !!coverFile };
|
|
167
|
+
return { written: destPath, manifestUpdated, coverWritten: !!coverFile, no };
|
|
124
168
|
}
|
|
125
169
|
|
|
126
170
|
// Backfill path only: add a cover to an entry that was already published without one.
|
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/lib/scan.mjs
CHANGED
|
@@ -191,8 +191,9 @@ function splitLogLine(line) {
|
|
|
191
191
|
// project has no entries yet; any other failure is surfaced so the caller
|
|
192
192
|
// knows the entry-exists filter may be incomplete (publish-entry still refuses
|
|
193
193
|
// overwrites against the fresh clone, so a stale scan cannot clobber anything).
|
|
194
|
-
export function fetchExistingEntries(targetRepo, branch, projectKey) {
|
|
195
|
-
const
|
|
194
|
+
export function fetchExistingEntries(targetRepo, branch, projectKey, targetDir = '') {
|
|
195
|
+
const contentPath = targetDir ? `${targetDir}/${projectKey}` : projectKey;
|
|
196
|
+
const r = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${contentPath}?ref=${branch}`, '--jq', '.[].name']);
|
|
196
197
|
if (r.status === 0) {
|
|
197
198
|
return { files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
|
|
198
199
|
}
|
|
@@ -218,7 +219,7 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
|
|
|
218
219
|
}
|
|
219
220
|
|
|
220
221
|
const results = projects.map((project) => {
|
|
221
|
-
const existing = getExisting(config.targetRepo, branch, project.key);
|
|
222
|
+
const existing = getExisting(config.targetRepo, branch, project.key, config.targetDir || '');
|
|
222
223
|
const scanned = scanProject(project, { branch, fetch, existingFiles: existing.files });
|
|
223
224
|
scanned.existenceCheck = existing.status;
|
|
224
225
|
return scanned;
|
|
@@ -226,6 +227,9 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
|
|
|
226
227
|
|
|
227
228
|
return {
|
|
228
229
|
targetRepo: config.targetRepo,
|
|
230
|
+
// Subdirectory of targetRepo holding the content tree ('' = repo root) — the
|
|
231
|
+
// skill appends it to the publish clone path (`--clone <clone>/<targetDir>`).
|
|
232
|
+
targetDir: config.targetDir || '',
|
|
229
233
|
branch,
|
|
230
234
|
deepDive: resolveDeepDive(config),
|
|
231
235
|
voicePath: config.voicePath || null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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"]
|