@natjswenson/press 0.1.0 → 0.3.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 CHANGED
@@ -5,6 +5,70 @@ 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.3.0] - 2026-08-01
9
+
10
+ Font stacks become per-engine profiles. Migrating `local-fitness` measured a real
11
+ regression the previous single-stack model would have shipped.
12
+
13
+ ### Added
14
+
15
+ - **`fonts.profiles`** — the brand's type *intent* is one thing; the fallback
16
+ chain that achieves it depends on the rendering engine. Targets pick one with
17
+ `params.font_profile`; omitting it uses `fonts.default_profile` (`browser`), so
18
+ every existing consumer is byte-for-byte unchanged.
19
+ - **`browser`** — Chromium/WebKit. `-apple-system` resolves to SF and the
20
+ fallbacks are never reached, so depth is free.
21
+ - **`fontconfig`** — WeasyPrint and anything else walking the chain for real.
22
+ Deliberately shallower: every extra face is one that can *win*.
23
+ - An unknown `font_profile` is an error, never a silent fall back to the default.
24
+
25
+ ### Fixed
26
+
27
+ - **The union stack visibly broke WeasyPrint output.** `local-fitness` renders
28
+ through WeasyPrint, which resolves none of the `ui-*` / `-apple-system`
29
+ keywords and walks the chain for real. The browser-tuned list put `Inter` and
30
+ `'Helvetica Neue'` ahead of `Arial`, and headlines resolved to
31
+ **Helvetica Neue Heavy Condensed** — measured with `pdffonts`, not assumed.
32
+ Under the `fontconfig` profile it renders `Arial-Bold` / `Georgia-Bold` /
33
+ `Menlo-Bold`, exactly as it always has.
34
+
35
+ ### Changed
36
+
37
+ - **Migrated `budget` and `local-fitness`.** budget renders through headless
38
+ Chrome, where the widened stacks are provably inert: identical embedded faces
39
+ and byte-identical rendered pages. local-fitness takes the `fontconfig`
40
+ profile and its emitted theme changed **zero values**. Suites stay green
41
+ (894 and 2063 tests).
42
+
43
+ ## [0.2.0] - 2026-08-01
44
+
45
+ Everything `natejswenson.io` needed to adopt the brand without changing by a
46
+ single pixel. Surveying the site turned up four values the token set could not
47
+ express, so it could not have been migrated without either losing them or
48
+ hand-writing them locally — which is the failure mode press exists to end.
49
+
50
+ ### Added
51
+
52
+ - **Alpha tints as first-class derived tokens** — `border`, `border_hover` and
53
+ `accent_dim`, computed from `ink` and `accent` so no consumer writes an
54
+ `rgba()` by hand.
55
+
56
+ ### Changed
57
+
58
+ - **`mono_stack` gained `'JetBrains Mono'`.** The site's stack was one fallback
59
+ deeper than the token set's; emitting the shorter one would have silently
60
+ removed a face. Fallbacks are additive, so the union is canonical — the same
61
+ call made for the résumé's stacks in 0.1.0. Purely additive for every existing
62
+ consumer.
63
+
64
+ ### Known inconsistency, kept deliberately
65
+
66
+ `hair` (0.18) and `border` (0.16) are the same idea at two different strengths —
67
+ the résumé's hairline and the site's. They are recorded as separate tokens rather
68
+ than reconciled, because reconciling them would change one of two shipped
69
+ products. That is a decision to make on purpose, not a side effect of adopting
70
+ press. Documented in `tokens.json` under `derived.$comment`.
71
+
8
72
  ## [0.1.0] - 2026-07-31
9
73
 
10
74
  First release. Replaces eight hand-ported copies of the PRESS brand with one
@@ -63,4 +127,6 @@ source of truth and a CI drift gate.
63
127
  rasterised cards, whose eyebrow legitimately runs at `.16em`. Scoped rather
64
128
  than waived, and caught by linting the real shipped corpus.
65
129
 
130
+ [0.3.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.3.0
131
+ [0.2.0]: https://github.com/natejswenson/claude-skills/releases/tag/press-v0.2.0
66
132
  [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.1.0
5
+ version: 0.3.0
6
6
  ---
7
7
 
8
8
  # /press — the brand system
package/brand/tokens.json CHANGED
@@ -33,14 +33,30 @@
33
33
  "ink_faint": "Decorative only — a faint element inside an illustration. Never body text, never a headline."
34
34
  },
35
35
  "derived": {
36
+ "$comment": "Alpha tints, all computed from ink or accent so no consumer writes an rgba() by hand. hair (0.18) and border (0.16) are the SAME IDEA at two different strengths — the résumé's hairline and the site's. That is a real inconsistency, kept deliberately: reconciling them would change one of two shipped products, which is a decision to make on purpose and not as a side effect of adopting press.",
36
37
  "hair_alpha": 0.18,
38
+ "border_alpha": 0.16,
39
+ "border_hover_alpha": 0.38,
40
+ "accent_dim_alpha": 0.12,
37
41
  "fill_steps": ["ink", "ink_mid", "dim"]
38
42
  },
39
43
  "fonts": {
40
- "$comment": "The union of every consumer's stack. Fallbacks are additive, so the richest chain is the canonical one the résumé's print-tuned stacks were the deepest and became these. Single quotes so the same string is valid CSS and needs no escaping in a Python literal.",
41
- "display_stack": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, 'Helvetica Neue', Arial, sans-serif",
42
- "serif_stack": "'New York', ui-serif, Georgia, 'Iowan Old Style', 'Times New Roman', serif",
43
- "mono_stack": "ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, Consolas, monospace"
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
+ }
44
60
  },
