@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/brand/laws.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# The PRESS laws
|
|
2
|
+
|
|
3
|
+
PRESS is a **brand system, not a template**. Nothing here tells you what to
|
|
4
|
+
build; it tells you what every thing you build must obey so that a morning
|
|
5
|
+
brief, a city profile, a budget report, a résumé, a LinkedIn card and a dev-log
|
|
6
|
+
cover read as one publication.
|
|
7
|
+
|
|
8
|
+
The values live in `tokens.json` and are generated into consumers. These are the
|
|
9
|
+
rules the numbers can't carry.
|
|
10
|
+
|
|
11
|
+
## 1. The accent law
|
|
12
|
+
|
|
13
|
+
There is **one** loud color. It is spent **once or twice in a document** — on
|
|
14
|
+
the single most notable figure, and on the stamp. Never as decoration, never on
|
|
15
|
+
a whole row of things, never to mean "good" or "bad".
|
|
16
|
+
|
|
17
|
+
Adding more orange does not make it louder. It makes it quieter.
|
|
18
|
+
|
|
19
|
+
Corollaries that have each already cost a real bug:
|
|
20
|
+
|
|
21
|
+
- **No traffic lights.** Over-budget, failed, missed, at-risk — none of these get
|
|
22
|
+
a hue. They get a text mark and a label, which survive greyscale printing and
|
|
23
|
+
every form of color vision. `budget` carries over-budget on a `⚠︎` and a tick,
|
|
24
|
+
not on red.
|
|
25
|
+
- **Emoji glyphs are a second loud color.** A bare `⚠` renders as a *colored*
|
|
26
|
+
emoji in Chromium. Always use the text-presentation form from
|
|
27
|
+
`tokens.marks.warn` (`⚠︎`, U+26A0 U+FE0E). The HTML looks fine and the PDF is
|
|
28
|
+
wrong, so nothing catches this but the lint.
|
|
29
|
+
- **The one exception** is a two-subject comparison, where the subjects *are* the
|
|
30
|
+
document (city A vs city B). There the accent identifies one subject
|
|
31
|
+
throughout, because the alternative is asking the reader to match a hue from
|
|
32
|
+
memory. A legend is mandatory when this exception is taken.
|
|
33
|
+
|
|
34
|
+
## 2. Structure is rules and whitespace
|
|
35
|
+
|
|
36
|
+
**No rounded corners. No shadows. No gradients. No fills. No boxes inside
|
|
37
|
+
boxes. No zebra striping.**
|
|
38
|
+
|
|
39
|
+
That is not minimalism for its own sake, and it is not an oversight to be
|
|
40
|
+
"fixed" later — it is the brand. Structure is carried by:
|
|
41
|
+
|
|
42
|
+
- **ink rules** — 8px above a masthead, 2px between sections, 1px under a row;
|
|
43
|
+
- **whitespace** — grouping is done by proximity, not by drawing a container;
|
|
44
|
+
- **typographic weight** — 900 display sans against 400 serif italic.
|
|
45
|
+
|
|
46
|
+
The single allowed radius is a circular avatar in the colophon, because a
|
|
47
|
+
cropped photograph is not a container.
|
|
48
|
+
|
|
49
|
+
## 3. Three voices, never mixed inside an element
|
|
50
|
+
|
|
51
|
+
| Voice | Face | Carries |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| **Structure** | display sans, 800–900, tight tracking (`-0.02` to `-0.03em`) | names, headlines, section titles, numerals |
|
|
54
|
+
| **Commentary** | serif italic, `dim` | standfirsts, captions, marginal notes, asides |
|
|
55
|
+
| **Data** | mono, tracked caps at small sizes | labels, dates, URLs, tables, terminal output, provenance |
|
|
56
|
+
|
|
57
|
+
Tones and verdicts are typographic — ink vs dim, roman vs italic — because that
|
|
58
|
+
is what is left once the accent law has taken color away.
|
|
59
|
+
|
|
60
|
+
## 4. Tracking has a hard ceiling
|
|
61
|
+
|
|
62
|
+
`letter-spacing` above **0.10em** silently breaks PDF text extraction: pdf.js
|
|
63
|
+
returns the string with spaces injected between characters, and poppler shows
|
|
64
|
+
nothing wrong, so a résumé that looks perfect becomes unparseable to an ATS.
|
|
65
|
+
`tokens.limits.max_letter_spacing_em` is the ceiling and `press lint` enforces
|
|
66
|
+
it. Tracked caps get their emphasis from size and weight, not from more space.
|
|
67
|
+
|
|
68
|
+
**The ceiling protects extraction, not taste**, so it binds only where a machine
|
|
69
|
+
reads the text back — PDFs and HTML pages. A rasterised card is pixels by the
|
|
70
|
+
time anyone sees it, and the card set runs its eyebrow at `.16em` deliberately.
|
|
71
|
+
Lint those with `--raster` (or `textExtractable: false`).
|
|
72
|
+
|
|
73
|
+
## 5. Every fill must be legible or labelled
|
|
74
|
+
|
|
75
|
+
The ink ramp (`derived.fill_steps`) is **sequential** — monotonic in lightness —
|
|
76
|
+
and encodes magnitude only, never identity. It is capped at three steps because
|
|
77
|
+
the lighter extensions drop below 3:1 against cream, and a fill nobody can see
|
|
78
|
+
is not worth the exception.
|
|
79
|
+
|
|
80
|
+
It is deliberately **not** a categorical palette. Run through the `dataviz`
|
|
81
|
+
skill's `validate_palette.js` it fails the chroma floor and the adjacent-pair
|
|
82
|
+
floor, which is the correct result for a near-neutral ramp and exactly why
|
|
83
|
+
identity here comes from direct labels rather than hue. Anything needing more
|
|
84
|
+
categories uses ranked bars, which need no categorical encoding at all.
|
|
85
|
+
|
|
86
|
+
## 6. A missing brand file must never break a render
|
|
87
|
+
|
|
88
|
+
Every consumer's theme loader deep-merges an optional local override file over
|
|
89
|
+
the defaults and **falls back silently on any error**. A report in the wrong
|
|
90
|
+
colors beats a report that didn't generate. This is why the generated loader is
|
|
91
|
+
part of the region rather than something each consumer reimplements.
|
|
92
|
+
|
|
93
|
+
## 7. Identity marks
|
|
94
|
+
|
|
95
|
+
- **Stamp** — a rotated (`-4deg`) square, accent border, accent initials from
|
|
96
|
+
`tokens.identity.stamp`. It appears once, in the masthead.
|
|
97
|
+
- **Eyebrow** — tracked-caps mono: `{BRAND_LINE} · {DOCUMENT KIND} · {date}`.
|
|
98
|
+
- **Byline** — right-aligned, dim, mono, from `tokens.identity.byline`.
|
|
99
|
+
- **Colophon** — the closing line. What the document is for, or what happened.
|
|
100
|
+
|
|
101
|
+
## What PRESS does not decide
|
|
102
|
+
|
|
103
|
+
Layout, component choice, and composition are the medium's business. A poster
|
|
104
|
+
card, a two-column report and a résumé share these laws and share nothing else.
|
|
105
|
+
See `components.md` for the vocabulary they *do* share by name.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "THE source of truth for the PRESS brand. Every color, stack and identity default in every artifact Nate produces is generated from this file. Do not hand-copy these values into a consumer — declare the consumer in targets.json and run `press emit` instead. See laws.md for what the values MEAN and why there are so few of them.",
|
|
3
|
+
"name": "press",
|
|
4
|
+
"schema": 1,
|
|
5
|
+
"colors": {
|
|
6
|
+
"paper": "#F5F0E6",
|
|
7
|
+
"paper_surface": "#ECE5D6",
|
|
8
|
+
"paper_elevated": "#E4DCCA",
|
|
9
|
+
"ink": "#181510",
|
|
10
|
+
"dim": "#6E675C",
|
|
11
|
+
"accent": "#E8501F",
|
|
12
|
+
"rule": "#181510",
|
|
13
|
+
"ink_mid": "#4A423A",
|
|
14
|
+
"ink_faint": "#8A8272"
|
|
15
|
+
},
|
|
16
|
+
"terminal": {
|
|
17
|
+
"$comment": "The dark panel is the ONE place the old dark palette survives, and only inside a .term element. This quartet never appears on paper.",
|
|
18
|
+
"term_bg": "#141A26",
|
|
19
|
+
"term_text": "#EFE9DC",
|
|
20
|
+
"term_dim": "#8A8478",
|
|
21
|
+
"term_hot": "#FF8A5C",
|
|
22
|
+
"term_prompt": "#1E2738"
|
|
23
|
+
},
|
|
24
|
+
"color_notes": {
|
|
25
|
+
"paper": "Warm cream. Flat — never gradiented, never textured.",
|
|
26
|
+
"paper_surface": "One step down from paper, for a panel that must separate from the page without a border. Still flat.",
|
|
27
|
+
"paper_elevated": "Two steps down. The deepest paper tone; below this, use an ink rule instead.",
|
|
28
|
+
"ink": "Near-black. Text, headlines, and every structural rule.",
|
|
29
|
+
"dim": "Muted secondary text; the serif commentary voice's color.",
|
|
30
|
+
"accent": "THE one loud color. Spent once or twice per document, never as decoration.",
|
|
31
|
+
"rule": "Rules are ink, named separately so an override can soften them without touching text color.",
|
|
32
|
+
"ink_mid": "Mid ink step: a second series, a stacked-bar segment, a bar track.",
|
|
33
|
+
"ink_faint": "Decorative only — a faint element inside an illustration. Never body text, never a headline."
|
|
34
|
+
},
|
|
35
|
+
"derived": {
|
|
36
|
+
"hair_alpha": 0.18,
|
|
37
|
+
"fill_steps": ["ink", "ink_mid", "dim"]
|
|
38
|
+
},
|
|
39
|
+
"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
|
+
},
|
|
45
|
+
"identity": {
|
|
46
|
+
"stamp": "NS",
|
|
47
|
+
"byline": "linkedin.com/in/natejswenson"
|
|
48
|
+
},
|
|
49
|
+
"marks": {
|
|
50
|
+
"warn": "⚠︎"
|
|
51
|
+
},
|
|
52
|
+
"limits": {
|
|
53
|
+
"max_letter_spacing_em": 0.1,
|
|
54
|
+
"min_fill_contrast_on_paper": 3.0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
## Voice — the universal core
|
|
2
|
+
|
|
3
|
+
These rules hold for **every** artifact: a LinkedIn post, an X thread, a dev-log
|
|
4
|
+
entry, a résumé bullet, a report caption, a card headline, a commit message.
|
|
5
|
+
|
|
6
|
+
They are the floor, not the whole voice. A medium layers its own voice on top
|
|
7
|
+
(ghostwriter's learned profile, devlog's release-note shape, the résumé's
|
|
8
|
+
achievement grammar) and **the medium wins on conflict** — these rules are what
|
|
9
|
+
survives when nothing more specific applies.
|
|
10
|
+
|
|
11
|
+
### Truth
|
|
12
|
+
|
|
13
|
+
- **Never fabricate.** No invented metrics, motivations, outcomes, quotes,
|
|
14
|
+
commands, or file hashes. If a detail isn't true to what actually happened,
|
|
15
|
+
cut it or ask for it.
|
|
16
|
+
- **Real numbers only.** A number in a draft must be traceable to something you
|
|
17
|
+
actually read. A missing number is `—` or a question, never a plausible
|
|
18
|
+
placeholder.
|
|
19
|
+
- **No drama for effect.** Authenticity beats a punchy-but-false image.
|
|
20
|
+
|
|
21
|
+
### Construction
|
|
22
|
+
|
|
23
|
+
- **No em dashes.** Use a comma, a semicolon, or two sentences.
|
|
24
|
+
- **No hedge words.** "Arguably", "essentially", "quite possibly", "it could be
|
|
25
|
+
said" — cut them. State the thing or don't.
|
|
26
|
+
- **No tacked-on punchy filler.** No closing one-liner that adds nothing, no
|
|
27
|
+
rhetorical fragment lists for rhythm, no clever-symmetry constructions
|
|
28
|
+
("not X, but Y" as a tic).
|
|
29
|
+
- **No throat-clearing.** Lead with the thesis; cut the runway before it.
|
|
30
|
+
|
|
31
|
+
### Framing
|
|
32
|
+
|
|
33
|
+
- **Write for the reader, not as a diary.** Prefer "here's what to do" over
|
|
34
|
+
"here's what I did". Lead with the change and its impact, not with "I".
|
|
35
|
+
- **Argue the idea, never the person.** Tools, teams and vendors are criticised
|
|
36
|
+
by what they do, not by name-calling.
|
|
37
|
+
- **Tighter is better.** If a sentence survives being cut, cut it.
|
|
38
|
+
|
|
39
|
+
### Endings
|
|
40
|
+
|
|
41
|
+
The ending is where AI writing gives itself away. An artifact ends on the last
|
|
42
|
+
real thing it has to say. It does **not** end on a summary of itself, a
|
|
43
|
+
rhetorical question to the reader, a call to "let me know what you think", or a
|
|
44
|
+
restatement of the opening.
|
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drift gate.
|
|
3
|
+
*
|
|
4
|
+
* `check` re-derives what each region should contain and compares it to what is
|
|
5
|
+
* on disk. Three things must fail, not just the obvious one:
|
|
6
|
+
*
|
|
7
|
+
* drift — the region exists but its bytes differ (someone hand-edited it,
|
|
8
|
+
* or the tokens moved and nobody re-emitted);
|
|
9
|
+
* missing — the file exists but has no region at all. A consumer that
|
|
10
|
+
* silently dropped its marker would otherwise report "all clean"
|
|
11
|
+
* while checking nothing;
|
|
12
|
+
* absent — a declared file isn't there.
|
|
13
|
+
*
|
|
14
|
+
* And a run that resolved *zero* targets is itself a failure. A glob that
|
|
15
|
+
* quietly matches nothing must go red — that is how a gate turns decorative.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { emitBody } from './emit.mjs';
|
|
19
|
+
import { findRegion, RegionError } from './region.mjs';
|
|
20
|
+
import { selectTargets, targetPath } from './targets.mjs';
|
|
21
|
+
|
|
22
|
+
export function checkTarget(target, root, tokens, version) {
|
|
23
|
+
const path = targetPath(target, root);
|
|
24
|
+
let text;
|
|
25
|
+
try {
|
|
26
|
+
text = readFileSync(path, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return { target, path, status: 'absent' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let found;
|
|
32
|
+
try {
|
|
33
|
+
found = findRegion(text, target.region, target.syntax);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof RegionError) return { target, path, status: 'corrupt', detail: err.message };
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
if (!found) return { target, path, status: 'missing' };
|
|
39
|
+
|
|
40
|
+
const expected = emitBody(tokens, target.emitter, target.params ?? {}).replace(/\s+$/, '');
|
|
41
|
+
const actual = found.body.replace(/\s+$/, '');
|
|
42
|
+
if (expected === actual) {
|
|
43
|
+
return { target, path, status: 'ok', writtenBy: found.version };
|
|
44
|
+
}
|
|
45
|
+
return { target, path, status: 'drift', diff: lineDiff(expected, actual), writtenBy: found.version };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function checkAll({ tokens, targets, root, ids, version }) {
|
|
49
|
+
const selected = selectTargets(targets, { root, ids });
|
|
50
|
+
const results = selected.map((t) => checkTarget(t, root, tokens, version));
|
|
51
|
+
const failures = results.filter((r) => r.status !== 'ok');
|
|
52
|
+
return {
|
|
53
|
+
results,
|
|
54
|
+
failures,
|
|
55
|
+
empty: selected.length === 0,
|
|
56
|
+
ok: selected.length > 0 && failures.length === 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A compact diff: common prefix and suffix are elided, so a one-line token
|
|
62
|
+
* change reads as one line instead of the whole region.
|
|
63
|
+
*/
|
|
64
|
+
export function lineDiff(expected, actual) {
|
|
65
|
+
const a = expected.split('\n');
|
|
66
|
+
const b = actual.split('\n');
|
|
67
|
+
let head = 0;
|
|
68
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
|
|
69
|
+
let tail = 0;
|
|
70
|
+
while (
|
|
71
|
+
tail < a.length - head &&
|
|
72
|
+
tail < b.length - head &&
|
|
73
|
+
a[a.length - 1 - tail] === b[b.length - 1 - tail]
|
|
74
|
+
) {
|
|
75
|
+
tail += 1;
|
|
76
|
+
}
|
|
77
|
+
const out = [];
|
|
78
|
+
if (head > 0) out.push(` … ${head} identical line${head === 1 ? '' : 's'}`);
|
|
79
|
+
for (const line of b.slice(head, b.length - tail)) out.push(`- ${line}`);
|
|
80
|
+
for (const line of a.slice(head, a.length - tail)) out.push(`+ ${line}`);
|
|
81
|
+
if (tail > 0) out.push(` … ${tail} identical line${tail === 1 ? '' : 's'}`);
|
|
82
|
+
return out.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const EXPLAIN = {
|
|
86
|
+
ok: 'in sync',
|
|
87
|
+
drift: 'region content differs from what the tokens produce',
|
|
88
|
+
missing: 'file has no press region — the generated block was removed',
|
|
89
|
+
absent: 'declared file does not exist at this path',
|
|
90
|
+
corrupt: 'region markers are malformed',
|
|
91
|
+
};
|
package/lib/emit.mjs
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emitters — one per shape a consumer needs the brand in.
|
|
3
|
+
*
|
|
4
|
+
* An emitter returns a region *body* (no markers); region.mjs wraps it. Every
|
|
5
|
+
* emitter is a pure function of (tokens, params), so the same inputs always
|
|
6
|
+
* produce the same bytes and `check` can re-derive what should be on disk.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { BRAND_DIR } from './tokens.mjs';
|
|
11
|
+
|
|
12
|
+
export class EmitError extends Error {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Preferred order for colors in generated output — a *sort key*, never a
|
|
16
|
+
* filter. Anything not named here is appended rather than dropped, so a token
|
|
17
|
+
* added to tokens.json always reaches every consumer. Filtering here instead
|
|
18
|
+
* would mean a new brand value silently never shipped, with `check` still
|
|
19
|
+
* reporting green.
|
|
20
|
+
*/
|
|
21
|
+
const COLOR_ORDER = [
|
|
22
|
+
'paper', 'paper_surface', 'paper_elevated',
|
|
23
|
+
'ink', 'dim', 'accent', 'rule', 'ink_mid', 'ink_faint',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const orderedColors = (tokens) => {
|
|
27
|
+
const rank = (k) => (COLOR_ORDER.indexOf(k) === -1 ? COLOR_ORDER.length : COLOR_ORDER.indexOf(k));
|
|
28
|
+
return Object.keys(tokens.colors)
|
|
29
|
+
.sort((a, b) => rank(a) - rank(b))
|
|
30
|
+
.map((k) => [k, tokens.colors[k]]);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// --------------------------------------------------------------------------
|
|
34
|
+
// python-theme
|
|
35
|
+
// --------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The token dict plus the loader every Python consumer shares: a deep-merge
|
|
39
|
+
* over an optional local override file, falling back silently on any error
|
|
40
|
+
* because a report in the wrong colors beats a report that didn't generate
|
|
41
|
+
* (laws.md §6).
|
|
42
|
+
*/
|
|
43
|
+
function pythonTheme(tokens, params) {
|
|
44
|
+
const envVar = required(params, 'env_var', 'python-theme');
|
|
45
|
+
const extras = new Set(params.extras ?? []);
|
|
46
|
+
const useLogging = params.logging === true;
|
|
47
|
+
const stamp = params.stamp ?? tokens.identity.stamp;
|
|
48
|
+
const brandLine = required(params, 'brand_line', 'python-theme');
|
|
49
|
+
const byline = params.byline ?? tokens.identity.byline;
|
|
50
|
+
const docKind = params.document_kind ?? 'DOCUMENT';
|
|
51
|
+
|
|
52
|
+
const out = [];
|
|
53
|
+
out.push('import copy');
|
|
54
|
+
out.push('import json');
|
|
55
|
+
if (useLogging) out.push('import logging');
|
|
56
|
+
out.push('import os');
|
|
57
|
+
out.push('from pathlib import Path');
|
|
58
|
+
out.push('');
|
|
59
|
+
if (useLogging) {
|
|
60
|
+
out.push('_LOG = logging.getLogger(__name__)');
|
|
61
|
+
out.push('');
|
|
62
|
+
}
|
|
63
|
+
out.push(`_BRAND_FILE_ENV = ${py(envVar)}`);
|
|
64
|
+
out.push('');
|
|
65
|
+
|
|
66
|
+
if (extras.has('warn')) {
|
|
67
|
+
out.push('#: Forces U+26A0 to text presentation. A bare warning sign renders as a');
|
|
68
|
+
out.push('#: *colored emoji* glyph in Chromium, which would put a second loud color on');
|
|
69
|
+
out.push('#: the page and break the accent law silently — it looks right in the HTML');
|
|
70
|
+
out.push('#: and wrong in the PDF.');
|
|
71
|
+
out.push(`WARN = ${py(tokens.marks.warn)}`);
|
|
72
|
+
out.push('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
out.push('DEFAULT_THEME: dict = {');
|
|
76
|
+
out.push(` "name": ${py(tokens.name)},`);
|
|
77
|
+
out.push(' "colors": {');
|
|
78
|
+
for (const [key, value] of orderedColors(tokens)) {
|
|
79
|
+
for (const line of wrapComment(tokens.notes[key], 8)) out.push(line);
|
|
80
|
+
out.push(` ${py(key)}: ${py(value)},`);
|
|
81
|
+
}
|
|
82
|
+
if (extras.has('terminal')) {
|
|
83
|
+
out.push(' # The dark panel palette. Only ever inside a terminal element.');
|
|
84
|
+
for (const [key, value] of Object.entries(tokens.terminal)) {
|
|
85
|
+
out.push(` ${py(key)}: ${py(value)},`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
out.push(' },');
|
|
89
|
+
out.push(' "fonts": {');
|
|
90
|
+
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(' # Commentary voice: serif italics for standfirsts and captions.');
|
|
93
|
+
out.push(` "serif_stack": ${py(tokens.fonts.serif_stack)},`);
|
|
94
|
+
out.push(' # Data voice: labels, dates, tables, provenance.');
|
|
95
|
+
out.push(` "mono_stack": ${py(tokens.fonts.mono_stack)},`);
|
|
96
|
+
if (extras.has('mono_file')) {
|
|
97
|
+
out.push(' # Point at a real TTF to load an authentic mono face via @font-face.');
|
|
98
|
+
out.push(' "mono_file": None,');
|
|
99
|
+
}
|
|
100
|
+
out.push(' },');
|
|
101
|
+
out.push(' "identity": {');
|
|
102
|
+
out.push(' # Typographic stamp (rotated square, accent border + initials).');
|
|
103
|
+
out.push(` "stamp": ${py(stamp)},`);
|
|
104
|
+
out.push(` # Masthead eyebrow, tracked caps: "{brand_line} · ${docKind} · date".`);
|
|
105
|
+
out.push(` "brand_line": ${py(brandLine)},`);
|
|
106
|
+
out.push(' # Right-aligned dim byline in the masthead.');
|
|
107
|
+
out.push(` "byline": ${py(byline)},`);
|
|
108
|
+
out.push(' },');
|
|
109
|
+
out.push('}');
|
|
110
|
+
|
|
111
|
+
if (extras.has('fill_steps')) {
|
|
112
|
+
out.push('');
|
|
113
|
+
out.push('#: Sequential fill steps, dark to light. Capped at three because the lighter');
|
|
114
|
+
out.push('#: extensions of this ramp drop under 3:1 against paper. Encodes magnitude');
|
|
115
|
+
out.push('#: only, never identity — see the brand laws on fills.');
|
|
116
|
+
out.push(`FILL_STEPS = (${tokens.derived.fillSteps.map(py).join(', ')})`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
out.push('');
|
|
120
|
+
out.push('');
|
|
121
|
+
out.push('def _deep_merge(base: dict, override: dict) -> dict:');
|
|
122
|
+
out.push(' """Recursively merge ``override`` into a copy of ``base``.');
|
|
123
|
+
out.push('');
|
|
124
|
+
out.push(' Non-dict values replace; unknown keys are kept, so a brand file written');
|
|
125
|
+
out.push(' against a newer default still loads against an older one.');
|
|
126
|
+
out.push(' """');
|
|
127
|
+
out.push(' out = copy.deepcopy(base)');
|
|
128
|
+
out.push(' for key, value in override.items():');
|
|
129
|
+
out.push(' if isinstance(value, dict) and isinstance(out.get(key), dict):');
|
|
130
|
+
out.push(' out[key] = _deep_merge(out[key], value)');
|
|
131
|
+
out.push(' else:');
|
|
132
|
+
out.push(' out[key] = copy.deepcopy(value)');
|
|
133
|
+
out.push(' return out');
|
|
134
|
+
out.push('');
|
|
135
|
+
out.push('');
|
|
136
|
+
out.push('def load_theme() -> dict:');
|
|
137
|
+
out.push(` """The active theme: \`\`DEFAULT_THEME\`\` merged with \`\`${envVar}\`\`.`);
|
|
138
|
+
out.push('');
|
|
139
|
+
out.push(' Read per render rather than cached, so editing the brand file takes effect');
|
|
140
|
+
out.push(' on the next render without restarting anything. A missing or broken brand');
|
|
141
|
+
out.push(' file must never break a render, so any load error falls back to the');
|
|
142
|
+
out.push(' default.');
|
|
143
|
+
out.push(' """');
|
|
144
|
+
out.push(' theme = copy.deepcopy(DEFAULT_THEME)');
|
|
145
|
+
out.push(' brand_file = os.environ.get(_BRAND_FILE_ENV)');
|
|
146
|
+
out.push(' if brand_file:');
|
|
147
|
+
out.push(' try:');
|
|
148
|
+
out.push(' override = json.loads(Path(brand_file).expanduser().read_text(encoding="utf-8"))');
|
|
149
|
+
out.push(' if isinstance(override, dict):');
|
|
150
|
+
out.push(' theme = _deep_merge(theme, override)');
|
|
151
|
+
if (useLogging) {
|
|
152
|
+
out.push(' else:');
|
|
153
|
+
out.push(' _LOG.warning(');
|
|
154
|
+
out.push(' "brand file %s is not a JSON object — using default theme", brand_file)');
|
|
155
|
+
out.push(' except (OSError, ValueError):');
|
|
156
|
+
out.push(' _LOG.warning(');
|
|
157
|
+
out.push(' "could not load brand file %s — using default theme",');
|
|
158
|
+
out.push(' brand_file, exc_info=True)');
|
|
159
|
+
} else {
|
|
160
|
+
out.push(' except (OSError, ValueError):');
|
|
161
|
+
out.push(' pass');
|
|
162
|
+
}
|
|
163
|
+
if (extras.has('mono_file')) {
|
|
164
|
+
out.push(' mono_file = theme.get("fonts", {}).get("mono_file")');
|
|
165
|
+
out.push(' if mono_file:');
|
|
166
|
+
out.push(' theme["fonts"]["mono_file"] = str(Path(mono_file).expanduser())');
|
|
167
|
+
}
|
|
168
|
+
out.push(' return theme');
|
|
169
|
+
|
|
170
|
+
return out.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --------------------------------------------------------------------------
|
|
174
|
+
// css-vars
|
|
175
|
+
// --------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A custom-property block. `vars` names which tokens the medium wants and,
|
|
179
|
+
* crucially, what to *call* them: the résumé's `--sig` and the site's `--fg`
|
|
180
|
+
* stay medium-idiomatic while provably carrying one source's values. Forcing a
|
|
181
|
+
* global rename would be churn for no gain.
|
|
182
|
+
*/
|
|
183
|
+
function cssVars(tokens, params) {
|
|
184
|
+
const selector = params.selector ?? ':root';
|
|
185
|
+
const vars = params.vars ?? [];
|
|
186
|
+
if (vars.length === 0) throw new EmitError('css-vars needs a non-empty "vars" list');
|
|
187
|
+
|
|
188
|
+
const decls = vars.map((entry) => {
|
|
189
|
+
const spec = typeof entry === 'string' ? { token: entry } : entry;
|
|
190
|
+
// Custom properties are kebab-case; token keys are snake_case.
|
|
191
|
+
const name = (spec.name ?? spec.token).replace(/_/g, '-');
|
|
192
|
+
const raw = spec.token === 'stamp'
|
|
193
|
+
? (params.stamp ?? tokens.identity.stamp)
|
|
194
|
+
: lookup(tokens, spec.token);
|
|
195
|
+
const value = spec.quote ? JSON.stringify(raw) : raw;
|
|
196
|
+
// `comments: "explicit"` keeps only hand-written hints; the token notes are
|
|
197
|
+
// written for a reader of tokens.json and are far too long for a CSS block.
|
|
198
|
+
const fallback = params.comments === 'explicit' ? null : tokens.notes[spec.token] ?? null;
|
|
199
|
+
return { name: `--${name}`, value, comment: spec.comment ?? fallback };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const pad = params.align ? Math.max(...decls.map((d) => d.name.length)) + 2 : 0;
|
|
203
|
+
|
|
204
|
+
const body = decls.map((d) => {
|
|
205
|
+
const line = ` ${`${d.name}:`.padEnd(pad)} ${d.value};`.replace(/ +;$/, ';');
|
|
206
|
+
return d.comment && params.comments !== false
|
|
207
|
+
? `${line}${' '.repeat(Math.max(1, 48 - line.length))}/* ${d.comment} */`
|
|
208
|
+
: line;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
for (const extra of params.trailing ?? []) body.push(` ${extra}`);
|
|
212
|
+
|
|
213
|
+
return [`${selector} {`, ...body, '}'].join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --------------------------------------------------------------------------
|
|
217
|
+
// md-palette / markdown-block / json
|
|
218
|
+
// --------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/** The palette as a prose bullet list, for a style guide an agent reads. */
|
|
221
|
+
function mdPalette(tokens, params) {
|
|
222
|
+
const skip = new Set(params.omit ?? []);
|
|
223
|
+
const lines = orderedColors(tokens)
|
|
224
|
+
.filter(([key]) => !skip.has(key))
|
|
225
|
+
.map(([key, value]) => `- **${titleize(key)}** \`${value}\` — ${tokens.notes[key]}`);
|
|
226
|
+
if (params.terminal !== false) {
|
|
227
|
+
const keys = Object.keys(tokens.terminal);
|
|
228
|
+
const names = keys.map((k) => k.replace(/^term_/, '')).join(', ');
|
|
229
|
+
const values = keys.map((k) => `\`${tokens.terminal[k]}\``).join(', ');
|
|
230
|
+
lines.push(
|
|
231
|
+
`- **Terminal panel** (${names}) ${values} — the one place the dark palette`,
|
|
232
|
+
' survives, and only inside a terminal element. Never on paper.',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Inline one of the brand contract documents verbatim. */
|
|
239
|
+
function markdownBlock(tokens, params) {
|
|
240
|
+
const doc = required(params, 'doc', 'markdown-block');
|
|
241
|
+
if (!/^[a-z-]+$/.test(doc)) throw new EmitError(`illegal doc name "${doc}"`);
|
|
242
|
+
const text = readFileSync(join(BRAND_DIR, `${doc}.md`), 'utf8').replace(/\s+$/, '');
|
|
243
|
+
const shift = params.heading_shift ?? 0;
|
|
244
|
+
if (!shift) return text;
|
|
245
|
+
return text
|
|
246
|
+
.split('\n')
|
|
247
|
+
.map((l) => (/^#{1,5} /.test(l) ? '#'.repeat(shift) + l : l))
|
|
248
|
+
.join('\n');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Raw values, for anything that just wants the numbers. */
|
|
252
|
+
function jsonTokens(tokens, params) {
|
|
253
|
+
const payload = {
|
|
254
|
+
colors: tokens.colors,
|
|
255
|
+
terminal: tokens.terminal,
|
|
256
|
+
fonts: tokens.fonts,
|
|
257
|
+
identity: { ...tokens.identity, ...(params.stamp ? { stamp: params.stamp } : {}) },
|
|
258
|
+
derived: { hair: tokens.derived.hair, fill_steps: tokens.derived.fillSteps },
|
|
259
|
+
};
|
|
260
|
+
for (const key of params.omit ?? []) delete payload[key];
|
|
261
|
+
return JSON.stringify(payload, null, 2);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export const EMITTERS = {
|
|
267
|
+
'python-theme': pythonTheme,
|
|
268
|
+
'css-vars': cssVars,
|
|
269
|
+
'md-palette': mdPalette,
|
|
270
|
+
'markdown-block': markdownBlock,
|
|
271
|
+
json: jsonTokens,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export function emitBody(tokens, emitter, params = {}) {
|
|
275
|
+
const fn = EMITTERS[emitter];
|
|
276
|
+
if (!fn) {
|
|
277
|
+
throw new EmitError(
|
|
278
|
+
`unknown emitter "${emitter}" (expected one of: ${Object.keys(EMITTERS).join(', ')})`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return fn(tokens, params);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
function required(params, key, emitter) {
|
|
287
|
+
const value = params[key];
|
|
288
|
+
if (value === undefined || value === null || value === '') {
|
|
289
|
+
throw new EmitError(`${emitter} requires params.${key}`);
|
|
290
|
+
}
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
|
|
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]) {
|
|
297
|
+
if (name in group) return group[name];
|
|
298
|
+
}
|
|
299
|
+
throw new EmitError(`unknown token "${name}"`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Python string literal. Double quotes, matching every existing consumer. */
|
|
303
|
+
function py(value) {
|
|
304
|
+
if (value === null) return 'None';
|
|
305
|
+
return JSON.stringify(value);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function wrapComment(text, indent) {
|
|
309
|
+
if (!text) return [];
|
|
310
|
+
const pad = ' '.repeat(indent);
|
|
311
|
+
const words = text.split(' ');
|
|
312
|
+
const lines = [];
|
|
313
|
+
let current = '';
|
|
314
|
+
for (const word of words) {
|
|
315
|
+
const next = current ? `${current} ${word}` : word;
|
|
316
|
+
if (`${pad}# ${next}`.length > 79 && current) {
|
|
317
|
+
lines.push(`${pad}# ${current}`);
|
|
318
|
+
current = word;
|
|
319
|
+
} else {
|
|
320
|
+
current = next;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (current) lines.push(`${pad}# ${current}`);
|
|
324
|
+
return lines;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function titleize(key) {
|
|
328
|
+
return key
|
|
329
|
+
.split('_')
|
|
330
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
331
|
+
.join(' ');
|
|
332
|
+
}
|