@natjswenson/press 0.2.0 → 0.4.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 +77 -0
- package/SKILL.md +69 -8
- package/bin/press.js +33 -0
- package/brand/tokens.json +16 -4
- package/lib/emit.mjs +24 -7
- package/lib/propagate.mjs +117 -0
- package/lib/tokens.mjs +23 -1
- package/package.json +1 -1
- package/skill-invariants.json +16 -4
- package/targets.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,81 @@ All notable changes to the **press** skill are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
6
6
|
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-08-01
|
|
9
|
+
|
|
10
|
+
Closes the propagation gap: a pinned check can tell you a consumer is intact,
|
|
11
|
+
but never that it is *current*.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`press propagate`** — re-emits every region in a consumer's checkout, bumps
|
|
16
|
+
any `@natjswenson/press@<version>` pin in its workflows, and reports what
|
|
17
|
+
moved. `--dry-run` answers "is this repo behind?"; `--json` is what CI
|
|
18
|
+
branches on.
|
|
19
|
+
- **`.github/workflows/press-propagate.yml`** — runs on every `press-v*` tag and
|
|
20
|
+
weekly, opening a PR in each consumer repo whose bytes actually changed. The
|
|
21
|
+
consumer list is derived from `targets.json` (with an optional `github` field
|
|
22
|
+
where the remote name differs), so adding a consumer stays one declaration.
|
|
23
|
+
Without `PRESS_PROPAGATE_TOKEN` it reports what it would do and exits clean,
|
|
24
|
+
rather than failing a release over a missing credential.
|
|
25
|
+
|
|
26
|
+
### Why
|
|
27
|
+
|
|
28
|
+
Consumers pin an exact version on purpose — a mutable reference in a repo that
|
|
29
|
+
auto-deploys to production is a supply-chain hole. But a pinned check passes
|
|
30
|
+
forever against the version it was pinned to: `natejswenson.io` sat **two
|
|
31
|
+
releases behind with entirely green CI**. Integrity and freshness are different
|
|
32
|
+
questions, and only the first was being answered.
|
|
33
|
+
|
|
34
|
+
So freshness is pushed from the source of truth rather than polled by each
|
|
35
|
+
consumer. Two rules keep it honest:
|
|
36
|
+
|
|
37
|
+
- **Content decides, not the version receipt.** A region written by 0.1.0 that
|
|
38
|
+
is still byte-correct today is current; opening a PR for it would be noise.
|
|
39
|
+
- **A stale pin alone is not "behind".** It changes no shipped artifact, so it
|
|
40
|
+
is bumped quietly and never triggers a PR by itself.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `changed` was derived by matching `/updated$/` against a status string, which
|
|
45
|
+
silently missed `"would update"` — so `--dry-run` reported "nothing to do"
|
|
46
|
+
while displaying a changed region. It is a boolean now, and a test pins that.
|
|
47
|
+
|
|
48
|
+
## [0.3.0] - 2026-08-01
|
|
49
|
+
|
|
50
|
+
Font stacks become per-engine profiles. Migrating `local-fitness` measured a real
|
|
51
|
+
regression the previous single-stack model would have shipped.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **`fonts.profiles`** — the brand's type *intent* is one thing; the fallback
|
|
56
|
+
chain that achieves it depends on the rendering engine. Targets pick one with
|
|
57
|
+
`params.font_profile`; omitting it uses `fonts.default_profile` (`browser`), so
|
|
58
|
+
every existing consumer is byte-for-byte unchanged.
|
|
59
|
+
- **`browser`** — Chromium/WebKit. `-apple-system` resolves to SF and the
|
|
60
|
+
fallbacks are never reached, so depth is free.
|
|
61
|
+
- **`fontconfig`** — WeasyPrint and anything else walking the chain for real.
|
|
62
|
+
Deliberately shallower: every extra face is one that can *win*.
|
|
63
|
+
- An unknown `font_profile` is an error, never a silent fall back to the default.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **The union stack visibly broke WeasyPrint output.** `local-fitness` renders
|
|
68
|
+
through WeasyPrint, which resolves none of the `ui-*` / `-apple-system`
|
|
69
|
+
keywords and walks the chain for real. The browser-tuned list put `Inter` and
|
|
70
|
+
`'Helvetica Neue'` ahead of `Arial`, and headlines resolved to
|
|
71
|
+
**Helvetica Neue Heavy Condensed** — measured with `pdffonts`, not assumed.
|
|
72
|
+
Under the `fontconfig` profile it renders `Arial-Bold` / `Georgia-Bold` /
|
|
73
|
+
`Menlo-Bold`, exactly as it always has.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **Migrated `budget` and `local-fitness`.** budget renders through headless
|
|
78
|
+
Chrome, where the widened stacks are provably inert: identical embedded faces
|
|
79
|
+
and byte-identical rendered pages. local-fitness takes the `fontconfig`
|
|
80
|
+
profile and its emitted theme changed **zero values**. Suites stay green
|
|
81
|
+
(894 and 2063 tests).
|
|
82
|
+
|
|
8
83
|
## [0.2.0] - 2026-08-01
|
|
9
84
|
|
|
10
85
|
Everything `natejswenson.io` needed to adopt the brand without changing by a
|
|
@@ -92,5 +167,7 @@ source of truth and a CI drift gate.
|
|
|
92
167
|
rasterised cards, whose eyebrow legitimately runs at `.16em`. Scoped rather
|
|
93
168
|
than waived, and caught by linting the real shipped corpus.
|
|
94
169
|
|
|
170
|
+
[0.4.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.4.0
|
|
171
|
+
[0.3.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.3.0
|
|
95
172
|
[0.2.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.2.0
|
|
96
173
|
[0.1.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.1.0
|
package/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: press
|
|
3
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
4
|
user_invocable: true
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.4.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# /press — the brand system
|
|
@@ -79,6 +79,46 @@ nothing reports "all clean", which is exactly how a gate turns decorative.
|
|
|
79
79
|
On failure it prints the diff and the exact `press emit` command that fixes it.
|
|
80
80
|
Run that; do not hand-edit the region.
|
|
81
81
|
|
|
82
|
+
## How a change reaches every product
|
|
83
|
+
|
|
84
|
+
There are **two different questions**, and conflating them is what leaves a
|
|
85
|
+
consumer silently stale.
|
|
86
|
+
|
|
87
|
+
| | Question | Answered by | Runs |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| **Integrity** | does this region match the press version this repo adopted? | `press check` | inside each consumer, against a **pinned** version |
|
|
90
|
+
| **Freshness** | has this repo adopted the **current** brand? | `press propagate` | from press, against a consumer's checkout |
|
|
91
|
+
|
|
92
|
+
**A pinned check can never answer the second question** — it passes forever
|
|
93
|
+
against the version it was pinned to. That is not a hypothetical: this skill's
|
|
94
|
+
own site consumer sat two releases behind with entirely green CI.
|
|
95
|
+
|
|
96
|
+
**Consumers pin on purpose.** A mutable `@latest` in a repo that auto-deploys to
|
|
97
|
+
production is a supply-chain hole, and a reproducible check is the only kind
|
|
98
|
+
worth gating a merge on. So freshness is **pushed from here** rather than polled
|
|
99
|
+
by each consumer:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node bin/press.js propagate --repo ../budget --dry-run # is it behind?
|
|
103
|
+
node bin/press.js propagate --repo ../budget # re-emit + bump its pin
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`propagate` re-emits every region it resolves, bumps any
|
|
107
|
+
`@natjswenson/press@<version>` pin it finds in that repo's workflows, and reports
|
|
108
|
+
what moved. The caller turns that into a pull request, so nothing lands
|
|
109
|
+
unreviewed — but nobody has to *remember*.
|
|
110
|
+
|
|
111
|
+
Two rules keep it from becoming noise or a false negative:
|
|
112
|
+
|
|
113
|
+
- **Content decides, not the version receipt.** A region written by 0.1.0 that
|
|
114
|
+
is still byte-correct under 0.3.0 is *current*. Opening a PR for it would be
|
|
115
|
+
noise.
|
|
116
|
+
- **A stale pin alone is not "behind".** It changes no shipped artifact, so it
|
|
117
|
+
is bumped silently and never triggers a PR on its own.
|
|
118
|
+
|
|
119
|
+
`.github/workflows/press-propagate.yml` runs this on every release and weekly,
|
|
120
|
+
opening a PR in each consumer repo whose bytes actually moved.
|
|
121
|
+
|
|
82
122
|
## Changing a brand value
|
|
83
123
|
|
|
84
124
|
This is the one operation that touches every product, so do it deliberately.
|
|
@@ -88,11 +128,15 @@ This is the one operation that touches every product, so do it deliberately.
|
|
|
88
128
|
3. `node bin/press.js emit`, then run the affected skills' own test suites —
|
|
89
129
|
a token change must not break anyone's baseline.
|
|
90
130
|
4. Re-render one real artifact per affected medium and **look at it**.
|
|
91
|
-
5.
|
|
92
|
-
--repo <path>` in each. Always pin `@latest`: a bare `npx @natjswenson/press`
|
|
93
|
-
silently prefers a stale global install over the registry.
|
|
131
|
+
5. Bump the version and add a `CHANGELOG.md` entry in the same change.
|
|
94
132
|
|
|
95
|
-
|
|
133
|
+
**Repos outside this one need no manual step.** Releasing the version bump fires
|
|
134
|
+
`press-propagate.yml`, which opens a PR in every consumer whose bytes actually
|
|
135
|
+
moved. To see it early, dry-run against a local checkout:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
node bin/press.js propagate --repo ../budget --dry-run
|
|
139
|
+
```
|
|
96
140
|
|
|
97
141
|
## Onboarding a new consumer
|
|
98
142
|
|
|
@@ -108,10 +152,27 @@ Bump the version and add a `CHANGELOG.md` entry in the same change.
|
|
|
108
152
|
| `markdown-block` | one of the brand docs, inlined into a SKILL.md |
|
|
109
153
|
| `json` | raw values |
|
|
110
154
|
|
|
111
|
-
3.
|
|
155
|
+
3. **Pick the font profile for its rendering engine.** `params.font_profile` is
|
|
156
|
+
`browser` by default. Anything going through **fontconfig — WeasyPrint above
|
|
157
|
+
all — must say `fontconfig`**, because it walks the fallback chain for real
|
|
158
|
+
and the browser chain resolves headlines to Helvetica Neue Heavy Condensed.
|
|
159
|
+
Survey the consumer's current stacks before you assume.
|
|
160
|
+
4. Add an `init` anchor naming the first and last line of the hand-written block
|
|
112
161
|
the region takes over, so the duplicate is **swallowed**, not left behind.
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
5. `node bin/press.js emit --target <id> --init`
|
|
163
|
+
6. Add a `press check` step to that repo's CI, **pinned to an exact version**:
|
|
164
|
+
`npx -y @natjswenson/press@0.4.0 check --repo .`. Never write a bare
|
|
165
|
+
`npx @natjswenson/press` — with no version reference at all, npx silently
|
|
166
|
+
prefers a stale global install over the registry, which cost this repo a
|
|
167
|
+
release once already. An exact pin also keeps a mutable dependency out of a
|
|
168
|
+
repo that auto-deploys to production; `propagate` bumps it for you.
|
|
169
|
+
7. If its GitHub repo name differs from the `repo` field (budget lives at
|
|
170
|
+
`local-budget`), add `"github": "<name>"` so propagation can find it.
|
|
171
|
+
|
|
172
|
+
**Survey before you emit.** Diff the consumer's real values against the token set
|
|
173
|
+
first: adopting the site turned up a `'JetBrains Mono'` fallback and three alpha
|
|
174
|
+
tints the tokens could not express, and emitting blindly would have silently
|
|
175
|
+
dropped them. Widen the tokens to the union; never narrow a consumer.
|
|
115
176
|
|
|
116
177
|
**Aliases are deliberate.** The résumé keeps `--sig`, the site keeps `--fg`. The
|
|
117
178
|
names stay idiomatic to their medium; only the values are shared. Renaming
|
package/bin/press.js
CHANGED
|
@@ -16,6 +16,7 @@ import { initRegion, spliceRegion, findRegion } from '../lib/region.mjs';
|
|
|
16
16
|
import { loadTargets, repoRoot, selectTargets, targetPath } from '../lib/targets.mjs';
|
|
17
17
|
import { checkAll, EXPLAIN } from '../lib/check.mjs';
|
|
18
18
|
import { lintText } from '../lib/lint.mjs';
|
|
19
|
+
import { propagate } from '../lib/propagate.mjs';
|
|
19
20
|
|
|
20
21
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
|
|
@@ -25,6 +26,7 @@ const USAGE = `press v${VERSION} — one brand, generated into every consumer.
|
|
|
25
26
|
press emit [--target <id>…] [--repo <path>] [--init] [--dry-run]
|
|
26
27
|
press check [--target <id>…] [--repo <path>] [--json]
|
|
27
28
|
press lint <file…> [--accent-cap <n>] [--raster] [--waive <rule>…]
|
|
29
|
+
press propagate [--repo <path>] [--dry-run] [--json]
|
|
28
30
|
press doctor [--repo <path>]
|
|
29
31
|
press tokens [--format json|css|md]
|
|
30
32
|
|
|
@@ -65,6 +67,8 @@ function main(argv) {
|
|
|
65
67
|
return cmdCheck({ tokens, root, values });
|
|
66
68
|
case 'lint':
|
|
67
69
|
return cmdLint({ tokens, files: positionals.slice(1), values });
|
|
70
|
+
case 'propagate':
|
|
71
|
+
return cmdPropagate({ tokens, root, values });
|
|
68
72
|
case 'doctor':
|
|
69
73
|
return cmdDoctor({ root, values });
|
|
70
74
|
case 'tokens':
|
|
@@ -176,6 +180,35 @@ function cmdLint({ tokens, files, values }) {
|
|
|
176
180
|
return findings === 0 ? 0 : 1;
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
function cmdPropagate({ tokens, root, values }) {
|
|
184
|
+
const report = propagate({
|
|
185
|
+
tokens,
|
|
186
|
+
targets: loadTargets(),
|
|
187
|
+
root,
|
|
188
|
+
version: VERSION,
|
|
189
|
+
dryRun: values['dry-run'],
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (values.json) {
|
|
193
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (report.regions.length === 0) {
|
|
198
|
+
return fail('no targets resolved under this repo — nothing to propagate');
|
|
199
|
+
}
|
|
200
|
+
table(['Target', 'File', 'Wrote by', 'Result'],
|
|
201
|
+
report.regions.map((r) => [r.id, r.path, r.wroteBy ?? '—', r.status]));
|
|
202
|
+
if (report.pins.length) {
|
|
203
|
+
table(['Workflow', 'Pinned', 'Now', 'Result'],
|
|
204
|
+
report.pins.map((p) => [p.file, p.from, p.to, p.status]));
|
|
205
|
+
}
|
|
206
|
+
process.stdout.write(report.changed
|
|
207
|
+
? `\nThis repo is BEHIND — ${report.stale.join(', ')} changed. Commit and open a PR.\n`
|
|
208
|
+
: `\nAlready on press v${VERSION}; nothing to do.\n`);
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
179
212
|
function cmdDoctor({ root, values }) {
|
|
180
213
|
const all = loadTargets();
|
|
181
214
|
const here = new Set(selectTargets(all, { root, ids: [] }).map((t) => t.id));
|
package/brand/tokens.json
CHANGED
|
@@ -41,10 +41,22 @@
|
|
|
41
41
|
"fill_steps": ["ink", "ink_mid", "dim"]
|
|
42
42
|
},
|
|
43
43
|
"fonts": {
|
|
44
|
-
"$comment": "The
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
44
|
+
"$comment": "The brand's type INTENT is one thing — SF-like sans for structure, New York-like serif for commentary, SF Mono-like mono for data. The fallback chain that actually achieves it depends on the rendering engine, so there are profiles rather than one list. Chromium resolves -apple-system to SF and never reaches the fallbacks; fontconfig (WeasyPrint) resolves none of the ui-*/-apple-system keywords and walks the chain for real, where a browser-tuned list lands on Helvetica Neue *Heavy Condensed* — measured with pdffonts, not assumed. Pick with params.font_profile; omitting it gives you `default_profile`. Single quotes so the same string is valid CSS and needs no escaping in a Python literal.",
|
|
45
|
+
"default_profile": "browser",
|
|
46
|
+
"profiles": {
|
|
47
|
+
"browser": {
|
|
48
|
+
"$comment": "Chromium/WebKit. -apple-system wins on macOS; the rest is depth for other platforms. The union of every browser consumer's chain, since fallbacks are additive.",
|
|
49
|
+
"display_stack": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, 'Helvetica Neue', Arial, sans-serif",
|
|
50
|
+
"serif_stack": "'New York', ui-serif, Georgia, 'Iowan Old Style', 'Times New Roman', serif",
|
|
51
|
+
"mono_stack": "ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace"
|
|
52
|
+
},
|
|
53
|
+
"fontconfig": {
|
|
54
|
+
"$comment": "WeasyPrint and anything else going through fontconfig, where the chain is walked for real. Measured to land on Arial-Bold / Georgia-Bold / Menlo-Bold — the faces local-fitness has always rendered. Deliberately shallower than the browser profile: every extra face here is one that can win, so depth is risk rather than robustness.",
|
|
55
|
+
"display_stack": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif",
|
|
56
|
+
"serif_stack": "'New York', ui-serif, Georgia, 'Times New Roman', serif",
|
|
57
|
+
"mono_stack": "ui-monospace, 'SF Mono', Menlo, monospace"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
48
60
|
},
|
|
49
61
|
"identity": {
|
|
50
62
|
"stamp": "NS",
|
package/lib/emit.mjs
CHANGED
|
@@ -48,6 +48,7 @@ function pythonTheme(tokens, params) {
|
|
|
48
48
|
const brandLine = required(params, 'brand_line', 'python-theme');
|
|
49
49
|
const byline = params.byline ?? tokens.identity.byline;
|
|
50
50
|
const docKind = params.document_kind ?? 'DOCUMENT';
|
|
51
|
+
const fonts = fontsFor(tokens, params);
|
|
51
52
|
|
|
52
53
|
const out = [];
|
|
53
54
|
out.push('import copy');
|
|
@@ -88,11 +89,11 @@ function pythonTheme(tokens, params) {
|
|
|
88
89
|
out.push(' },');
|
|
89
90
|
out.push(' "fonts": {');
|
|
90
91
|
out.push(' # Display/structure voice, set 800-900 with tight tracking by the CSS.');
|
|
91
|
-
out.push(` "display_stack": ${py(
|
|
92
|
+
out.push(` "display_stack": ${py(fonts.display_stack)},`);
|
|
92
93
|
out.push(' # Commentary voice: serif italics for standfirsts and captions.');
|
|
93
|
-
out.push(` "serif_stack": ${py(
|
|
94
|
+
out.push(` "serif_stack": ${py(fonts.serif_stack)},`);
|
|
94
95
|
out.push(' # Data voice: labels, dates, tables, provenance.');
|
|
95
|
-
out.push(` "mono_stack": ${py(
|
|
96
|
+
out.push(` "mono_stack": ${py(fonts.mono_stack)},`);
|
|
96
97
|
if (extras.has('mono_file')) {
|
|
97
98
|
out.push(' # Point at a real TTF to load an authentic mono face via @font-face.');
|
|
98
99
|
out.push(' "mono_file": None,');
|
|
@@ -191,7 +192,7 @@ function cssVars(tokens, params) {
|
|
|
191
192
|
const name = (spec.name ?? spec.token).replace(/_/g, '-');
|
|
192
193
|
const raw = spec.token === 'stamp'
|
|
193
194
|
? (params.stamp ?? tokens.identity.stamp)
|
|
194
|
-
: lookup(tokens, spec.token);
|
|
195
|
+
: lookup(tokens, spec.token, params);
|
|
195
196
|
const value = spec.quote ? JSON.stringify(raw) : raw;
|
|
196
197
|
// `comments: "explicit"` keeps only hand-written hints; the token notes are
|
|
197
198
|
// written for a reader of tokens.json and are far too long for a CSS block.
|
|
@@ -253,7 +254,7 @@ function jsonTokens(tokens, params) {
|
|
|
253
254
|
const payload = {
|
|
254
255
|
colors: tokens.colors,
|
|
255
256
|
terminal: tokens.terminal,
|
|
256
|
-
fonts: tokens
|
|
257
|
+
fonts: fontsFor(tokens, params),
|
|
257
258
|
identity: { ...tokens.identity, ...(params.stamp ? { stamp: params.stamp } : {}) },
|
|
258
259
|
derived: { hair: tokens.derived.hair, fill_steps: tokens.derived.fillSteps },
|
|
259
260
|
};
|
|
@@ -291,6 +292,22 @@ function required(params, key, emitter) {
|
|
|
291
292
|
return value;
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The font stacks for this target's rendering engine. Defaults to the token
|
|
297
|
+
* set's own default profile, so a target that says nothing is unchanged.
|
|
298
|
+
*/
|
|
299
|
+
function fontsFor(tokens, params = {}) {
|
|
300
|
+
const name = params.font_profile;
|
|
301
|
+
if (!name) return tokens.fonts;
|
|
302
|
+
const profile = tokens.fontProfiles?.[name];
|
|
303
|
+
if (!profile) {
|
|
304
|
+
throw new EmitError(
|
|
305
|
+
`unknown font_profile "${name}" (available: ${Object.keys(tokens.fontProfiles ?? {}).join(', ')})`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return profile;
|
|
309
|
+
}
|
|
310
|
+
|
|
294
311
|
const DERIVED = {
|
|
295
312
|
hair: 'hair',
|
|
296
313
|
border: 'border',
|
|
@@ -298,9 +315,9 @@ const DERIVED = {
|
|
|
298
315
|
accent_dim: 'accentDim',
|
|
299
316
|
};
|
|
300
317
|
|
|
301
|
-
function lookup(tokens, name) {
|
|
318
|
+
function lookup(tokens, name, params = {}) {
|
|
302
319
|
if (name in DERIVED) return tokens.derived[DERIVED[name]];
|
|
303
|
-
for (const group of [tokens.colors, tokens.terminal, tokens
|
|
320
|
+
for (const group of [tokens.colors, tokens.terminal, fontsFor(tokens, params), tokens.identity, tokens.marks]) {
|
|
304
321
|
if (name in group) return group[name];
|
|
305
322
|
}
|
|
306
323
|
throw new EmitError(`unknown token "${name}"`);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Propagation — how a token change reaches every product.
|
|
3
|
+
*
|
|
4
|
+
* There are two different questions, and conflating them is what leaves a
|
|
5
|
+
* consumer silently stale:
|
|
6
|
+
*
|
|
7
|
+
* INTEGRITY "does this region match the press version this repo adopted?"
|
|
8
|
+
* Answered by `check`, run inside each consumer against a PINNED
|
|
9
|
+
* version. Pinned because a mutable reference in a repo that
|
|
10
|
+
* auto-deploys to production is a supply-chain hole, and because a
|
|
11
|
+
* reproducible check is the only kind worth gating a merge on.
|
|
12
|
+
*
|
|
13
|
+
* FRESHNESS "has this repo adopted the CURRENT brand?"
|
|
14
|
+
* Answered here, by running the newest press against a consumer's
|
|
15
|
+
* checkout. A pinned check can never answer this — it passes
|
|
16
|
+
* forever against the version it was pinned to, which is exactly
|
|
17
|
+
* how natejswenson.io sat two releases behind with green CI.
|
|
18
|
+
*
|
|
19
|
+
* So freshness is pushed from the source of truth rather than polled by each
|
|
20
|
+
* consumer: press re-emits into the checkout, bumps the pin it finds, and the
|
|
21
|
+
* caller turns that into a pull request. Consumers still review and merge, so
|
|
22
|
+
* nothing lands unreviewed — but nobody has to *remember*.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import { emitBody } from './emit.mjs';
|
|
28
|
+
import { findRegion, spliceRegion } from './region.mjs';
|
|
29
|
+
import { selectTargets, targetPath } from './targets.mjs';
|
|
30
|
+
|
|
31
|
+
/** The npm reference consumers pin in CI, e.g. `@natjswenson/press@0.3.0`. */
|
|
32
|
+
const PIN_RE = /(@natjswenson\/press@)(\d+\.\d+\.\d+)/g;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Re-emit every resolvable region in `root` and bump any pinned press version
|
|
36
|
+
* found in its workflows.
|
|
37
|
+
*
|
|
38
|
+
* Returns a summary rather than printing, so the same call serves the human
|
|
39
|
+
* table, the `--json` output a CI job branches on, and the tests.
|
|
40
|
+
*/
|
|
41
|
+
export function propagate({ tokens, targets, root, version, dryRun = false }) {
|
|
42
|
+
const selected = selectTargets(targets, { root, ids: [] });
|
|
43
|
+
const regions = [];
|
|
44
|
+
|
|
45
|
+
for (const target of selected) {
|
|
46
|
+
const path = targetPath(target, root);
|
|
47
|
+
const before = readFileSync(path, 'utf8');
|
|
48
|
+
const found = findRegion(before, target.region, target.syntax);
|
|
49
|
+
if (!found) {
|
|
50
|
+
regions.push({ id: target.id, path: target.path, status: 'missing' });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const body = emitBody(tokens, target.emitter, target.params ?? {});
|
|
54
|
+
const after = spliceRegion(before, target.region, target.syntax, body, version);
|
|
55
|
+
// Compare the region bodies, not whole files: the start marker carries the
|
|
56
|
+
// emitting version, so a no-op release would otherwise look like a change
|
|
57
|
+
// and open a pull request that alters nothing anyone can see.
|
|
58
|
+
const changed = found.body.replace(/\s+$/, '') !== body.replace(/\s+$/, '');
|
|
59
|
+
if (changed && !dryRun) writeFileSync(path, after, 'utf8');
|
|
60
|
+
regions.push({
|
|
61
|
+
id: target.id,
|
|
62
|
+
path: target.path,
|
|
63
|
+
// `changed` is carried as a boolean, never re-derived from the status
|
|
64
|
+
// string: matching /updated$/ silently missed "would update", so a
|
|
65
|
+
// dry run reported "nothing to do" while showing a changed region.
|
|
66
|
+
changed,
|
|
67
|
+
status: changed ? (dryRun ? 'would update' : 'updated') : 'current',
|
|
68
|
+
wroteBy: found.version,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pins = bumpPins(root, version, dryRun);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
root,
|
|
76
|
+
version,
|
|
77
|
+
regions,
|
|
78
|
+
pins,
|
|
79
|
+
// A repo is behind if its bytes moved. A pin bump alone is not worth a pull
|
|
80
|
+
// request — it changes no shipped artifact.
|
|
81
|
+
changed: regions.some((r) => r.changed),
|
|
82
|
+
stale: regions.filter((r) => r.changed).map((r) => r.id),
|
|
83
|
+
missing: regions.filter((r) => r.status === 'missing').map((r) => r.id),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Rewrite `@natjswenson/press@X` to the running version across a repo's
|
|
89
|
+
* workflow files. The pin and the region must move together: adopting new
|
|
90
|
+
* bytes while still pinning the old version makes that repo's own `check` fail,
|
|
91
|
+
* which would be a confusing way to learn about a brand update.
|
|
92
|
+
*/
|
|
93
|
+
function bumpPins(root, version, dryRun) {
|
|
94
|
+
const dir = join(root, '.github', 'workflows');
|
|
95
|
+
if (!existsSync(dir)) return [];
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const name of readdirSync(dir)) {
|
|
98
|
+
if (!/\.ya?ml$/.test(name)) continue;
|
|
99
|
+
const path = join(dir, name);
|
|
100
|
+
const text = readFileSync(path, 'utf8');
|
|
101
|
+
const found = [...text.matchAll(PIN_RE)].map((m) => m[2]);
|
|
102
|
+
if (found.length === 0) continue;
|
|
103
|
+
const behind = found.filter((v) => v !== version);
|
|
104
|
+
if (behind.length === 0) {
|
|
105
|
+
out.push({ file: `.github/workflows/${name}`, from: found[0], to: version, status: 'current' });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!dryRun) writeFileSync(path, text.replace(PIN_RE, `$1${version}`), 'utf8');
|
|
109
|
+
out.push({
|
|
110
|
+
file: `.github/workflows/${name}`,
|
|
111
|
+
from: [...new Set(behind)].join(', '),
|
|
112
|
+
to: version,
|
|
113
|
+
status: dryRun ? 'would bump' : 'bumped',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
package/lib/tokens.mjs
CHANGED
|
@@ -37,6 +37,24 @@ const clean = (group) => {
|
|
|
37
37
|
return out;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The three stacks for one rendering engine. An unknown profile name is an
|
|
42
|
+
* error rather than a silent fall back to the default — a target asking for
|
|
43
|
+
* `fontconfig` and quietly getting the browser chain is precisely the bug this
|
|
44
|
+
* whole mechanism exists to prevent.
|
|
45
|
+
*/
|
|
46
|
+
function fontProfile(raw, name) {
|
|
47
|
+
const profiles = raw.fonts?.profiles ?? {};
|
|
48
|
+
const key = name ?? raw.fonts?.default_profile;
|
|
49
|
+
const profile = profiles[key];
|
|
50
|
+
if (!profile) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`unknown font profile "${key}" (available: ${Object.keys(profiles).join(', ')})`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return clean(profile);
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
function resolve(raw) {
|
|
41
59
|
const colors = clean(raw.colors);
|
|
42
60
|
const terminal = clean(raw.terminal);
|
|
@@ -64,7 +82,11 @@ function resolve(raw) {
|
|
|
64
82
|
colors,
|
|
65
83
|
terminal,
|
|
66
84
|
notes: clean(raw.color_notes),
|
|
67
|
-
fonts:
|
|
85
|
+
fonts: fontProfile(raw, raw.fonts?.default_profile),
|
|
86
|
+
fontProfiles: Object.fromEntries(
|
|
87
|
+
Object.keys(raw.fonts?.profiles ?? {}).map((p) => [p, fontProfile(raw, p)]),
|
|
88
|
+
),
|
|
89
|
+
defaultFontProfile: raw.fonts?.default_profile,
|
|
68
90
|
identity: clean(raw.identity),
|
|
69
91
|
marks: clean(raw.marks),
|
|
70
92
|
limits: clean(raw.limits),
|
package/package.json
CHANGED
package/skill-invariants.json
CHANGED
|
@@ -32,14 +32,24 @@
|
|
|
32
32
|
"rationale": "ghostwriter's learned voice and devlog's release-note shape are personal, per-medium, and higher-signal than the universal floor. Without this precedence rule, folding voice into press would degrade both."
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
"id": "npx-must-
|
|
36
|
-
"pattern": "
|
|
37
|
-
"rationale": "A bare npx invocation silently prefers a stale global install over the registry
|
|
35
|
+
"id": "npx-must-carry-a-version",
|
|
36
|
+
"pattern": "Never write a bare\\s+`npx @natjswenson/press`",
|
|
37
|
+
"rationale": "A bare npx invocation with no version reference silently prefers a stale global install over the registry; this exact failure cost this repo a release with shipflow. The rule was originally 'always pin @latest'; from 0.4.0 consumers pin an EXACT version instead, because a mutable reference in a repo that auto-deploys to prod is a supply-chain hole and propagate now handles the bumping. Either form avoids the stale-global trap — a bare invocation is the thing that must never appear."
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
"id": "look-at-the-artifact",
|
|
41
41
|
"pattern": "Re-render one real artifact per affected medium and \\*\\*look at it\\*\\*",
|
|
42
42
|
"rationale": "A token change is the one edit that touches every product at once, and no test asserts taste. The visual confirmation step is the only thing standing between a one-character edit and shipping it everywhere."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "integrity-and-freshness-are-different",
|
|
46
|
+
"pattern": "pinned check can never answer",
|
|
47
|
+
"rationale": "The propagation design rests on these being separate questions: a pinned check proves the region is intact, never that it is current. natejswenson.io sat two releases behind with green CI. If this line goes, the next person 'simplifies' by dropping propagate or by unpinning consumers, and either choice reopens a real failure."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "fontconfig-consumers-need-their-own-profile",
|
|
51
|
+
"pattern": "must say `fontconfig`",
|
|
52
|
+
"rationale": "WeasyPrint walks the fallback chain for real, so the browser-tuned stack resolved local-fitness headlines to Helvetica Neue Heavy Condensed. A new consumer onboarded without checking its engine ships that regression silently — the emitted CSS looks correct and only the rendered PDF is wrong."
|
|
43
53
|
}
|
|
44
54
|
],
|
|
45
55
|
"baseline": [
|
|
@@ -47,7 +57,9 @@
|
|
|
47
57
|
"id": "pre-migration-value-snapshot",
|
|
48
58
|
"kind": "golden",
|
|
49
59
|
"test": "tests/baseline.test.mjs",
|
|
50
|
-
"fixtures": [
|
|
60
|
+
"fixtures": [
|
|
61
|
+
"tests/fixtures/pre-migration-values.json"
|
|
62
|
+
],
|
|
51
63
|
"update_command": "node tests/fixtures/update-pre-migration.mjs",
|
|
52
64
|
"rationale": "A frozen snapshot of every brand value as it actually existed in eight files across four repos (claude-skills, budget, local-fitness, natejswenson.io) before press generated any of them. This is the migration's no-op proof: whatever press emits must still carry those values, so a future token edit that silently changes a colour a shipped product depends on goes red instead of quietly re-rendering every artifact wrong. Pinned to a real past state, never synthesised. The paired negative assertion (deleting the accent from the known set must be detected) keeps the check from passing while the comparison rots, and a min_sources floor of 7 stops the fixture from shrinking to nothing and still reporting clean."
|
|
53
65
|
},
|
package/targets.json
CHANGED
|
@@ -244,7 +244,8 @@
|
|
|
244
244
|
"init": {
|
|
245
245
|
"replaceFrom": "^import copy$",
|
|
246
246
|
"replaceTo": "^ return theme$"
|
|
247
|
-
}
|
|
247
|
+
},
|
|
248
|
+
"github": "local-budget"
|
|
248
249
|
},
|
|
249
250
|
{
|
|
250
251
|
"id": "local-fitness",
|
|
@@ -260,7 +261,9 @@
|
|
|
260
261
|
"logging": true,
|
|
261
262
|
"extras": [
|
|
262
263
|
"mono_file"
|
|
263
|
-
]
|
|
264
|
+
],
|
|
265
|
+
"font_profile": "fontconfig",
|
|
266
|
+
"byline": "linkedin.com/in/natejswenson"
|
|
264
267
|
},
|
|
265
268
|
"init": {
|
|
266
269
|
"replaceFrom": "^import copy$",
|