45
61
  "identity": {
46
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(tokens.fonts.display_stack)},`);
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(tokens.fonts.serif_stack)},`);
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(tokens.fonts.mono_stack)},`);
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.fonts,
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,9 +292,32 @@ function required(params, key, emitter) {
291
292
  return value;
292
293
  }
293
294
 
294
- function lookup(tokens, name) {
295
- if (name === 'hair') return tokens.derived.hair;
296
- for (const group of [tokens.colors, tokens.terminal, tokens.fonts, tokens.identity, tokens.marks]) {
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
+
311
+ const DERIVED = {
312
+ hair: 'hair',
313
+ border: 'border',
314
+ border_hover: 'borderHover',
315
+ accent_dim: 'accentDim',
316
+ };
317
+
318
+ function lookup(tokens, name, params = {}) {
319
+ if (name in DERIVED) return tokens.derived[DERIVED[name]];
320
+ for (const group of [tokens.colors, tokens.terminal, fontsFor(tokens, params), tokens.identity, tokens.marks]) {
297
321
  if (name in group) return group[name];
298
322
  }
299
323
  throw new EmitError(`unknown token "${name}"`);
package/lib/tokens.mjs CHANGED
@@ -37,13 +37,38 @@ 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);
43
61
 
44
- const hairAlpha = raw.derived?.hair_alpha ?? 0.18;
45
62
  const [r, g, b] = hexToRgb(colors.ink);
46
- const hair = `rgba(${r}, ${g}, ${b}, ${hairAlpha})`;
63
+ const [ar, ag, ab] = hexToRgb(colors.accent);
64
+ const inkAlpha = (a) => `rgba(${r}, ${g}, ${b}, ${a})`;
65
+
66
+ const hairAlpha = raw.derived?.hair_alpha ?? 0.18;
67
+ const hair = inkAlpha(hairAlpha);
68
+ // Named separately from `hair` on purpose — see derived.$comment in tokens.json.
69
+ const border = inkAlpha(raw.derived?.border_alpha ?? hairAlpha);
70
+ const borderHover = inkAlpha(raw.derived?.border_hover_alpha ?? 0.38);
71
+ const accentDim = `rgba(${ar}, ${ag}, ${ab}, ${raw.derived?.accent_dim_alpha ?? 0.12})`;
47
72
 
48
73
  const fillSteps = (raw.derived?.fill_steps ?? []).map((name) => {
49
74
  const value = colors[name];
@@ -57,11 +82,15 @@ function resolve(raw) {
57
82
  colors,
58
83
  terminal,
59
84
  notes: clean(raw.color_notes),
60
- fonts: clean(raw.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,
61
90
  identity: clean(raw.identity),
62
91
  marks: clean(raw.marks),
63
92
  limits: clean(raw.limits),
64
- derived: { hair, hairAlpha, fillSteps },
93
+ derived: { hair, hairAlpha, border, borderHover, accentDim, fillSteps },
65
94
  /** Every literal color the brand permits, for the off-palette lint. */
66
95
  palette: [...Object.values(colors), ...Object.values(terminal)],
67
96
  };
@@ -77,6 +106,9 @@ export function hexToRgb(hex) {
77
106
  /** Look up a token by its flat name, including the derived ones. */
78
107
  export function tokenValue(tokens, name) {
79
108
  if (name === 'hair') return tokens.derived.hair;
109
+ if (name === 'border') return tokens.derived.border;
110
+ if (name === 'border_hover') return tokens.derived.borderHover;
111
+ if (name === 'accent_dim') return tokens.derived.accentDim;
80
112
  if (name in tokens.colors) return tokens.colors[name];
81
113
  if (name in tokens.terminal) return tokens.terminal[name];
82
114
  if (name in tokens.fonts) return tokens.fonts[name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@natjswenson/press",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "One brand system — tokens, laws, run presentation and voice core — generated into every consumer with a CI drift gate",
5
5
  "license": "MIT",
6
6
  "author": "Nate Swenson",
package/targets.json CHANGED
@@ -260,7 +260,9 @@
260
260
  "logging": true,
261
261
  "extras": [
262
262
  "mono_file"
263
- ]
263
+ ],
264
+ "font_profile": "fontconfig",
265
+ "byline": "linkedin.com/in/natejswenson"
264
266
  },
265
267
  "init": {
266
268
  "replaceFrom": "^import copy$",
@@ -303,12 +305,33 @@
303
305
  "token": "ink_faint",
304
306
  "name": "fg-tertiary"
305
307
  },
308
+ {
309
+ "token": "border"
310
+ },
311
+ {
312
+ "token": "border_hover"
313
+ },
306
314
  {
307
315
  "token": "accent"
308
316
  },
317
+ {
318
+ "token": "accent_dim"
319
+ },
309
320
  {
310
321
  "token": "rule"
311
322
  },
323
+ {
324
+ "token": "display_stack",
325
+ "name": "font-display"
326
+ },
327
+ {
328
+ "token": "serif_stack",
329
+ "name": "font-serif"
330
+ },
331
+ {
332
+ "token": "mono_stack",
333
+ "name": "font-mono"
334
+ },
312
335
  {
313
336
  "token": "term_bg",
314
337
  "name": "panel"
@@ -330,6 +353,10 @@
330
353
  "name": "panel-bar"
331
354
  }
332
355
  ]
356
+ },
357
+ "init": {
358
+ "replaceFrom": "^:root \\{$",
359
+ "replaceTo": "^\\}$"
333
360
  }
334
361
  }
335
362
  ]