@natjswenson/press 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ All notable changes to the **press** skill are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
+ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-07-31
9
+
10
+ First release. Replaces eight hand-ported copies of the PRESS brand with one
11
+ source of truth and a CI drift gate.
12
+
13
+ ### Added
14
+
15
+ - **`brand/tokens.json`** — the only place a brand value is written down:
16
+ colours, the terminal panel palette, font stacks, identity defaults, and the
17
+ limits the lint enforces.
18
+ - **Four contract documents** the brand is more than colours:
19
+ `laws.md` (the accent law, structure, the three voices, the tracking ceiling),
20
+ `components.md` (the shared component vocabulary), `agent-ui.md` (how a run
21
+ should read in the chat transcript), `voice-core.md` (the copy rules that
22
+ hold for every artifact).
23
+ - **Marked-region splicing** — press owns a marked block inside an otherwise
24
+ hand-written file, so a consumer keeps its stylesheet, its poster geometry and
25
+ its personal footer. The start marker carries a version + content-hash
26
+ receipt.
27
+ - **Five emitters** — `python-theme` (token dict plus the shared deep-merge
28
+ loader), `css-vars` (with per-consumer name aliases), `md-palette`,
29
+ `markdown-block`, `json`.
30
+ - **`press check`** — the drift gate. Fails on drifted bytes, on a region that
31
+ has gone missing, and on a run that resolved zero targets.
32
+ - **`press lint`** — mechanical brand law: off-palette hexes, the tracking
33
+ ceiling, emoji-presentation glyphs, shadows, gradients, radii, and an optional
34
+ accent cap. Waivable per line or per file.
35
+ - **`press emit --init`** — first-time migration, swallowing the hand-written
36
+ block it takes over rather than leaving a duplicate behind.
37
+ - **`press doctor`** and **`press tokens`**.
38
+ - **Baseline eval** pinned to a real past state: every brand value as it existed
39
+ in eight files across four repos before press generated any of them, plus
40
+ byte-exact goldens per target. Two-sided, with anti-vacuity floors.
41
+
42
+ ### Changed
43
+
44
+ - **Migrated five in-repo consumers** to generated regions — `city-report`,
45
+ `resume`, `ghostwriter`, `ghostwriter-x`, `devlog`. Every value is unchanged
46
+ and each skill's own suite stays green (222, 13 files, 352, 432 and 255 tests
47
+ respectively).
48
+ - **Font stacks widened to the union of every consumer's.** The résumé's
49
+ print-tuned stacks were the deepest; fallbacks are additive, so the richest
50
+ chain became the canonical one and every other consumer gained it.
51
+ - **The `.term` rules in both card sets now reference variables** instead of
52
+ five hardcoded hexes.
53
+
54
+ ### Fixed
55
+
56
+ - **Three genuinely-shared token groups were undeclared anywhere** and are now
57
+ in `tokens.json`: `ink_faint` (`#8A8272`), the terminal panel palette
58
+ (`#141A26`, `#EFE9DC`, `#8A8478`, `#FF8A5C`, `#1E2738` — hardcoded in three
59
+ files), and two paper steps the site was carrying alone (`#ECE5D6`,
60
+ `#E4DCCA`).
61
+ - **The tracking ceiling was over-strict.** It exists to protect PDF text
62
+ extraction, so it binds on documents a machine reads back and not on
63
+ rasterised cards, whose eyebrow legitimately runs at `.16em`. Scoped rather
64
+ than waived, and caught by linting the real shipped corpus.
65
+
66
+ [0.1.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nate Swenson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # press
2
+
3
+ One brand system for everything — design tokens, the visual laws, the
4
+ run-presentation contract, and the universal voice core — generated into every
5
+ consumer, with a CI gate that fails when any of them drifts.
6
+
7
+ PRESS is the warm-paper editorial brand shared by a morning brief, a city
8
+ profile, a budget report, a résumé, a LinkedIn card, a dev-log cover, and
9
+ natejswenson.com. Before this skill it existed as **eight hand-ported copies
10
+ across four repositories**, with five different names for the same orange and no
11
+ way to notice when one of them moved.
12
+
13
+ ## The idea
14
+
15
+ `brand/tokens.json` is the only place a brand value is written down. Everything
16
+ else is generated from it into a **marked region** inside an otherwise
17
+ hand-written file:
18
+
19
+ ```python
20
+ # >>> press:tokens v0.1.0 sha256:2b3d… GENERATED by @natjswenson/press, do not edit
21
+ DEFAULT_THEME: dict = { ... }
22
+ def load_theme() -> dict: ...
23
+ # <<< press:tokens
24
+ ```
25
+
26
+ The consumer keeps everything else — its 250-line stylesheet, its poster
27
+ geometry, its personal avatar footer. `press check` re-derives each region and
28
+ fails CI on any difference.
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ npx -y @natjswenson/press@latest check --repo . # is this repo in sync?
34
+ npx -y @natjswenson/press@latest emit --repo . # make it so
35
+ npx -y @natjswenson/press@latest lint theme.css # mechanical brand law
36
+ npx -y @natjswenson/press@latest doctor # the whole registry
37
+ npx -y @natjswenson/press@latest tokens --format css
38
+ ```
39
+
40
+ Always pin `@latest`. A bare `npx @natjswenson/press` silently prefers a stale
41
+ global install over the registry.
42
+
43
+ ## What `check` fails on
44
+
45
+ | Status | Means |
46
+ |---|---|
47
+ | `drift` | the region exists but its bytes differ |
48
+ | `missing` | the file has no region — the generated block was deleted |
49
+ | `absent` | a declared file is not there |
50
+ | *(empty run)* | **zero targets resolved — the check verified nothing** |
51
+
52
+ The last two matter most. A checker that verifies nothing reports "all clean",
53
+ which is how a gate turns decorative.
54
+
55
+ ## What it does not own
56
+
57
+ Layout and composition (the medium's business), chart colour validation (the
58
+ `dataviz` skill), and any voice learned from the user's own writing —
59
+ ghostwriter's learned profile and devlog's release-note shape stay where they
60
+ are and win on conflict. PRESS sets the floor everything shares, not the whole
61
+ of anything.
62
+
63
+ ## Adding a consumer
64
+
65
+ Declare it in `targets.json`, choose an emitter (`python-theme`, `css-vars`,
66
+ `md-palette`, `markdown-block`, `json`), give it an `init` anchor naming the
67
+ hand-written block the region takes over, then:
68
+
69
+ ```bash
70
+ npx -y @natjswenson/press@latest emit --target <id> --init
71
+ ```
72
+
73
+ Aliases are deliberate: the résumé keeps `--sig`, the site keeps `--fg`. The
74
+ names stay idiomatic to their medium; only the values are shared.
75
+
76
+ ## License
77
+
78
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,139 @@
1
+ ---
2
+ name: press
3
+ description: The one brand system for everything produced in Claude — design tokens, the visual laws, the run-presentation contract, and the universal voice core. Use when composing or restyling any artifact (report, résumé, card, cover, PDF, HTML page, chart), when asked about brand colors, fonts, the accent law or "the PRESS look", when adding a new skill that renders anything, or when a brand value needs to change everywhere at once. Also handles "check the brand is in sync", "why do my colors differ", and onboarding a new consumer repo.
4
+ user_invocable: true
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # /press — the brand system
9
+
10
+ You are running the **press** skill. PRESS is the warm-paper editorial brand
11
+ every artifact shares: a morning brief, a city profile, a budget report, a
12
+ résumé, a LinkedIn card, a dev-log cover, and the site all read as issues of one
13
+ publication.
14
+
15
+ **Announce at start:** "I'm using the press skill for the brand."
16
+
17
+ > All commands below run from the directory containing this `SKILL.md` (the
18
+ > skill's install dir, `$SKILL_DIR`). Resolve it once and `cd` there first. When
19
+ > working in another repo, pass `--repo <path>`.
20
+
21
+ ## The one rule
22
+
23
+ **Never write a brand value into a file by hand.** Not a hex, not a font stack,
24
+ not the monogram. Every consumer gets its values *generated* into a marked
25
+ region, and CI fails if any of them drifts. Copying a value is how this brand
26
+ ended up in eight hand-ported copies with five different names for the same
27
+ orange, which is the problem this skill exists to end.
28
+
29
+ If a consumer needs a color that doesn't exist yet, add it to
30
+ `brand/tokens.json` and re-emit. Never invent one locally.
31
+
32
+ ## What's here
33
+
34
+ | File | Is |
35
+ |---|---|
36
+ | `brand/tokens.json` | **The** source of truth: colors, font stacks, identity, limits |
37
+ | `brand/laws.md` | Why there are so few values: the accent law, structure, the three voices, the tracking ceiling |
38
+ | `brand/components.md` | The shared component vocabulary — masthead, standfirst, big stat, ledger, duel, terminal, table |
39
+ | `brand/agent-ui.md` | How a skill's run should read in the chat transcript |
40
+ | `brand/voice-core.md` | The copy rules that hold for every artifact |
41
+ | `targets.json` | Every place the brand is written down |
42
+
43
+ ## Composing something new
44
+
45
+ 1. **Read `brand/laws.md` first, then `brand/components.md`.** They are short.
46
+ Composing without them produces something that uses the right colors and
47
+ still looks like a different product.
48
+ 2. **Pick the components the content needs**, not a template. PRESS is a brand
49
+ system, not a template — two artifacts should never share a skeleton just
50
+ because they share a brand.
51
+ 3. **Get the values from the CLI, never from memory:**
52
+ ```bash
53
+ node bin/press.js tokens --format css # a :root block
54
+ node bin/press.js tokens --format json # raw values
55
+ node bin/press.js tokens --format md # the palette as prose
56
+ ```
57
+ 4. **Lint before showing the user:**
58
+ ```bash
59
+ node bin/press.js lint <file…> [--accent-cap 2]
60
+ ```
61
+ Findings are mechanical brand violations, not style opinions. Fix them.
62
+ 5. **Show the rendered artifact, not a description of it.** Per
63
+ `brand/agent-ui.md`, a visual claim without the image in the transcript is
64
+ not a result.
65
+
66
+ ## Checking every consumer is in sync
67
+
68
+ ```bash
69
+ node bin/press.js check # this repo
70
+ node bin/press.js check --repo ../budget
71
+ node bin/press.js doctor # the whole registry, present or not
72
+ ```
73
+
74
+ `check` fails on three things, not one: a region whose bytes drifted, a region
75
+ that has **gone missing** from a file that should have one, and a run that
76
+ resolved **zero** targets. The last two matter most — a checker that verifies
77
+ nothing reports "all clean", which is exactly how a gate turns decorative.
78
+
79
+ On failure it prints the diff and the exact `press emit` command that fixes it.
80
+ Run that; do not hand-edit the region.
81
+
82
+ ## Changing a brand value
83
+
84
+ This is the one operation that touches every product, so do it deliberately.
85
+
86
+ 1. Edit `brand/tokens.json`. One value, with a reason.
87
+ 2. `node bin/press.js emit --dry-run` and read what would change.
88
+ 3. `node bin/press.js emit`, then run the affected skills' own test suites —
89
+ a token change must not break anyone's baseline.
90
+ 4. Re-render one real artifact per affected medium and **look at it**.
91
+ 5. For repos outside this one, run `npx -y @natjswenson/press@latest emit
92
+ --repo <path>` in each. Always pin `@latest`: a bare `npx @natjswenson/press`
93
+ silently prefers a stale global install over the registry.
94
+
95
+ Bump the version and add a `CHANGELOG.md` entry in the same change.
96
+
97
+ ## Onboarding a new consumer
98
+
99
+ 1. Add a target to `targets.json`: `id`, `repo`, `path`, `region`, `syntax`
100
+ (`python` | `css` | `md`), `emitter`, and `params`.
101
+ 2. Choose the emitter:
102
+
103
+ | Emitter | Produces |
104
+ |---|---|
105
+ | `python-theme` | the token dict plus the shared deep-merge loader |
106
+ | `css-vars` | a custom-property block, with per-consumer aliases |
107
+ | `md-palette` | the palette as a prose bullet list |
108
+ | `markdown-block` | one of the brand docs, inlined into a SKILL.md |
109
+ | `json` | raw values |
110
+
111
+ 3. Add an `init` anchor naming the first and last line of the hand-written block
112
+ the region takes over, so the duplicate is **swallowed**, not left behind.
113
+ 4. `node bin/press.js emit --target <id> --init`
114
+ 5. Add a `press check` step to that repo's CI.
115
+
116
+ **Aliases are deliberate.** The résumé keeps `--sig`, the site keeps `--fg`. The
117
+ names stay idiomatic to their medium; only the values are shared. Renaming
118
+ across three repos would be churn for no gain.
119
+
120
+ **Never generate a whole file.** The region owns the token block; the consumer
121
+ owns everything else — its 250-line stylesheet, its poster geometry, its
122
+ personal avatar footer. A whole-file sync clobbered exactly that once already.
123
+
124
+ ## Adopting the prose contracts
125
+
126
+ `brand/agent-ui.md` and `brand/voice-core.md` are spliced **into** a consuming
127
+ SKILL.md as `markdown-block` regions rather than referenced. A consuming skill is
128
+ a separately installed plugin and cannot reliably read this skill's files at
129
+ runtime, so build-time splicing is the only mechanism that actually works.
130
+
131
+ A medium's own voice layers on top and **wins on conflict** — ghostwriter's
132
+ learned profile and devlog's release-note shape stay where they are. The core is
133
+ what applies when nothing more specific does.
134
+
135
+ ## What press does not decide
136
+
137
+ Layout and composition (the medium's business), chart color validation (the
138
+ `dataviz` skill), and any voice learned from the user's own writing. PRESS sets
139
+ the floor everything shares, not the whole of anything.
package/bin/press.js ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * press — the brand CLI.
4
+ *
5
+ * The agent decides; this does. Every command is deterministic, offline, and
6
+ * costs nothing, so it is safe to run in CI on every pull request.
7
+ */
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { parseArgs } from 'node:util';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { dirname, join, relative } from 'node:path';
12
+
13
+ import { loadTokens } from '../lib/tokens.mjs';
14
+ import { emitBody } from '../lib/emit.mjs';
15
+ import { initRegion, spliceRegion, findRegion } from '../lib/region.mjs';
16
+ import { loadTargets, repoRoot, selectTargets, targetPath } from '../lib/targets.mjs';
17
+ import { checkAll, EXPLAIN } from '../lib/check.mjs';
18
+ import { lintText } from '../lib/lint.mjs';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ const VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
22
+
23
+ const USAGE = `press v${VERSION} — one brand, generated into every consumer.
24
+
25
+ press emit [--target <id>…] [--repo <path>] [--init] [--dry-run]
26
+ press check [--target <id>…] [--repo <path>] [--json]
27
+ press lint <file…> [--accent-cap <n>] [--raster] [--waive <rule>…]
28
+ press doctor [--repo <path>]
29
+ press tokens [--format json|css|md]
30
+
31
+ Docs: brand/laws.md (why), brand/components.md (what), targets.json (where).`;
32
+
33
+ const OPTIONS = {
34
+ target: { type: 'string', multiple: true, default: [] },
35
+ repo: { type: 'string' },
36
+ init: { type: 'boolean', default: false },
37
+ 'dry-run': { type: 'boolean', default: false },
38
+ json: { type: 'boolean', default: false },
39
+ format: { type: 'string', default: 'json' },
40
+ 'accent-cap': { type: 'string' },
41
+ raster: { type: 'boolean', default: false },
42
+ waive: { type: 'string', multiple: true, default: [] },
43
+ help: { type: 'boolean', default: false },
44
+ };
45
+
46
+ function main(argv) {
47
+ const { values, positionals } = parseArgs({
48
+ args: argv,
49
+ options: OPTIONS,
50
+ allowPositionals: true,
51
+ });
52
+ const command = positionals[0];
53
+ if (values.help || !command) {
54
+ process.stdout.write(`${USAGE}\n`);
55
+ return command ? 0 : 1;
56
+ }
57
+
58
+ const tokens = loadTokens();
59
+ const root = repoRoot(values.repo ?? process.cwd());
60
+
61
+ switch (command) {
62
+ case 'emit':
63
+ return cmdEmit({ tokens, root, values });
64
+ case 'check':
65
+ return cmdCheck({ tokens, root, values });
66
+ case 'lint':
67
+ return cmdLint({ tokens, files: positionals.slice(1), values });
68
+ case 'doctor':
69
+ return cmdDoctor({ root, values });
70
+ case 'tokens':
71
+ return cmdTokens({ tokens, values });
72
+ default:
73
+ process.stderr.write(`press: unknown command "${command}"\n\n${USAGE}\n`);
74
+ return 1;
75
+ }
76
+ }
77
+
78
+ function cmdEmit({ tokens, root, values }) {
79
+ const targets = selectTargets(loadTargets(), { root, ids: values.target });
80
+ if (targets.length === 0) return fail('no targets resolved under this repo — nothing to emit');
81
+
82
+ const rows = [];
83
+ for (const target of targets) {
84
+ const path = targetPath(target, root);
85
+ const body = emitBody(tokens, target.emitter, target.params ?? {});
86
+ const before = readFileSync(path, 'utf8');
87
+ const has = findRegion(before, target.region, target.syntax);
88
+
89
+ let after;
90
+ if (has) {
91
+ if (values.init) return fail(`${target.id} already has a press:${target.region} region — drop --init`);
92
+ after = spliceRegion(before, target.region, target.syntax, body, VERSION);
93
+ } else {
94
+ if (!values.init) {
95
+ return fail(`${target.id} has no press:${target.region} region — re-run with --init to create one`);
96
+ }
97
+ after = initRegion(before, target.region, target.syntax, body, VERSION, target.init ?? {});
98
+ }
99
+
100
+ const changed = after !== before;
101
+ if (changed && !values['dry-run']) writeFileSync(path, after, 'utf8');
102
+ rows.push([
103
+ target.id,
104
+ target.emitter,
105
+ relative(root, path),
106
+ changed ? (values['dry-run'] ? 'would write' : has ? 'updated' : 'created') : 'unchanged',
107
+ ]);
108
+ }
109
+
110
+ table(['Target', 'Emitter', 'File', 'Result'], rows);
111
+ return 0;
112
+ }
113
+
114
+ function cmdCheck({ tokens, root, values }) {
115
+ const report = checkAll({
116
+ tokens,
117
+ targets: loadTargets(),
118
+ root,
119
+ ids: values.target,
120
+ version: VERSION,
121
+ });
122
+
123
+ if (values.json) {
124
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
125
+ return report.ok ? 0 : 1;
126
+ }
127
+
128
+ // A run that checked nothing is a failure, never a pass.
129
+ if (report.empty) {
130
+ return fail('no targets resolved under this repo — check verified nothing');
131
+ }
132
+
133
+ table(
134
+ ['Target', 'File', 'Status'],
135
+ report.results.map((r) => [r.target.id, relative(root, r.path), r.status]),
136
+ );
137
+
138
+ if (report.failures.length === 0) return 0;
139
+
140
+ for (const f of report.failures) {
141
+ process.stderr.write(`\n${f.target.id} — ${EXPLAIN[f.status]}\n`);
142
+ if (f.detail) process.stderr.write(`${f.detail}\n`);
143
+ if (f.diff) process.stderr.write(`${f.diff}\n`);
144
+ }
145
+ const ids = report.failures.map((f) => f.target.id);
146
+ process.stderr.write(
147
+ `\nFix: press emit ${ids.map((id) => `--target ${id}`).join(' ')}` +
148
+ `${report.failures.some((f) => f.status === 'missing') ? ' --init' : ''}\n`,
149
+ );
150
+ return 1;
151
+ }
152
+
153
+ function cmdLint({ tokens, files, values }) {
154
+ if (files.length === 0) return fail('lint needs at least one file');
155
+ const accentCap = values['accent-cap'] === undefined ? null : Number(values['accent-cap']);
156
+ let findings = 0;
157
+ const rows = [];
158
+
159
+ for (const file of files) {
160
+ const result = lintText(readFileSync(file, 'utf8'), tokens, {
161
+ file,
162
+ accentCap,
163
+ waivers: values.waive,
164
+ // A card is pixels by the time anyone reads it, so the tracking ceiling
165
+ // (which protects PDF text extraction) does not bind.
166
+ textExtractable: !values.raster,
167
+ });
168
+ findings += result.findings.length;
169
+ rows.push([file, String(result.findings.length), result.ok ? 'clean' : 'violations']);
170
+ for (const f of result.findings) {
171
+ process.stderr.write(`${f.file}:${f.line || '-'} ${f.rule} ${f.message}\n`);
172
+ }
173
+ }
174
+
175
+ table(['File', 'Findings', 'Status'], rows);
176
+ return findings === 0 ? 0 : 1;
177
+ }
178
+
179
+ function cmdDoctor({ root, values }) {
180
+ const all = loadTargets();
181
+ const here = new Set(selectTargets(all, { root, ids: [] }).map((t) => t.id));
182
+ table(
183
+ ['Target', 'Repo', 'File', 'Present'],
184
+ all.map((t) => [t.id, t.repo, t.path, here.has(t.id) ? 'yes' : 'no']),
185
+ );
186
+ return 0;
187
+ }
188
+
189
+ function cmdTokens({ tokens, values }) {
190
+ if (values.format === 'json') {
191
+ process.stdout.write(`${emitBody(tokens, 'json', {})}\n`);
192
+ } else if (values.format === 'css') {
193
+ const vars = [...Object.keys(tokens.colors), 'hair'];
194
+ process.stdout.write(`${emitBody(tokens, 'css-vars', { vars, align: true, comments: false })}\n`);
195
+ } else if (values.format === 'md') {
196
+ process.stdout.write(`${emitBody(tokens, 'md-palette', {})}\n`);
197
+ } else {
198
+ return fail(`unknown --format "${values.format}" (json, css, md)`);
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ // --- output ---------------------------------------------------------------
204
+
205
+ function table(headers, rows) {
206
+ const widths = headers.map((h, i) =>
207
+ Math.max(h.length, ...rows.map((r) => String(r[i] ?? '').length)),
208
+ );
209
+ const line = (cells) => `| ${cells.map((c, i) => String(c ?? '').padEnd(widths[i])).join(' | ')} |`;
210
+ process.stdout.write(`${line(headers)}\n`);
211
+ process.stdout.write(`|${widths.map((w) => '-'.repeat(w + 2)).join('|')}|\n`);
212
+ for (const row of rows) process.stdout.write(`${line(row)}\n`);
213
+ }
214
+
215
+ function fail(message) {
216
+ process.stderr.write(`press: ${message}\n`);
217
+ return 1;
218
+ }
219
+
220
+ try {
221
+ process.exitCode = main(process.argv.slice(2));
222
+ } catch (err) {
223
+ process.stderr.write(`press: ${err.message}\n`);
224
+ process.exitCode = 1;
225
+ }
@@ -0,0 +1,33 @@
1
+ ## Presentation — how a run should look
2
+
3
+ This skill is watched, not just run. Everything below assumes the user is
4
+ reading the conversation, so **the transcript is part of the product.**
5
+
6
+ **Keep the machinery invisible.** The user should see a short status line and a
7
+ table, not a scroll of raw command output. Concretely:
8
+
9
+ - **Never print file contents into the conversation.** Not a fetched page, not a
10
+ source file, not a script's own source. Scripts hand each other *paths*; when
11
+ you need a file's text in context, use the `Read` tool rather than `cat`,
12
+ `sed`, `head`, or a `--show` flag. Anything the user already has open
13
+ somewhere is a wall of text in chat.
14
+ - **One script call, not a pipeline.** Every step should be a single command that
15
+ returns everything you need. If you find yourself chaining `sed`/`grep`/
16
+ `python3 -` to reshape output, the script should have given it to you — say so
17
+ rather than working around it.
18
+ - **Report in tables, with named columns.** Ad-hoc prose summaries are why runs
19
+ read inconsistently. Every stage that produces more than one fact reports a
20
+ table with a fixed column set, declared in this skill's own steps below.
21
+ Omit noise: don't list unchanged fields, don't repeat inputs back, don't show
22
+ paths the user can't act on.
23
+ - **Show, don't describe.** When a run produces something visual, `Read` the
24
+ rendered image so the user sees it, instead of writing a paragraph about it.
25
+ - **Never claim a visual result without the artifact.** "It looks better" with no
26
+ PNG in the transcript is not a result.
27
+
28
+ **The exception — narrate the slow parts.** Anything that takes more than a
29
+ couple of seconds gets one short lowercase line as it starts (`fetching the
30
+ posting…`, `rendering press + ats-plain…`) so the user sees progress rather than
31
+ dead air. One line each, not a table.
32
+
33
+ **Announce the skill once, at the start**, in one sentence, and never again.
@@ -0,0 +1,64 @@
1
+ # The shared component vocabulary
2
+
3
+ Every PRESS medium composes from the same named parts. The *geometry* differs by
4
+ medium — a masthead is 8px of rule and a 62px stamp on a 1200×1500 poster card,
5
+ and 8px of rule with a 0.8rem stamp on a letter page — but the **name, the
6
+ anatomy, and the job** are fixed. That is what makes a card and a report
7
+ recognisably the same publication.
8
+
9
+ Class names below are the contract. A medium may add classes; it may not rename
10
+ these or change what they mean.
11
+
12
+ ## Frame — present on every document
13
+
14
+ | Part | Class | Anatomy | Job |
15
+ |---|---|---|---|
16
+ | **Masthead** | `.mast` / `header.masthead` | heavy top rule (8px ink) · `.stamp` (rotated -4°, accent border + initials) · `.eyebrow` (tracked-caps mono issue line) · `.byline` (right-aligned, dim, mono) | says whose publication this is, and which issue |
17
+ | **Headline** | `h1` | display sans 900, tracking `-0.03em`, line-height ~1.0 | the one claim |
18
+ | **Standfirst** | `.stand` / `p.standfirst` | serif italic, dim, ≤3 lines | the setup |
19
+ | **Colophon** | `.colophon` / `footer.provenance` | 2px ink rule above · mono, dim · optional circular avatar | what this was for, where the numbers came from |
20
+
21
+ The headline carries **at most one** accent-colored pivot phrase (`.sig`). That
22
+ is usually where the document's single accent moment is spent.
23
+
24
+ ## Body — pick what the content needs, never all of them
25
+
26
+ | Part | Class | Proves | Budget |
27
+ |---|---|---|---|
28
+ | **Big stat** | `.bigstat > .fig` (+ `.unit`, `.kicker`) | a number-led claim | `.fig` ≤6 chars |
29
+ | **Stat strip** | `.stat-strip > .stat` (`.value .label .bench`) | 3–6 headline figures at a glance | ruled above and below, no tiles |
30
+ | **Facts strip** | `.facts > .fact` (`.flabel .fval .fcap`) | 2–4 quick specs | `.fval` ≤14 chars |
31
+ | **Ledger** | `.ledger > .lrow` (`.lno .lbody .lt .le`) | a method, 3–4 steps | `.lt` ≤38 · `.le` ≤60 |
32
+ | **Tiles** | `.tiles > .tile` (`.tno .tt .te`) | exactly 4 compact steps | `.tt` ≤22/line |
33
+ | **Duel** | `.duel > .side.lose/.win` (`.verdict .who .how`) | a decision between two | 2 sides, `.how` ≤40 |
34
+ | **Bars** | `.bars > .brow` (`.blabel .btrack > .bfill .bval`) | a comparison of magnitudes | 3–4 rows, every bar labelled |
35
+ | **Pull quote** | `.pull > .q` (+ `.qrule`) | the thesis | ≤2 lines |
36
+ | **Terminal** | `.term > .tl` (+ `.prompt`, `.dim`, `.hot`) | that it is real | see below |
37
+ | **Data table** | `table.data` | the full numbers | mono, ink rules, never zebra |
38
+ | **Section** | `section.report-section` + `h3.block-title` | an editorial division | 2px ink rule above |
39
+ | **Marginal** | `.marginal` (`.ast`) | the gotcha, as a footnote | ≤2 lines |
40
+ | **Caption** | `p.caption` | what a chart is showing | serif italic, dim |
41
+
42
+ **The hero earns roughly half the surface.** Whichever part proves the point gets
43
+ the space; the supporting parts stay small. Two to three body parts per card,
44
+ more only in a long-form report where sections do the pacing.
45
+
46
+ ## The terminal is a transcription, not scenery
47
+
48
+ When a terminal appears, it is real captured output, condensed by **cutting whole
49
+ rows** — never by smoothing real output into summary prose. A 5-column table cut
50
+ to 3 rows still reads real; the same data rewritten as a sentence reads like
51
+ marketing. Missing values print `—`, the way a real CLI does; never invent one.
52
+ Monospace alignment is binary — every row pads to one shared character width.
53
+
54
+ ## Charts
55
+
56
+ Charts obey `laws.md` §5: the ink ramp encodes magnitude only, every mark is
57
+ directly labelled, and any two-series chart carries a legend. Colour choices
58
+ beyond that belong to the `dataviz` skill, not to PRESS.
59
+
60
+ ## Unavailable data
61
+
62
+ `p.unavailable` — mono, dim, a 2px dim rule on the left. A section with no data
63
+ says so in place. It is never silently dropped, because a missing section and a
64
+ section that doesn't exist look identical to the reader.