@terpjs/contract 0.6.1 → 0.7.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/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@terpjs/contract",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
6
6
  "exports": {
7
7
  ".": "./src/index.ts",
8
8
  "./schema": "./src/schema.d.ts",
9
9
  "./openapi.json": "./openapi.json",
10
- "./tokens.css": "./src/tokens.css"
10
+ "./tokens.css": "./src/tokens.css",
11
+ "./tokens.manifest.json": "./src/tokens.manifest.json"
11
12
  },
12
13
  "types": "./src/index.ts",
13
14
  "bin": {
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Token build: tokens.json (light, the framework-agnostic source of truth) +
3
- * tokens.dark.json (the dark colour overrides) -> src/tokens.css (design §7.1, item 2).
2
+ * Token build: the theme registry in `themes.json` plus one source per theme -> the compiled
3
+ * stylesheet `src/tokens.css` and the published `src/tokens.manifest.json` (design §7.1, item 2).
4
4
  *
5
- * The output is one stylesheet with three blocks:
6
- * 1. `:root` — the light theme (every token).
7
- * 2. `[data-theme="dark"]` the dark colour overrides (explicit choice).
8
- * 3. `@media (prefers-color-scheme: dark)` scoped to `:root:not([data-theme="light"])`
9
- * — the OS preference applies automatically unless the app pinned a theme.
5
+ * The output is one stylesheet with a block per theme:
6
+ * 1. `:root` — the base theme, and the only block carrying geometry.
7
+ * 2. `[data-theme='<name>']` one per non-base theme, colours only, an explicit choice.
8
+ * 3. `@media (prefers-color-scheme: dark)` scoped to `:root:not([data-theme])`
9
+ * — the OS preference selects the registry's `systemDark` theme when nothing is pinned.
10
+ *
11
+ * The media selector matches only an *unpinned* root. It used to be
12
+ * `:root:not([data-theme='light'])`, which was equivalent while `light` and `dark` were the
13
+ * only themes and became a defect the moment a third existed: it matches
14
+ * `[data-theme='contrast']`, outranks it on specificity (two compound parts against one), and
15
+ * so laid the dark colours over a theme the app had explicitly pinned whenever the OS
16
+ * preferred dark.
10
17
  *
11
18
  * Apps opt in/out per user via the `data-theme` attribute on <html> (react-core's
12
19
  * `ThemeProvider` manages it); with no attribute the OS preference wins. Regenerate with
@@ -22,6 +29,32 @@ import StyleDictionary from "style-dictionary";
22
29
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23
30
  const buildDir = mkdtempSync(join(tmpdir(), "terp-tokens-"));
24
31
 
32
+ const read = (name) => JSON.parse(readFileSync(join(packageRoot, name), "utf8"));
33
+
34
+ const registry = read("themes.json");
35
+ const themes = registry.themes;
36
+ const base = themes.find((theme) => theme.name === registry.base);
37
+ const overlays = themes.filter((theme) => theme.name !== registry.base);
38
+
39
+ // The generator is the first thing to read the registry, so it is the right place to refuse a
40
+ // registry that cannot produce a coherent sheet. Each of these would otherwise emit something
41
+ // that looks fine and behaves wrongly in the browser.
42
+ if (!base) throw new Error(`themes.json: base theme "${registry.base}" is not in the list`);
43
+ if (!themes.some((theme) => theme.name === registry.systemDark)) {
44
+ throw new Error(`themes.json: systemDark "${registry.systemDark}" is not in the list`);
45
+ }
46
+ if (registry.systemDark === registry.base) {
47
+ throw new Error("themes.json: systemDark must not be the base theme");
48
+ }
49
+ for (const theme of themes) {
50
+ if (!/^[a-z][a-z0-9-]*$/.test(theme.name)) {
51
+ throw new Error(`themes.json: "${theme.name}" is not usable as a data-theme value`);
52
+ }
53
+ if (theme.appearance !== "light" && theme.appearance !== "dark") {
54
+ throw new Error(`themes.json: ${theme.name} appearance must be "light" or "dark"`);
55
+ }
56
+ }
57
+
25
58
  async function buildCss(sourceFile, outputFile) {
26
59
  const sd = new StyleDictionary({
27
60
  source: [join(packageRoot, sourceFile)],
@@ -51,36 +84,134 @@ function declarations(css) {
51
84
  .join("\n");
52
85
  }
53
86
 
54
- const light = declarations(await buildCss("tokens.json", "tokens.light.css"));
55
- const dark = declarations(await buildCss("tokens.dark.json", "tokens.dark.css"));
87
+ /** Every theme's compiled declaration lines, keyed by theme name, in registry order. */
88
+ const compiled = new Map();
89
+ for (const theme of themes) {
90
+ compiled.set(
91
+ theme.name,
92
+ declarations(await buildCss(theme.source, `tokens.${theme.name}.css`)),
93
+ );
94
+ }
56
95
  rmSync(buildDir, { recursive: true, force: true });
57
96
 
97
+ const themeBlocks = overlays
98
+ .map(
99
+ (theme) => `
100
+ /* ${theme.label}: an explicit choice via <html data-theme="${theme.name}">.
101
+ ${theme.description} */
102
+ [data-theme='${theme.name}'] {
103
+ color-scheme: ${theme.appearance};
104
+ ${compiled.get(theme.name)}
105
+ }
106
+ `,
107
+ )
108
+ .join("");
109
+
110
+ const systemDark = themes.find((theme) => theme.name === registry.systemDark);
111
+
58
112
  const output = `/**
59
113
  * Do not edit directly, this file was auto-generated.
60
114
  */
61
115
 
62
116
  :root {
63
117
  /* Opts native chrome (scrollbars, the <select> option popup, form controls,
64
- text-field carets) into the light palette so it never renders as foreign
65
- OS-light chrome. The dark blocks below flip it to dark. */
66
- color-scheme: light;
67
- ${light}
68
- }
69
-
70
- /* Dark theme: an explicit user/app choice via <html data-theme="dark">. */
71
- [data-theme='dark'] {
72
- color-scheme: dark;
73
- ${dark}
118
+ text-field carets) into the ${base.appearance} palette so it never renders as foreign
119
+ OS-${base.appearance === "light" ? "dark" : "light"} chrome. Each theme block below sets its own. */
120
+ color-scheme: ${base.appearance};
121
+ ${compiled.get(base.name)}
74
122
  }
75
-
76
- /* Dark theme: the OS preference, unless the app pinned light explicitly. */
123
+ ${themeBlocks}
124
+ /* ${systemDark.label}: the OS preference, unless the app pinned any theme explicitly. */
77
125
  @media (prefers-color-scheme: dark) {
78
- :root:not([data-theme='light']) {
79
- color-scheme: dark;
80
- ${dark.replace(/^ {2}/gm, " ")}
126
+ :root:not([data-theme]) {
127
+ color-scheme: ${systemDark.appearance};
128
+ ${compiled.get(systemDark.name).replace(/^ {2}/gm, " ")}
81
129
  }
82
130
  }
83
131
  `;
84
132
 
85
133
  writeFileSync(join(packageRoot, "src", "tokens.css"), output);
86
- console.log("wrote src/tokens.css");
134
+ console.log(`wrote src/tokens.css (${themes.length} themes)`);
135
+
136
+ /**
137
+ * The manifest: the same tokens as machine-readable data, so a consumer does not have to
138
+ * parse a stylesheet to find out what exists.
139
+ *
140
+ * Three consumers need it and none of them could get it before. A theme editor had to
141
+ * hard-code its own token list, because `tokens.json` is Style-Dictionary-shaped and is not
142
+ * exported from the package — only the compiled CSS is. An agent editing a theme by hand had
143
+ * to infer names from whatever it found in `node_modules`, with no way to tell which tokens
144
+ * are safe to theme or which must stay legible against which. And a human had no list at all.
145
+ *
146
+ * Everything here is derived, never restated: the token set comes from the theme sources, the
147
+ * category from the token's own path, `themeable` from whether any non-base theme overrides it,
148
+ * and the pairings from `token-pairs.json` — the same file the contrast gate reads. Nothing in
149
+ * this file can disagree with the stylesheet beside it, because both are generated from the
150
+ * same input in the same run.
151
+ */
152
+ function cssName(path) {
153
+ return `--${path.join("-")}`.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
154
+ }
155
+
156
+ /**
157
+ * Every leaf `{ value }` in a token tree, as `[cssName, { value, category }]`.
158
+ *
159
+ * The category is the source tree's own top-level family, not a re-split of the CSS name:
160
+ * `zIndex.base` is in the `zIndex` family even though it flattens to `--z-index-base`, and
161
+ * splitting the CSS name would have called that family `z`.
162
+ */
163
+ function flatten(node, path = []) {
164
+ if (node && typeof node === "object" && "value" in node) {
165
+ return [[cssName(path), { value: String(node.value), category: path[0] }]];
166
+ }
167
+ if (!node || typeof node !== "object") return [];
168
+ return Object.entries(node).flatMap(([key, child]) => flatten(child, [...path, key]));
169
+ }
170
+
171
+ const sources = new Map(
172
+ themes.map((theme) => [theme.name, new Map(flatten(read(theme.source)))]),
173
+ );
174
+ const baseTokens = sources.get(base.name);
175
+ const pairs = read("token-pairs.json");
176
+
177
+ const manifest = {
178
+ $comment:
179
+ "Generated by scripts/build-tokens.mjs from themes.json, the per-theme token sources and " +
180
+ "token-pairs.json. Do not edit directly; regenerate with `npm run -w @terpjs/contract " +
181
+ "tokens`. CI fails on drift.",
182
+ // The themes a `values` key can name, so a consumer can build a theme picker from this file
183
+ // alone rather than hard-coding the list it happens to know about.
184
+ base: registry.base,
185
+ systemDark: registry.systemDark,
186
+ themes: themes.map(({ name, label, appearance, description }) => ({
187
+ name,
188
+ label,
189
+ appearance,
190
+ description,
191
+ })),
192
+ tokens: [...baseTokens.entries()].map(([name, token]) => ({
193
+ name,
194
+ category: token.category,
195
+ // Only the themes that actually declare the token, base always included. A theme absent
196
+ // here inherits the base value — the cascade the sheet performs, stated as data. A token
197
+ // present under the base alone is theme-invariant by design (space, radius, font, motion,
198
+ // z-index): declared once and inherited. Saying so is what stops an editor from offering a
199
+ // per-theme control that would have no effect.
200
+ values: Object.fromEntries(
201
+ themes
202
+ .map((theme) => [theme.name, sources.get(theme.name).get(name)?.value])
203
+ .filter(([, value]) => value !== undefined),
204
+ ),
205
+ themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
206
+ })),
207
+ textPairs: pairs.textPairs,
208
+ };
209
+
210
+ writeFileSync(
211
+ join(packageRoot, "src", "tokens.manifest.json"),
212
+ `${JSON.stringify(manifest, null, 2)}\n`,
213
+ );
214
+ console.log(
215
+ `wrote src/tokens.manifest.json (${manifest.tokens.length} tokens, ` +
216
+ `${manifest.themes.length} themes, ${manifest.textPairs.length} pairs)`,
217
+ );
@@ -0,0 +1,107 @@
1
+ // A minimal CSS rule reader for the token-sheet gates.
2
+ //
3
+ // Not a general parser and not exported from the package (`exports` in package.json
4
+ // publishes only the entry, the schema, the OpenAPI document and the sheet itself). It
5
+ // exists because two gates read `tokens.css` and a naive "slice to the next `}`" scan is
6
+ // wrong in a way that passes: a value containing a brace, or an `@media` wrapper, ends the
7
+ // block early and the gate then asserts about a fragment.
8
+
9
+ /**
10
+ * Every `selector { … }` rule in `css`, flattened.
11
+ *
12
+ * An `@media` (or any other conditional group) contributes its inner rules rather than
13
+ * itself, so a nested `:root` is reachable by selector. A statement at-rule — `@charset`,
14
+ * `@import`, `@layer a, b;` — declares no block and is skipped; without that, its trailing
15
+ * `;` would let the scan run on to the *next* rule's brace and silently merge two rules.
16
+ *
17
+ * Each rule carries its custom properties as `declarations` and *every* declaration —
18
+ * standard properties included — as `properties`. The two are separate because the token
19
+ * gates want only custom properties nearly everywhere (a theme block is a set of tokens),
20
+ * and exactly one gate wants a standard one: `color-scheme`, which every theme block must
21
+ * declare and which is invisible to a custom-property reader.
22
+ *
23
+ * @param {string} css
24
+ * @returns {{ selector: string, declarations: Map<string, string>, properties: Map<string, string> }[]}
25
+ */
26
+ export function parseRules(css) {
27
+ const source = stripComments(css);
28
+ const rules = [];
29
+ let index = 0;
30
+ while (index < source.length) {
31
+ const open = source.indexOf("{", index);
32
+ if (open === -1) break;
33
+ // A `;` before the next `{` means the run of text is a statement at-rule, not a
34
+ // selector: consume it and carry on rather than treating the following block as its own.
35
+ const statementEnd = source.indexOf(";", index);
36
+ if (statementEnd !== -1 && statementEnd < open) {
37
+ index = statementEnd + 1;
38
+ continue;
39
+ }
40
+ const selector = source.slice(index, open).trim();
41
+ let depth = 1;
42
+ let cursor = open + 1;
43
+ // Quote-aware: a brace inside a quoted value (a font stack, a `url()`, a `content`
44
+ // string) is data, not structure. Counting it would end the block early and leave every
45
+ // later declaration in the rule unread — silently, because the fragment still parses.
46
+ let quote = "";
47
+ while (cursor < source.length && depth > 0) {
48
+ const character = source[cursor];
49
+ if (quote) {
50
+ if (character === "\\") cursor += 1;
51
+ else if (character === quote) quote = "";
52
+ } else if (character === '"' || character === "'") {
53
+ quote = character;
54
+ } else if (character === "{") depth += 1;
55
+ else if (character === "}") depth -= 1;
56
+ cursor += 1;
57
+ }
58
+ const body = source.slice(open + 1, cursor - 1);
59
+ if (selector.startsWith("@")) {
60
+ rules.push(...parseRules(body));
61
+ } else if (selector) {
62
+ rules.push({
63
+ selector,
64
+ declarations: parseDeclarations(body),
65
+ properties: parseProperties(body),
66
+ });
67
+ }
68
+ index = cursor;
69
+ }
70
+ return rules;
71
+ }
72
+
73
+ /**
74
+ * The `--token: value` custom properties declared directly in a rule body, in source order.
75
+ *
76
+ * @param {string} body
77
+ * @returns {Map<string, string>}
78
+ */
79
+ export function parseDeclarations(body) {
80
+ const declarations = new Map();
81
+ for (const match of body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
82
+ declarations.set(match[1], match[2].trim());
83
+ }
84
+ return declarations;
85
+ }
86
+
87
+ /**
88
+ * Every declaration directly in a rule body, standard properties included, in source order.
89
+ *
90
+ * Only `color-scheme` is read through this today, but a reader that special-cased that one
91
+ * name would quietly stop working the moment a second standard property mattered.
92
+ *
93
+ * @param {string} body
94
+ * @returns {Map<string, string>}
95
+ */
96
+ export function parseProperties(body) {
97
+ const properties = new Map();
98
+ for (const match of body.matchAll(/([a-zA-Z-][\w-]*)\s*:\s*([^;]+);/g)) {
99
+ properties.set(match[1], match[2].trim());
100
+ }
101
+ return properties;
102
+ }
103
+
104
+ /** `css` with `/* … *\/` comments removed. */
105
+ export function stripComments(css) {
106
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
107
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ parseDeclarations,
5
+ parseProperties,
6
+ parseRules,
7
+ stripComments,
8
+ } from "./css-rules.js";
9
+
10
+ // The reader the two token gates are built on. Every case here is a shape that made an
11
+ // earlier, naive version of this code assert about a fragment of a rule while still
12
+ // reporting green — which is the only failure mode that matters for a gate.
13
+
14
+ describe("parseRules", () => {
15
+ it("reads a flat rule's custom properties", () => {
16
+ const rules = parseRules(":root { --a: 1; --b: 2; }");
17
+ expect(rules).toHaveLength(1);
18
+ expect(rules[0].selector).toBe(":root");
19
+ expect([...rules[0].declarations]).toEqual([
20
+ ["--a", "1"],
21
+ ["--b", "2"],
22
+ ]);
23
+ });
24
+
25
+ it("flattens a conditional group to its inner rules", () => {
26
+ // The sheet's OS-preference dark theme is a `:root:not(…)` nested in `@media`. A reader
27
+ // that returned the `@media` itself would find no declarations and the completeness
28
+ // gate would pass by asserting about nothing.
29
+ const rules = parseRules(
30
+ "@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --a: 9; } }",
31
+ );
32
+ expect(rules.map((rule) => rule.selector)).toEqual([":root:not([data-theme='light'])"]);
33
+ expect(rules[0].declarations.get("--a")).toBe("9");
34
+ });
35
+
36
+ it("skips a statement at-rule instead of swallowing the next rule", () => {
37
+ // `@layer tokens;` declares no block. Scanning to the next `{` would make the selector
38
+ // `@layer tokens; :root`, which starts with `@`, so the reader would recurse into
39
+ // `:root`'s body and drop the rule — `:root` then does not exist and every downstream
40
+ // assertion is about a sheet with no light theme.
41
+ const rules = parseRules("@layer tokens;\n:root { --a: 1; }");
42
+ expect(rules.map((rule) => rule.selector)).toEqual([":root"]);
43
+ expect(rules[0].declarations.get("--a")).toBe("1");
44
+ });
45
+
46
+ it("skips a charset or import line the same way", () => {
47
+ const rules = parseRules('@charset "utf-8";\n@import "x.css";\n:root { --a: 1; }');
48
+ expect(rules.map((rule) => rule.selector)).toEqual([":root"]);
49
+ });
50
+
51
+ it("does not end a block on a brace inside a value", () => {
52
+ // Not hypothetical for a generated sheet: a font stack or a `url()` can carry one.
53
+ const rules = parseRules(':root { --a: "}"; --b: 2; }');
54
+ expect(rules).toHaveLength(1);
55
+ expect(rules[0].declarations.get("--b")).toBe("2");
56
+ });
57
+
58
+ it("ignores commented-out declarations", () => {
59
+ const rules = parseRules(":root { /* --a: 1; */ --b: 2; }");
60
+ expect([...rules[0].declarations.keys()]).toEqual(["--b"]);
61
+ });
62
+
63
+ it("returns nothing for a sheet with no rules", () => {
64
+ expect(parseRules("")).toEqual([]);
65
+ expect(parseRules("/* just a comment */")).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe("parseProperties", () => {
70
+ it("reads standard properties alongside custom ones", () => {
71
+ // `color-scheme` is the reason this exists: every theme block must declare it, and a
72
+ // custom-property reader cannot see it, so the gate that checks it would pass vacuously.
73
+ const properties = parseProperties("color-scheme: dark; --a: 1;");
74
+ expect([...properties]).toEqual([
75
+ ["color-scheme", "dark"],
76
+ ["--a", "1"],
77
+ ]);
78
+ });
79
+
80
+ it("does not read a property name out of a value", () => {
81
+ // A value containing a colon — a `url(https://…)`, a media condition — would otherwise
82
+ // register as a second property and the caller would be reading noise.
83
+ expect([...parseProperties("background: url(https://x/y.png);").keys()]).toEqual([
84
+ "background",
85
+ ]);
86
+ });
87
+
88
+ it("is exposed on every rule the reader returns", () => {
89
+ const [rule] = parseRules(":root { color-scheme: light; --a: 1; }");
90
+ expect([...rule.declarations.keys()]).toEqual(["--a"]);
91
+ expect(rule.properties.get("color-scheme")).toBe("light");
92
+ });
93
+ });
94
+
95
+ describe("parseDeclarations", () => {
96
+ it("keeps the last value when a property repeats", () => {
97
+ // The cascade's own behaviour: a duplicated property in one block resolves to the last.
98
+ expect(parseDeclarations("--a: 1; --a: 2;").get("--a")).toBe("2");
99
+ });
100
+
101
+ it("ignores ordinary properties", () => {
102
+ expect([...parseDeclarations("color: red; --a: 1;").keys()]).toEqual(["--a"]);
103
+ });
104
+ });
105
+
106
+ describe("stripComments", () => {
107
+ it("removes multi-line comments", () => {
108
+ expect(stripComments("a/* x\ny */b")).toBe("ab");
109
+ });
110
+ });