@natjswenson/devlog 0.9.0 → 0.11.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 +151 -82
- package/bin/devlog.js +160 -29
- package/examples/react/useDevLogEntries.js +2 -0
- package/image-style/icons.md +3 -2
- package/image-style/style-guide.example.md +165 -28
- package/lib/assemble_post.mjs +71 -0
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +12 -0
- package/lib/cover_gen.mjs +6 -1
- package/lib/lint_post.mjs +47 -1
- package/lib/migrate_entry_numbers.mjs +116 -0
- package/lib/publish_entry.mjs +255 -14
- package/lib/scan.mjs +101 -18
- package/package.json +1 -1
- package/skill-invariants.json +21 -1
package/bin/devlog.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
3
3
|
import {
|
|
4
4
|
existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, realpathSync,
|
|
5
|
-
readdirSync, statSync,
|
|
5
|
+
readdirSync, statSync, rmSync, mkdtempSync,
|
|
6
6
|
} from 'node:fs';
|
|
7
7
|
import { dirname, join, resolve, basename } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -32,9 +32,10 @@ import {
|
|
|
32
32
|
validateConfig,
|
|
33
33
|
resolveDeepDive,
|
|
34
34
|
} from '../lib/core.mjs';
|
|
35
|
-
import { scanAll } from '../lib/scan.mjs';
|
|
35
|
+
import { scanAll, summarizeScan } from '../lib/scan.mjs';
|
|
36
36
|
import { lintPost, parseFrontmatter, splitSections } from '../lib/lint_post.mjs';
|
|
37
|
-
import { publishEntry, addCoverToExistingEntry } from '../lib/publish_entry.mjs';
|
|
37
|
+
import { publishEntry, addCoverToExistingEntry, tombstoneEntry, syncEntryFromFrontmatter } from '../lib/publish_entry.mjs';
|
|
38
|
+
import { writeAssembledBlocks } from '../lib/assemble_post.mjs';
|
|
38
39
|
import { addProject, removeProject, setField, SETTABLE_FIELDS } from '../lib/config_ops.mjs';
|
|
39
40
|
import { loadStyleGuide, getRecentCovers, mergeManifestEntries } from '../lib/cover_gen.mjs';
|
|
40
41
|
import { renderCoverImage } from '../lib/render_cover.mjs';
|
|
@@ -110,6 +111,18 @@ function emitJSON(obj, exitCode = 0) {
|
|
|
110
111
|
process.exit(exitCode);
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// parseArgs is strict by default, so an unknown flag (`render-cover --force`)
|
|
115
|
+
// used to die with a raw ERR_PARSE_ARGS_UNKNOWN_OPTION stack trace instead of
|
|
116
|
+
// the JSON error shape every agent-facing command promises. Same contract as
|
|
117
|
+
// commit-covers' hand-rolled parser: unknown/malformed flags → bad-flag JSON.
|
|
118
|
+
function safeParseArgs(spec) {
|
|
119
|
+
try {
|
|
120
|
+
return parseArgs(spec);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
emitJSON({ error: 'bad-flag', message: e.message }, 2);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
function readValidConfigOrExit({ json = false } = {}) {
|
|
114
127
|
if (!existsSync(CONFIG_PATH)) {
|
|
115
128
|
if (json) emitJSON({ error: 'config-missing', path: CONFIG_PATH, hint: 'Run `npx @natjswenson/devlog init` first.' }, 1);
|
|
@@ -462,7 +475,7 @@ async function cmdInit() {
|
|
|
462
475
|
|
|
463
476
|
// ─── add-project ─────────────────────────────────────────────────────────────
|
|
464
477
|
async function cmdAddProject(rest) {
|
|
465
|
-
const { values } =
|
|
478
|
+
const { values } = safeParseArgs({
|
|
466
479
|
args: rest,
|
|
467
480
|
options: {
|
|
468
481
|
path: { type: 'string' },
|
|
@@ -541,7 +554,7 @@ async function cmdAddProject(rest) {
|
|
|
541
554
|
|
|
542
555
|
// ─── remove-project ──────────────────────────────────────────────────────────
|
|
543
556
|
async function cmdRemoveProject(rest) {
|
|
544
|
-
const { values, positionals } =
|
|
557
|
+
const { values, positionals } = safeParseArgs({
|
|
545
558
|
args: rest,
|
|
546
559
|
options: { yes: { type: 'boolean', default: false } },
|
|
547
560
|
allowPositionals: true,
|
|
@@ -571,7 +584,7 @@ async function cmdRemoveProject(rest) {
|
|
|
571
584
|
|
|
572
585
|
// ─── set ─────────────────────────────────────────────────────────────────────
|
|
573
586
|
function cmdSet(rest) {
|
|
574
|
-
const { positionals } =
|
|
587
|
+
const { positionals } = safeParseArgs({ args: rest, options: {}, allowPositionals: true });
|
|
575
588
|
const [field, value] = positionals;
|
|
576
589
|
const config = readValidConfigOrExit({ json: true });
|
|
577
590
|
if (!field || value === undefined) {
|
|
@@ -588,11 +601,14 @@ function cmdSet(rest) {
|
|
|
588
601
|
|
|
589
602
|
// ─── scan ────────────────────────────────────────────────────────────────────
|
|
590
603
|
function cmdScan(rest) {
|
|
591
|
-
const { values } =
|
|
604
|
+
const { values } = safeParseArgs({
|
|
592
605
|
args: rest,
|
|
593
606
|
options: {
|
|
594
607
|
project: { type: 'string' },
|
|
595
608
|
'no-fetch': { type: 'boolean', default: false },
|
|
609
|
+
// Plan-table view: per release, commitCount instead of the commit list
|
|
610
|
+
// and diffstat; skippedTags collapsed to per-reason counts.
|
|
611
|
+
summary: { type: 'boolean', default: false },
|
|
596
612
|
// scan always emits JSON; the flag is accepted so `scan --json` (as
|
|
597
613
|
// SKILL.md spells it) is never a crash.
|
|
598
614
|
json: { type: 'boolean', default: true },
|
|
@@ -600,19 +616,29 @@ function cmdScan(rest) {
|
|
|
600
616
|
allowPositionals: false,
|
|
601
617
|
});
|
|
602
618
|
const config = readValidConfigOrExit({ json: true });
|
|
603
|
-
|
|
619
|
+
let result = scanAll(config, { projectKey: values.project || null, fetch: !values['no-fetch'] });
|
|
620
|
+
if (values.summary) result = summarizeScan(result);
|
|
621
|
+
// Which CLI actually ran: npx caches aggressively, and a stale install has
|
|
622
|
+
// silently missed shipped fixes before — the skill compares this against the
|
|
623
|
+
// version its own instructions shipped with.
|
|
624
|
+
if (!result.error) result.cliVersion = readPackageVersion();
|
|
604
625
|
emitJSON(result, result.error ? 1 : 0);
|
|
605
626
|
}
|
|
606
627
|
|
|
607
628
|
// ─── lint-post ───────────────────────────────────────────────────────────────
|
|
608
629
|
function cmdLintPost(rest) {
|
|
609
|
-
const { values, positionals } =
|
|
630
|
+
const { values, positionals } = safeParseArgs({
|
|
610
631
|
args: rest,
|
|
611
|
-
options: {
|
|
632
|
+
options: {
|
|
633
|
+
'min-sources': { type: 'string' },
|
|
634
|
+
// Deterministic voice-contract rules (em dashes, banned phrases) —
|
|
635
|
+
// opt-in so non-voice callers and the eval harness keep their behavior.
|
|
636
|
+
voice: { type: 'boolean', default: false },
|
|
637
|
+
},
|
|
612
638
|
allowPositionals: true,
|
|
613
639
|
});
|
|
614
640
|
const file = positionals[0];
|
|
615
|
-
if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-post <file> [--min-sources N]' }, 2);
|
|
641
|
+
if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog lint-post <file> [--min-sources N] [--voice]' }, 2);
|
|
616
642
|
|
|
617
643
|
let minSources;
|
|
618
644
|
if (values['min-sources'] !== undefined) {
|
|
@@ -633,13 +659,13 @@ function cmdLintPost(rest) {
|
|
|
633
659
|
} catch (e) {
|
|
634
660
|
emitJSON({ error: 'unreadable', message: e.message }, 2);
|
|
635
661
|
}
|
|
636
|
-
const result = lintPost(content, { minSources, filename: file });
|
|
662
|
+
const result = lintPost(content, { minSources, filename: file, voice: values.voice });
|
|
637
663
|
emitJSON({ ...result, minSources }, result.ok ? 0 : 1);
|
|
638
664
|
}
|
|
639
665
|
|
|
640
666
|
// ─── publish-entry ───────────────────────────────────────────────────────────
|
|
641
667
|
function cmdPublishEntry(rest) {
|
|
642
|
-
const { values } =
|
|
668
|
+
const { values } = safeParseArgs({
|
|
643
669
|
args: rest,
|
|
644
670
|
options: {
|
|
645
671
|
clone: { type: 'string' },
|
|
@@ -677,6 +703,92 @@ function cmdPublishEntry(rest) {
|
|
|
677
703
|
}
|
|
678
704
|
}
|
|
679
705
|
|
|
706
|
+
// ─── tombstone ───────────────────────────────────────────────────────────────
|
|
707
|
+
// Editorially retire a (project, version) identity after its entry was moved,
|
|
708
|
+
// consolidated, or deleted by hand — scan then reports `entry-tombstoned` and
|
|
709
|
+
// publish-entry refuses it forever.
|
|
710
|
+
function cmdTombstone(rest) {
|
|
711
|
+
const { values } = safeParseArgs({
|
|
712
|
+
args: rest,
|
|
713
|
+
options: {
|
|
714
|
+
clone: { type: 'string' },
|
|
715
|
+
project: { type: 'string' },
|
|
716
|
+
version: { type: 'string' },
|
|
717
|
+
reason: { type: 'string' },
|
|
718
|
+
},
|
|
719
|
+
allowPositionals: false,
|
|
720
|
+
});
|
|
721
|
+
for (const flag of ['clone', 'project', 'version', 'reason']) {
|
|
722
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `tombstone requires --${flag}` }, 1);
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const result = tombstoneEntry({
|
|
726
|
+
cloneDir: expandHome(values.clone),
|
|
727
|
+
project: values.project,
|
|
728
|
+
version: values.version,
|
|
729
|
+
reason: values.reason,
|
|
730
|
+
});
|
|
731
|
+
emitJSON({ ok: true, ...result });
|
|
732
|
+
} catch (e) {
|
|
733
|
+
emitJSON({ error: 'tombstone-failed', message: e.message }, 1);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── sync-entry ──────────────────────────────────────────────────────────────
|
|
738
|
+
// Resync a published entry's manifest row (title/summary/date/tags) from its
|
|
739
|
+
// .md frontmatter after a deliberate post-publish edit.
|
|
740
|
+
function cmdSyncEntry(rest) {
|
|
741
|
+
const { values } = safeParseArgs({
|
|
742
|
+
args: rest,
|
|
743
|
+
options: {
|
|
744
|
+
clone: { type: 'string' },
|
|
745
|
+
project: { type: 'string' },
|
|
746
|
+
slug: { type: 'string' },
|
|
747
|
+
},
|
|
748
|
+
allowPositionals: false,
|
|
749
|
+
});
|
|
750
|
+
for (const flag of ['clone', 'project', 'slug']) {
|
|
751
|
+
if (!values[flag]) emitJSON({ error: 'missing-flag', message: `sync-entry requires --${flag}` }, 1);
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const result = syncEntryFromFrontmatter({
|
|
755
|
+
cloneDir: expandHome(values.clone),
|
|
756
|
+
project: values.project,
|
|
757
|
+
slug: values.slug,
|
|
758
|
+
});
|
|
759
|
+
emitJSON({ ok: true, ...result });
|
|
760
|
+
} catch (e) {
|
|
761
|
+
emitJSON({ error: 'sync-failed', message: e.message }, 1);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── assemble-post ───────────────────────────────────────────────────────────
|
|
766
|
+
// Extract a draft's fenced code blocks, in order, into numbered files so the
|
|
767
|
+
// Step 4 assemble-and-run check is mechanical instead of hand-copied.
|
|
768
|
+
function cmdAssemblePost(rest) {
|
|
769
|
+
const { values, positionals } = safeParseArgs({
|
|
770
|
+
args: rest,
|
|
771
|
+
options: { out: { type: 'string' } },
|
|
772
|
+
allowPositionals: true,
|
|
773
|
+
});
|
|
774
|
+
const file = positionals[0];
|
|
775
|
+
if (!file) emitJSON({ error: 'missing-arg', message: 'Usage: devlog assemble-post <draft> --out <dir>' }, 2);
|
|
776
|
+
if (!values.out) emitJSON({ error: 'missing-flag', message: 'assemble-post requires --out' }, 1);
|
|
777
|
+
|
|
778
|
+
let content;
|
|
779
|
+
try {
|
|
780
|
+
content = readFileSync(expandHome(file), 'utf8');
|
|
781
|
+
} catch (e) {
|
|
782
|
+
emitJSON({ error: 'unreadable', message: e.message }, 2);
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
const result = writeAssembledBlocks(content, expandHome(values.out));
|
|
786
|
+
emitJSON({ ok: true, ...result });
|
|
787
|
+
} catch (e) {
|
|
788
|
+
emitJSON({ error: 'assemble-failed', message: e.message }, 1);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
680
792
|
// ─── backfill-covers list ─────────────────────────────────────────────────────
|
|
681
793
|
function cmdBackfillCovers(rest) {
|
|
682
794
|
const sub = rest[0];
|
|
@@ -684,7 +796,7 @@ function cmdBackfillCovers(rest) {
|
|
|
684
796
|
emitJSON({ error: 'unknown-subcommand', message: 'Usage: devlog backfill-covers list --clone <cloneDir> [--project <key>] [--out <staging-dir>] [--all]' }, 2);
|
|
685
797
|
return;
|
|
686
798
|
}
|
|
687
|
-
const { values } =
|
|
799
|
+
const { values } = safeParseArgs({
|
|
688
800
|
args: rest.slice(1),
|
|
689
801
|
options: {
|
|
690
802
|
clone: { type: 'string' },
|
|
@@ -761,7 +873,7 @@ function cmdBackfillCovers(rest) {
|
|
|
761
873
|
|
|
762
874
|
// ─── cover-context ─────────────────────────────────────────────────────────────
|
|
763
875
|
function cmdCoverContext(rest) {
|
|
764
|
-
const { positionals, values } =
|
|
876
|
+
const { positionals, values } = safeParseArgs({
|
|
765
877
|
args: rest,
|
|
766
878
|
options: {
|
|
767
879
|
clone: { type: 'string' },
|
|
@@ -826,7 +938,7 @@ function regenerateContactSheet(outDir) {
|
|
|
826
938
|
}
|
|
827
939
|
|
|
828
940
|
async function cmdRenderCover(rest) {
|
|
829
|
-
const { positionals, values } =
|
|
941
|
+
const { positionals, values } = safeParseArgs({
|
|
830
942
|
args: rest,
|
|
831
943
|
options: {
|
|
832
944
|
project: { type: 'string' },
|
|
@@ -852,17 +964,21 @@ async function cmdRenderCover(rest) {
|
|
|
852
964
|
mkdirSync(projectDir, { recursive: true });
|
|
853
965
|
const pngPath = join(projectDir, `${values.slug}.png`);
|
|
854
966
|
|
|
855
|
-
//
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
967
|
+
// The HTML file is the source of truth: whenever it's present, render it —
|
|
968
|
+
// overwriting any stale PNG from a previous attempt. (The old
|
|
969
|
+
// PNG-exists short-circuit silently ignored freshly edited HTML, which cost
|
|
970
|
+
// every real retry loop an ls/mtime/md5 debugging dance and a guessed-at
|
|
971
|
+
// `--force` flag that didn't exist.) Only when the HTML is gone does an
|
|
972
|
+
// existing valid PNG mean "already rendered, nothing to do".
|
|
862
973
|
let html;
|
|
863
974
|
try {
|
|
864
975
|
html = readFileSync(expandHome(htmlFile), 'utf8');
|
|
865
976
|
} catch (e) {
|
|
977
|
+
if (existsSync(pngPath) && isValidPngFile(pngPath)) {
|
|
978
|
+
regenerateContactSheet(outDir);
|
|
979
|
+
emitJSON({ ok: true, written: pngPath, rendered: false });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
866
982
|
emitJSON({ error: 'html-unreadable', message: e.message }, 1);
|
|
867
983
|
return;
|
|
868
984
|
}
|
|
@@ -878,9 +994,10 @@ async function cmdRenderCover(rest) {
|
|
|
878
994
|
}
|
|
879
995
|
writeFileSync(pngPath, png);
|
|
880
996
|
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
997
|
+
// The HTML source deliberately stays on disk (it lives in the run's scratch
|
|
998
|
+
// dir and dies with it): keeping it is what makes "tweak the HTML, re-run
|
|
999
|
+
// render-cover" work at all — deleting it on success broke every
|
|
1000
|
+
// post-render Edit attempt in real runs.
|
|
884
1001
|
regenerateContactSheet(outDir);
|
|
885
1002
|
emitJSON({ ok: true, written: pngPath, rendered: true });
|
|
886
1003
|
}
|
|
@@ -1018,7 +1135,7 @@ async function cmdCommitCovers(rest) {
|
|
|
1018
1135
|
|
|
1019
1136
|
// ─── config (view) ───────────────────────────────────────────────────────────
|
|
1020
1137
|
async function cmdConfig(rest) {
|
|
1021
|
-
const { values } =
|
|
1138
|
+
const { values } = safeParseArgs({
|
|
1022
1139
|
args: rest,
|
|
1023
1140
|
options: { json: { type: 'boolean', default: false } },
|
|
1024
1141
|
allowPositionals: false,
|
|
@@ -1137,12 +1254,17 @@ Setup & config:
|
|
|
1137
1254
|
${kleur.cyan('npx @natjswenson/devlog config [--json]')} Show current config (with validation)
|
|
1138
1255
|
|
|
1139
1256
|
Used by the /devlog skill:
|
|
1140
|
-
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>]')} JSON plan of new releases needing entries
|
|
1141
|
-
${kleur.cyan('npx @natjswenson/devlog lint-post <file>')}
|
|
1257
|
+
${kleur.cyan('npx @natjswenson/devlog scan [--project <key>] [--summary]')} JSON plan of new releases needing entries
|
|
1258
|
+
${kleur.cyan('npx @natjswenson/devlog lint-post <file> [--voice]')} Deterministic post-contract check (+ voice rules)
|
|
1259
|
+
${kleur.cyan('npx @natjswenson/devlog assemble-post <draft> --out <dir>')} Extract the draft's code blocks for the run-it check
|
|
1142
1260
|
${kleur.cyan('npx @natjswenson/devlog publish-entry ...')} Copy a drafted entry into the clone + update manifest (never overwrites)
|
|
1143
1261
|
${kleur.cyan('npx @natjswenson/devlog cover-context <project> <slug> --clone <dir>')} Style guide + reference-image paths for cover composition
|
|
1144
1262
|
${kleur.cyan('npx @natjswenson/devlog render-cover <html> --project <key> --slug <s> --out <dir>')} Rasterize a composed cover to PNG
|
|
1145
1263
|
|
|
1264
|
+
Editorial maintenance:
|
|
1265
|
+
${kleur.cyan('npx @natjswenson/devlog tombstone --clone <dir> --project <key> --version <v> --reason <why>')} Retire a moved/consolidated entry's identity
|
|
1266
|
+
${kleur.cyan('npx @natjswenson/devlog sync-entry --clone <dir> --project <key> --slug <v>')} Resync a manifest row from an edited entry's frontmatter
|
|
1267
|
+
|
|
1146
1268
|
Backfilling covers onto existing posts:
|
|
1147
1269
|
${kleur.cyan('npx @natjswenson/devlog backfill-covers list --clone <dir> [--out <staging-dir>]')} List posts missing a cover
|
|
1148
1270
|
${kleur.cyan('npx @natjswenson/devlog commit-covers <staging-dir> [--force [slug]]')} Publish staged covers to already-published entries
|
|
@@ -1196,6 +1318,15 @@ if (isMain) {
|
|
|
1196
1318
|
case 'publish-entry':
|
|
1197
1319
|
cmdPublishEntry(rest);
|
|
1198
1320
|
break;
|
|
1321
|
+
case 'tombstone':
|
|
1322
|
+
cmdTombstone(rest);
|
|
1323
|
+
break;
|
|
1324
|
+
case 'sync-entry':
|
|
1325
|
+
cmdSyncEntry(rest);
|
|
1326
|
+
break;
|
|
1327
|
+
case 'assemble-post':
|
|
1328
|
+
cmdAssemblePost(rest);
|
|
1329
|
+
break;
|
|
1199
1330
|
case 'backfill-covers':
|
|
1200
1331
|
cmdBackfillCovers(rest);
|
|
1201
1332
|
break;
|
|
@@ -34,6 +34,8 @@ function validateManifest(data) {
|
|
|
34
34
|
const entries = [];
|
|
35
35
|
for (const e of data.entries) {
|
|
36
36
|
if (!e || typeof e !== 'object') continue;
|
|
37
|
+
// Tombstoned rows (removed: true) are editorial retirements, not entries.
|
|
38
|
+
if (e.removed) continue;
|
|
37
39
|
const { date, file, title, summary, version } = e;
|
|
38
40
|
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
39
41
|
if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
|
package/image-style/icons.md
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Twenty small, secondary/accent icons — **never the hero illustration itself**. Each is a
|
|
4
4
|
real inline SVG, 24×24 viewBox, stroke-only (`stroke="currentColor" fill="none"`), so it
|
|
5
|
-
recolors for free via CSS `color: #
|
|
6
|
-
|
|
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>"`
|
|
7
8
|
(the exact `<name>` from the table below) — `render-cover`'s geometry guard reads this
|
|
8
9
|
attribute to confirm no catalog icon has drifted into the hero zone.
|
|
9
10
|
|
|
@@ -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.
|
|
@@ -55,10 +56,32 @@ element with `id="hero-zone"` at exactly this position and size — `render-cove
|
|
|
55
56
|
mechanically checks the rendered `#hero-zone` rect against these numbers (within a 2px
|
|
56
57
|
tolerance for subpixel rounding) and refuses to render if it doesn't match, or if
|
|
57
58
|
`#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
|
+
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.
|
|
59
63
|
|
|
60
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.
|
|
61
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
|
+
|
|
62
85
|
**Two named composition slots** — pick one per post:
|
|
63
86
|
- **Single centered hero** — one freehand mechanism, nothing else, inside the hero zone.
|
|
64
87
|
- **Two-node before/after** — a left node, a right node, and a connecting line, all
|
|
@@ -68,12 +91,20 @@ Every key point of the hero shape you draw *inside* `#hero-zone` should snap to
|
|
|
68
91
|
These are placement/proportion guidance, not literal templates — the actual shapes
|
|
69
92
|
inside each slot are still freehand per post.
|
|
70
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
|
+
|
|
71
102
|
**Catalog icons are never placed inside `#hero-zone`, in either slot.** The mechanism
|
|
72
103
|
and both two-node shapes are always freehand SVG you draw yourself. A catalog icon
|
|
73
104
|
(`image-style/icons.md`) may only appear as a small accent glyph in the kicker/title
|
|
74
105
|
area, entirely outside the hero zone.
|
|
75
106
|
|
|
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.
|
|
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.
|
|
77
108
|
|
|
78
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.
|
|
79
110
|
|
|
@@ -87,8 +118,15 @@ For the two-node slot specifically: the accent icon's presence must not be read
|
|
|
87
118
|
simply never captured, so keep everything inside it.
|
|
88
119
|
- Reference the bundled font only by its fixed name, with a fallback:
|
|
89
120
|
`font-family: 'DevlogCoverFont', sans-serif;` — never embed font bytes yourself, never
|
|
90
|
-
reference any other font file. The renderer injects the real font
|
|
91
|
-
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.
|
|
92
130
|
- No external resources of any kind — no `<link>`, no `@import`, no remote `<img src>`,
|
|
93
131
|
no web fonts, no raster images. All artwork is inline SVG built from basic shapes
|
|
94
132
|
(`<path>`, `<circle>`, `<rect>`, `<line>`, `<polygon>`, `<polyline>`) — everything must
|
|
@@ -96,25 +134,98 @@ For the two-node slot specifically: the accent icon's presence must not be read
|
|
|
96
134
|
|
|
97
135
|
## Visual direction
|
|
98
136
|
|
|
99
|
-
The site (natejswenson.com)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- **
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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).
|
|
118
229
|
- **Restraint in execution, not in ambition:** the illustration should be a real, specific
|
|
119
230
|
scene (multiple shapes composed together to depict one concept), not a single
|
|
120
231
|
primitive. But avoid clutter — every shape in the illustration should serve the one
|
|
@@ -131,8 +242,34 @@ editorial illustrations, not marketing graphics and not a repeated template:
|
|
|
131
242
|
- Don't fall back to a generic circle/square/checkmark/arrow when stuck — that's the
|
|
132
243
|
exact failure mode this guide exists to prevent. Spend the extra step finding the
|
|
133
244
|
concrete mechanism the post describes.
|
|
134
|
-
- 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.
|
|
135
271
|
- Don't embed a photograph, stock image, or anything requiring an external fetch — the
|
|
136
272
|
illustration is drawn from inline SVG primitives, not sourced from anywhere.
|
|
137
|
-
- Don't reference any font other than `'DevlogCoverFont'`
|
|
138
|
-
|
|
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.
|