@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 +66 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/SKILL.md +139 -0
- package/bin/press.js +225 -0
- package/brand/agent-ui.md +33 -0
- package/brand/components.md +64 -0
- package/brand/laws.md +105 -0
- package/brand/tokens.json +56 -0
- package/brand/voice-core.md +44 -0
- package/lib/check.mjs +91 -0
- package/lib/emit.mjs +332 -0
- package/lib/lint.mjs +130 -0
- package/lib/region.mjs +182 -0
- package/lib/targets.mjs +67 -0
- package/lib/tokens.mjs +86 -0
- package/package.json +47 -0
- package/skill-invariants.json +73 -0
- package/targets.json +336 -0
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.
|