rastack 0.0.23 → 0.0.25
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 +9 -0
- package/dist/rastack-design.d.ts +17 -0
- package/dist/rastack-design.js +134 -0
- package/dist/rastack-tokens.d.ts +15 -0
- package/dist/rastack-tokens.js +43 -0
- package/dist/rastack.d.ts +2 -0
- package/dist/rastack.js +12 -0
- package/dist/tokens/color.d.ts +22 -0
- package/dist/tokens/color.js +70 -0
- package/dist/tokens/compile.d.ts +34 -0
- package/dist/tokens/compile.js +103 -0
- package/dist/tokens/css.d.ts +30 -0
- package/dist/tokens/css.js +102 -0
- package/dist/tokens/define.d.ts +71 -0
- package/dist/tokens/define.js +98 -0
- package/dist/tokens/index.d.ts +17 -0
- package/dist/tokens/index.js +33 -0
- package/dist/tokens/resolve.d.ts +40 -0
- package/dist/tokens/resolve.js +135 -0
- package/dist/tokens/studio.d.ts +21 -0
- package/dist/tokens/studio.js +328 -0
- package/dist/tokens/theme.d.ts +55 -0
- package/dist/tokens/theme.js +139 -0
- package/dist/tokens/ts.d.ts +15 -0
- package/dist/tokens/ts.js +73 -0
- package/dist/tokens/types.d.ts +92 -0
- package/dist/tokens/types.js +35 -0
- package/jest.config.cjs +6 -0
- package/package.json +3 -2
- package/src/rastack-design.ts +117 -0
- package/src/rastack-tokens.ts +46 -0
- package/src/rastack.ts +12 -0
- package/src/tokens/color.ts +74 -0
- package/src/tokens/compile.ts +85 -0
- package/src/tokens/css.ts +138 -0
- package/src/tokens/define.ts +128 -0
- package/src/tokens/index.ts +18 -0
- package/src/tokens/resolve.ts +170 -0
- package/src/tokens/studio.ts +357 -0
- package/src/tokens/theme.ts +180 -0
- package/src/tokens/ts.ts +80 -0
- package/src/tokens/types.ts +125 -0
- package/test/tokens.spec.ts +302 -0
- package/theme/index.ts +9 -0
- package/theme/provider.tsx +157 -0
- package/tokens.ts +9 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design-token model — the **W3C Design Tokens (DTCG) format**.
|
|
3
|
+
*
|
|
4
|
+
* A design token is the smallest, named, platform-agnostic decision about how
|
|
5
|
+
* the UI looks: a colour, a spacing step, a radius, a font family. The DTCG
|
|
6
|
+
* community-group spec (https://tr.designtokens.org) is the industry standard
|
|
7
|
+
* serialization: a nested JSON tree of **groups** whose leaves are **tokens**,
|
|
8
|
+
* each carrying a `$value`, an optional `$type`, and an optional
|
|
9
|
+
* `$description`. Values may be literals or **aliases** — a `"{group.token}"`
|
|
10
|
+
* reference to another token.
|
|
11
|
+
*
|
|
12
|
+
* ```jsonc
|
|
13
|
+
* {
|
|
14
|
+
* "color": {
|
|
15
|
+
* "$type": "color",
|
|
16
|
+
* "brand": { "500": { "$value": "#4F6BFF" } },
|
|
17
|
+
* "text": { "$value": "{color.brand.500}" } // alias
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* This module is the pure data model shared by the compiler
|
|
23
|
+
* (`tools/src/tokens/{resolve,css,ts}.ts`) and the React runtime
|
|
24
|
+
* (`rastack/theme`). It has no filesystem or React dependency, so it builds for
|
|
25
|
+
* the browser exactly like `rastack/define`.
|
|
26
|
+
*/
|
|
27
|
+
/** The DTCG `$type`s this framework understands. */
|
|
28
|
+
export type TokenType = "color" | "dimension" | "fontFamily" | "fontWeight" | "duration" | "cubicBezier" | "number" | "shadow" | "border" | "typography" | "transition";
|
|
29
|
+
/** A shadow token value (single shadow or a stack of them). */
|
|
30
|
+
export interface ShadowValue {
|
|
31
|
+
offsetX: string | number;
|
|
32
|
+
offsetY: string | number;
|
|
33
|
+
blur?: string | number;
|
|
34
|
+
spread?: string | number;
|
|
35
|
+
color: string;
|
|
36
|
+
inset?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Anything that can appear as a token `$value` (including aliases as strings). */
|
|
39
|
+
export type TokenValue = string | number | boolean | ShadowValue | ShadowValue[] | string[] | Record<string, unknown>;
|
|
40
|
+
/** A single design token — a group leaf. Identified by the presence of `$value`. */
|
|
41
|
+
export interface DesignToken {
|
|
42
|
+
$value: TokenValue;
|
|
43
|
+
$type?: TokenType;
|
|
44
|
+
$description?: string;
|
|
45
|
+
$extensions?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A group node: named children (tokens or nested groups) plus optional group
|
|
49
|
+
* metadata. `$type` set on a group is **inherited** by descendant tokens that
|
|
50
|
+
* don't declare their own — the standard DTCG type-inheritance rule.
|
|
51
|
+
*
|
|
52
|
+
* Typed loosely (`any` children) on purpose: the tree is arbitrary-depth data,
|
|
53
|
+
* mirroring how the codebase types OpenAPI documents.
|
|
54
|
+
*/
|
|
55
|
+
export interface TokenGroup {
|
|
56
|
+
$type?: TokenType;
|
|
57
|
+
$description?: string;
|
|
58
|
+
[child: string]: DesignToken | TokenGroup | TokenType | string | undefined;
|
|
59
|
+
}
|
|
60
|
+
/** The root of a token tree. */
|
|
61
|
+
export type TokenDocument = TokenGroup;
|
|
62
|
+
/**
|
|
63
|
+
* A themeable token document: a base tree plus named **modes** (e.g. `light`,
|
|
64
|
+
* `dark`) that override a subset of tokens. Modes compile to CSS selector
|
|
65
|
+
* scopes, so a running app flips theme by toggling one attribute.
|
|
66
|
+
*/
|
|
67
|
+
export interface ThemedTokens {
|
|
68
|
+
/** Base tokens — the values that apply in every mode. */
|
|
69
|
+
tokens: TokenDocument;
|
|
70
|
+
/** Per-mode overrides, deep-merged over the base. */
|
|
71
|
+
modes?: Record<string, TokenDocument>;
|
|
72
|
+
/** Which mode is the default (emitted onto `:root`). */
|
|
73
|
+
defaultMode?: string;
|
|
74
|
+
}
|
|
75
|
+
/** A token after alias resolution and `$type` inheritance — ready to emit. */
|
|
76
|
+
export interface ResolvedToken {
|
|
77
|
+
/** Dotted path, e.g. `color.brand.500`. */
|
|
78
|
+
path: string;
|
|
79
|
+
/** The token's `$type` (inherited or inferred). */
|
|
80
|
+
type: TokenType | undefined;
|
|
81
|
+
/** The literal value with every alias followed to its source. */
|
|
82
|
+
value: TokenValue;
|
|
83
|
+
/**
|
|
84
|
+
* If this token is a direct alias of another, the target's dotted path.
|
|
85
|
+
* Emitters use it to preserve the reference (CSS `var(--target)`), so theming
|
|
86
|
+
* cascades instead of being flattened away.
|
|
87
|
+
*/
|
|
88
|
+
aliasOf?: string;
|
|
89
|
+
$description?: string;
|
|
90
|
+
}
|
|
91
|
+
/** True when a node is a token leaf (has `$value`) rather than a group. */
|
|
92
|
+
export declare function isToken(node: unknown): node is DesignToken;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Design-token model — the **W3C Design Tokens (DTCG) format**.
|
|
4
|
+
*
|
|
5
|
+
* A design token is the smallest, named, platform-agnostic decision about how
|
|
6
|
+
* the UI looks: a colour, a spacing step, a radius, a font family. The DTCG
|
|
7
|
+
* community-group spec (https://tr.designtokens.org) is the industry standard
|
|
8
|
+
* serialization: a nested JSON tree of **groups** whose leaves are **tokens**,
|
|
9
|
+
* each carrying a `$value`, an optional `$type`, and an optional
|
|
10
|
+
* `$description`. Values may be literals or **aliases** — a `"{group.token}"`
|
|
11
|
+
* reference to another token.
|
|
12
|
+
*
|
|
13
|
+
* ```jsonc
|
|
14
|
+
* {
|
|
15
|
+
* "color": {
|
|
16
|
+
* "$type": "color",
|
|
17
|
+
* "brand": { "500": { "$value": "#4F6BFF" } },
|
|
18
|
+
* "text": { "$value": "{color.brand.500}" } // alias
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* This module is the pure data model shared by the compiler
|
|
24
|
+
* (`tools/src/tokens/{resolve,css,ts}.ts`) and the React runtime
|
|
25
|
+
* (`rastack/theme`). It has no filesystem or React dependency, so it builds for
|
|
26
|
+
* the browser exactly like `rastack/define`.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.isToken = isToken;
|
|
30
|
+
/** True when a node is a token leaf (has `$value`) rather than a group. */
|
|
31
|
+
function isToken(node) {
|
|
32
|
+
return (typeof node === "object" &&
|
|
33
|
+
node !== null &&
|
|
34
|
+
Object.prototype.hasOwnProperty.call(node, "$value"));
|
|
35
|
+
}
|
package/jest.config.cjs
CHANGED
|
@@ -3,4 +3,10 @@ module.exports = {
|
|
|
3
3
|
testEnvironment: 'node',
|
|
4
4
|
testMatch: ['**/test/**/*.spec.ts'],
|
|
5
5
|
moduleFileExtensions: ['ts', 'js'],
|
|
6
|
+
// Let executed test fixtures import the package by its published subpath
|
|
7
|
+
// names, the same way a consumer would (the resource compiler resolves these
|
|
8
|
+
// via its own ts.Program; these mappings are for code jest actually runs).
|
|
9
|
+
moduleNameMapper: {
|
|
10
|
+
'^rastack/tokens$': '<rootDir>/tokens.ts',
|
|
11
|
+
},
|
|
6
12
|
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rastack",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "runtime.ts",
|
|
6
6
|
"types": "runtime.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"rastack": "dist/rastack.js"
|
|
8
|
+
"rastack": "dist/rastack.js",
|
|
9
|
+
"ras": "dist/rastack.js"
|
|
9
10
|
},
|
|
10
11
|
"publishConfig": {
|
|
11
12
|
"access": "public"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `rastack design` (aka `ras design`) — a live design-system studio.
|
|
5
|
+
*
|
|
6
|
+
* rastack design [input] [--port <n>] [--prefix <p>]
|
|
7
|
+
*
|
|
8
|
+
* Boots a tiny, dependency-free HTTP server that serves an interactive page
|
|
9
|
+
* showcasing the design system — colour swatches, the type ramp, spacing,
|
|
10
|
+
* radii, shadows, and component previews built from the tokens themselves — and
|
|
11
|
+
* lets you edit token values in place with a live preview. Editing a `.json`
|
|
12
|
+
* source and hitting **Save** writes it back and recompiles `tokens.css` /
|
|
13
|
+
* `tokens.ts`, so the rest of the app picks the change up on reload.
|
|
14
|
+
*
|
|
15
|
+
* In keeping with the local-first ethos (the whole API runs in the browser via
|
|
16
|
+
* WASM), the studio needs no build step and no external dependencies.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as http from "http";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { compileTokens, loadTokens } from "./tokens/compile";
|
|
22
|
+
import { renderStudio } from "./tokens/studio";
|
|
23
|
+
import { ThemedTokens } from "./tokens/types";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_INPUT = "tokens/theme.tokens.json";
|
|
26
|
+
const DEFAULT_OUT = ".rastack";
|
|
27
|
+
|
|
28
|
+
interface Args {
|
|
29
|
+
input: string;
|
|
30
|
+
port: number;
|
|
31
|
+
prefix: string;
|
|
32
|
+
outDir: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv: string[]): Args {
|
|
36
|
+
const positional: string[] = [];
|
|
37
|
+
let port = 4321;
|
|
38
|
+
let prefix = "";
|
|
39
|
+
let outDir = DEFAULT_OUT;
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
if (a === "--port") port = Number(argv[++i]) || port;
|
|
43
|
+
else if (a === "--prefix") prefix = argv[++i] ?? "";
|
|
44
|
+
else if (a === "--out") outDir = argv[++i] ?? outDir;
|
|
45
|
+
else positional.push(a);
|
|
46
|
+
}
|
|
47
|
+
return { input: positional[0] || DEFAULT_INPUT, port, prefix, outDir };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function main(argv: string[]): void {
|
|
51
|
+
const args = parseArgs(argv);
|
|
52
|
+
const isJson = args.input.endsWith(".json");
|
|
53
|
+
|
|
54
|
+
let themed: ThemedTokens;
|
|
55
|
+
try {
|
|
56
|
+
themed = loadTokens(args.input);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(` ✗ ${(err as Error).message}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const server = http.createServer((req, res) => {
|
|
64
|
+
// Only edits to a JSON source can be written back safely (a module source
|
|
65
|
+
// is code, not data).
|
|
66
|
+
if (req.method === "POST" && req.url === "/save") {
|
|
67
|
+
if (!isJson) {
|
|
68
|
+
res.writeHead(400).end("Editing is only supported for .json token sources.");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let body = "";
|
|
72
|
+
req.on("data", (c) => (body += c));
|
|
73
|
+
req.on("end", () => {
|
|
74
|
+
try {
|
|
75
|
+
const next = JSON.parse(body) as ThemedTokens;
|
|
76
|
+
require("fs").writeFileSync(
|
|
77
|
+
path.resolve(args.input),
|
|
78
|
+
JSON.stringify(next, null, 2) + "\n",
|
|
79
|
+
);
|
|
80
|
+
themed = next;
|
|
81
|
+
// Recompile so tokens.css / tokens.ts reflect the save immediately.
|
|
82
|
+
compileTokens(args.input, args.outDir, { prefix: args.prefix });
|
|
83
|
+
res.writeHead(200, { "Content-Type": "application/json" }).end(`{"ok":true}`);
|
|
84
|
+
console.log(` ↳ saved ${args.input} & recompiled`);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
res.writeHead(500).end((err as Error).message);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
|
|
93
|
+
const html = renderStudio(themed, {
|
|
94
|
+
prefix: args.prefix,
|
|
95
|
+
editable: isJson,
|
|
96
|
+
title: "Design System",
|
|
97
|
+
});
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(html);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
res.writeHead(404).end("Not found");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
server.listen(args.port, () => {
|
|
106
|
+
console.log(
|
|
107
|
+
`\n rastack design — studio for ${args.input}\n` +
|
|
108
|
+
` ▸ http://localhost:${args.port}\n` +
|
|
109
|
+
(isJson ? ` Edits Save back to ${args.input}.\n` : ` Read-only (module source).\n`) +
|
|
110
|
+
`\n Press Ctrl+C to stop.\n`,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (require.main === module) {
|
|
116
|
+
main(process.argv.slice(2));
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `rastack tokens` — compile a design-token source into shippable artifacts.
|
|
5
|
+
*
|
|
6
|
+
* rastack tokens [input] [outDir] [--prefix <p>]
|
|
7
|
+
*
|
|
8
|
+
* `input` a DTCG `.json` file, or a compiled module that default-exports
|
|
9
|
+
* `defineTokens(...)` (default: `tokens/theme.tokens.json`).
|
|
10
|
+
* `outDir` where to write `tokens.{rastack.json,css,ts}` (default: `.rastack`).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `rastack compile`: TypeScript-authored source → canonical JSON +
|
|
13
|
+
* ready-to-use outputs, with alias/cycle errors surfaced before anything is
|
|
14
|
+
* written.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { compileTokens } from "./tokens/compile";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_INPUT = "tokens/theme.tokens.json";
|
|
20
|
+
const DEFAULT_OUT = ".rastack";
|
|
21
|
+
|
|
22
|
+
function main(argv: string[]): void {
|
|
23
|
+
const positional: string[] = [];
|
|
24
|
+
let prefix = "";
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
if (argv[i] === "--prefix") prefix = argv[++i] ?? "";
|
|
27
|
+
else positional.push(argv[i]);
|
|
28
|
+
}
|
|
29
|
+
const input = positional[0] || DEFAULT_INPUT;
|
|
30
|
+
const outDir = positional[1] || DEFAULT_OUT;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { themed, files } = compileTokens(input, outDir, { prefix });
|
|
34
|
+
const modes = Object.keys(themed.modes ?? {});
|
|
35
|
+
console.log(
|
|
36
|
+
`✓ compiled design tokens${modes.length ? ` (${modes.length} mode${modes.length > 1 ? "s" : ""}: ${modes.join(", ")})` : ""} → ${files.join(", ")}`,
|
|
37
|
+
);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(` ✗ ${(err as Error).message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (require.main === module) {
|
|
45
|
+
main(process.argv.slice(2));
|
|
46
|
+
}
|
package/src/rastack.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* rastack list [resourcesDir] Print resources, fields and relations
|
|
10
10
|
* rastack urls [resourcesDir] Print the generated /api/{app}/v1/{model}/ routes
|
|
11
11
|
* rastack generate [--schema-only] Generate typed hooks from OpenAPI
|
|
12
|
+
* rastack tokens [input] [outDir] Compile design tokens → CSS vars + typed theme
|
|
13
|
+
* rastack design [input] [--port n] Live design-system studio (showcase + edit)
|
|
12
14
|
* rastack serve [--warehouse dir] Run the Rust API over an Iceberg warehouse
|
|
13
15
|
* rastack admin [--warehouse dir] Run the Rust admin (Django-admin-style DB browser)
|
|
14
16
|
* rastack wasm Compile the API to WebAssembly (in-browser local dev)
|
|
@@ -74,6 +76,14 @@ switch (command) {
|
|
|
74
76
|
// Compile the API to WebAssembly for in-browser local dev.
|
|
75
77
|
run("rastack-wasm-build.js", rest);
|
|
76
78
|
break;
|
|
79
|
+
case "tokens":
|
|
80
|
+
// Design tokens → CSS custom properties + typed theme.
|
|
81
|
+
run("rastack-tokens.js", rest);
|
|
82
|
+
break;
|
|
83
|
+
case "design":
|
|
84
|
+
// Live design-system studio (showcase + inspect + edit).
|
|
85
|
+
run("rastack-design.js", rest);
|
|
86
|
+
break;
|
|
77
87
|
case "scan":
|
|
78
88
|
run("scan.js");
|
|
79
89
|
break;
|
|
@@ -92,6 +102,8 @@ switch (command) {
|
|
|
92
102
|
` rastack compile [resourcesDir] [outDir]\n` +
|
|
93
103
|
` rastack check | list | urls [resourcesDir]\n` +
|
|
94
104
|
` rastack generate [--schema-only]\n` +
|
|
105
|
+
` rastack tokens [input] [outDir] [--prefix p] Compile design tokens → CSS vars + typed theme\n` +
|
|
106
|
+
` rastack design [input] [--port n] Live design-system studio (showcase + edit)\n` +
|
|
95
107
|
` rastack serve | admin [--warehouse dir]\n` +
|
|
96
108
|
` rastack wasm Compile the API to WebAssembly (browser local dev)\n` +
|
|
97
109
|
` rastack scan [files...] [--fail-on-pii]\n` +
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny, dependency-free colour maths — just enough to turn one brand colour into
|
|
3
|
+
* a full tint/shade scale and to pick a readable on-colour. Used by
|
|
4
|
+
* {@link defineTheme} so a non-designer specifies a single hex and gets a
|
|
5
|
+
* complete, sensible palette.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface Rgb {
|
|
9
|
+
r: number;
|
|
10
|
+
g: number;
|
|
11
|
+
b: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Parse `#rgb` / `#rrggbb` (with or without `#`) into 0–255 channels. */
|
|
15
|
+
export function parseHex(hex: string): Rgb {
|
|
16
|
+
let h = hex.trim().replace(/^#/, "");
|
|
17
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
18
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) {
|
|
19
|
+
throw new Error(`Not a hex colour: "${hex}" (use e.g. "#4F6BFF").`);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
23
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
24
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toHex({ r, g, b }: Rgb): string {
|
|
29
|
+
const h = (n: number) =>
|
|
30
|
+
("0" + Math.round(Math.max(0, Math.min(255, n))).toString(16)).slice(-2);
|
|
31
|
+
return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Blend two colours; `amount` is how far from `a` toward `b` (0–1). */
|
|
35
|
+
export function mix(a: string, b: string, amount: number): string {
|
|
36
|
+
const x = parseHex(a);
|
|
37
|
+
const y = parseHex(b);
|
|
38
|
+
return toHex({
|
|
39
|
+
r: x.r + (y.r - x.r) * amount,
|
|
40
|
+
g: x.g + (y.g - x.g) * amount,
|
|
41
|
+
b: x.b + (y.b - x.b) * amount,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Relative luminance (0 dark – 1 light), for contrast decisions. */
|
|
46
|
+
export function luminance(hex: string): number {
|
|
47
|
+
const { r, g, b } = parseHex(hex);
|
|
48
|
+
const lin = (c: number) => {
|
|
49
|
+
const s = c / 255;
|
|
50
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
51
|
+
};
|
|
52
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Black or white — whichever is readable on `hex`. */
|
|
56
|
+
export function readableOn(hex: string): string {
|
|
57
|
+
return luminance(hex) > 0.45 ? "#0B1020" : "#FFFFFF";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How much to mix a seed toward white (tints) or black (shades) at each step of
|
|
62
|
+
* a Tailwind-style 50–950 scale. `500` is the seed itself.
|
|
63
|
+
*/
|
|
64
|
+
const TINT: Record<number, number> = { 50: 0.95, 100: 0.9, 200: 0.78, 300: 0.62, 400: 0.34 };
|
|
65
|
+
const SHADE: Record<number, number> = { 600: 0.12, 700: 0.28, 800: 0.44, 900: 0.6, 950: 0.74 };
|
|
66
|
+
|
|
67
|
+
/** Build a `{ 50…950 }` colour scale from a single seed colour. */
|
|
68
|
+
export function scale(seed: string): Record<string, string> {
|
|
69
|
+
const out: Record<string, string> = {};
|
|
70
|
+
for (const [step, amt] of Object.entries(TINT)) out[step] = mix(seed, "#FFFFFF", amt);
|
|
71
|
+
out["500"] = toHex(parseHex(seed));
|
|
72
|
+
for (const [step, amt] of Object.entries(SHADE)) out[step] = mix(seed, "#000000", amt);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token compilation with file IO — the engine behind `rastack tokens`.
|
|
3
|
+
*
|
|
4
|
+
* Loads a token source (canonical DTCG `.json`, or a `.js`/compiled module that
|
|
5
|
+
* default-exports a {@link ThemedTokens}), resolves it, and writes three
|
|
6
|
+
* artifacts:
|
|
7
|
+
*
|
|
8
|
+
* - `tokens.rastack.json` — the canonical, resolved DTCG document (the manifest
|
|
9
|
+
* analogue: one file that fully describes the design system).
|
|
10
|
+
* - `tokens.css` — CSS custom properties with per-mode selector scopes.
|
|
11
|
+
* - `tokens.ts` — the typed theme tree + `TokenPath` union.
|
|
12
|
+
*
|
|
13
|
+
* Kept separate from the pure core (`./index.ts`) so the browser runtime never
|
|
14
|
+
* pulls in `fs`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import { defineTokens } from "./define";
|
|
20
|
+
import { CssOptions, emitCss } from "./css";
|
|
21
|
+
import { resolveTheme } from "./resolve";
|
|
22
|
+
import { emitTs } from "./ts";
|
|
23
|
+
import { ThemedTokens } from "./types";
|
|
24
|
+
|
|
25
|
+
export interface TokenCompileOptions extends CssOptions {}
|
|
26
|
+
|
|
27
|
+
export interface TokenCompileResult {
|
|
28
|
+
themed: ThemedTokens;
|
|
29
|
+
files: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load a token source into a normalised {@link ThemedTokens}. `.json` is parsed
|
|
34
|
+
* as DTCG; any other extension is `require`d and its default export (or the
|
|
35
|
+
* module itself) is taken — so a `defineTokens(...)` module works once compiled
|
|
36
|
+
* to JS (or when a `.ts` loader such as ts-node is registered).
|
|
37
|
+
*/
|
|
38
|
+
export function loadTokens(file: string): ThemedTokens {
|
|
39
|
+
const abs = path.resolve(file);
|
|
40
|
+
if (!fs.existsSync(abs)) throw new Error(`No such tokens file: ${file}`);
|
|
41
|
+
|
|
42
|
+
if (abs.endsWith(".json")) {
|
|
43
|
+
const raw = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
44
|
+
// Accept either the themed shape or a bare DTCG tree.
|
|
45
|
+
return defineTokens(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
49
|
+
const mod = require(abs);
|
|
50
|
+
const exported = mod?.default ?? mod;
|
|
51
|
+
return defineTokens(exported);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The canonical, resolved DTCG document written to `tokens.rastack.json`. */
|
|
55
|
+
export function canonicalDocument(themed: ThemedTokens): unknown {
|
|
56
|
+
return {
|
|
57
|
+
$description: "Compiled by `rastack tokens`. The resolved design system.",
|
|
58
|
+
...themed.tokens,
|
|
59
|
+
...(themed.modes && Object.keys(themed.modes).length
|
|
60
|
+
? { $extensions: { "com.rastack.modes": themed.modes, "com.rastack.defaultMode": themed.defaultMode } }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compile a token source file to `tokens.{rastack.json,css,ts}` in `outDir`. */
|
|
66
|
+
export function compileTokens(
|
|
67
|
+
file: string,
|
|
68
|
+
outDir: string,
|
|
69
|
+
options: TokenCompileOptions = {},
|
|
70
|
+
): TokenCompileResult {
|
|
71
|
+
const themed = loadTokens(file);
|
|
72
|
+
// Resolve eagerly so alias/cycle errors surface here, before writing anything.
|
|
73
|
+
resolveTheme(themed);
|
|
74
|
+
|
|
75
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
76
|
+
const jsonPath = path.join(outDir, "tokens.rastack.json");
|
|
77
|
+
const cssPath = path.join(outDir, "tokens.css");
|
|
78
|
+
const tsPath = path.join(outDir, "tokens.ts");
|
|
79
|
+
|
|
80
|
+
fs.writeFileSync(jsonPath, JSON.stringify(canonicalDocument(themed), null, 2) + "\n");
|
|
81
|
+
fs.writeFileSync(cssPath, emitCss(themed, options));
|
|
82
|
+
fs.writeFileSync(tsPath, emitTs(resolveTheme(themed).base, options));
|
|
83
|
+
|
|
84
|
+
return { themed, files: [jsonPath, cssPath, tsPath] };
|
|
85
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS custom-property emitter — the primary, framework-agnostic output.
|
|
3
|
+
*
|
|
4
|
+
* Design tokens become CSS variables under `:root`, and each **mode** becomes a
|
|
5
|
+
* selector scope that overrides only the tokens it changes. A running app then
|
|
6
|
+
* switches theme by toggling one attribute (`<html data-theme="dark">`), and
|
|
7
|
+
* every `var(--…)` reference recascades for free. Aliases are preserved as
|
|
8
|
+
* `var(--target)` rather than inlined, so overriding a base token
|
|
9
|
+
* automatically flows through everything that references it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cssVarName, ResolvedTheme, resolveTheme } from "./resolve";
|
|
13
|
+
import { ThemedTokens, ResolvedToken, ShadowValue, TokenValue } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface CssOptions {
|
|
16
|
+
/** Prefix every variable: `prefix: "rs"` → `--rs-color-brand-500`. */
|
|
17
|
+
prefix?: string;
|
|
18
|
+
/**
|
|
19
|
+
* How a mode maps to a selector. Given the mode name, return the selector
|
|
20
|
+
* whose block holds that mode's overrides. Default: `[data-theme="<mode>"]`,
|
|
21
|
+
* with the default mode also written to `:root`.
|
|
22
|
+
*/
|
|
23
|
+
modeSelector?: (mode: string) => string;
|
|
24
|
+
/** Root selector for the base tokens. Default `:root`. */
|
|
25
|
+
rootSelector?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Render one CSS number/length; bare numbers stay unitless. */
|
|
29
|
+
function dim(v: string | number): string {
|
|
30
|
+
return typeof v === "number" ? `${v}px` : v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Quote a font-family entry only when it contains whitespace. */
|
|
34
|
+
function fontEntry(name: string): string {
|
|
35
|
+
return /\s/.test(name) && !/^["']/.test(name) ? `"${name}"` : name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shadow(s: ShadowValue): string {
|
|
39
|
+
const parts = [
|
|
40
|
+
s.inset ? "inset" : "",
|
|
41
|
+
dim(s.offsetX),
|
|
42
|
+
dim(s.offsetY),
|
|
43
|
+
s.blur != null ? dim(s.blur) : "",
|
|
44
|
+
s.spread != null ? dim(s.spread) : "",
|
|
45
|
+
s.color,
|
|
46
|
+
];
|
|
47
|
+
return parts.filter(Boolean).join(" ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Format a resolved token's literal value as a CSS declaration value. */
|
|
51
|
+
export function formatCssValue(token: ResolvedToken): string {
|
|
52
|
+
const { type, value } = token;
|
|
53
|
+
switch (type) {
|
|
54
|
+
case "fontFamily":
|
|
55
|
+
return (Array.isArray(value) ? (value as string[]) : [value as string])
|
|
56
|
+
.map(fontEntry)
|
|
57
|
+
.join(", ");
|
|
58
|
+
case "cubicBezier":
|
|
59
|
+
return `cubic-bezier(${(value as unknown as number[]).join(", ")})`;
|
|
60
|
+
case "shadow":
|
|
61
|
+
return (Array.isArray(value) ? (value as ShadowValue[]) : [value as ShadowValue])
|
|
62
|
+
.map(shadow)
|
|
63
|
+
.join(", ");
|
|
64
|
+
case "dimension":
|
|
65
|
+
return dim(value as string | number);
|
|
66
|
+
case "number":
|
|
67
|
+
case "fontWeight":
|
|
68
|
+
case "color":
|
|
69
|
+
case "duration":
|
|
70
|
+
return String(value);
|
|
71
|
+
default:
|
|
72
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The right-hand side of a token's CSS declaration: `var(--alias)` or a literal. */
|
|
77
|
+
function declValue(token: ResolvedToken, prefix: string): string {
|
|
78
|
+
if (token.aliasOf) return `var(${cssVarName(token.aliasOf, prefix)})`;
|
|
79
|
+
return formatCssValue(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function block(
|
|
83
|
+
selector: string,
|
|
84
|
+
tokens: ResolvedToken[],
|
|
85
|
+
prefix: string,
|
|
86
|
+
indent = " ",
|
|
87
|
+
): string {
|
|
88
|
+
const lines = tokens.map(
|
|
89
|
+
(tok) => `${indent}${cssVarName(tok.path, prefix)}: ${declValue(tok, prefix)};`,
|
|
90
|
+
);
|
|
91
|
+
return `${selector} {\n${lines.join("\n")}\n}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Only the tokens whose emitted declaration differs from the base. */
|
|
95
|
+
function changed(
|
|
96
|
+
base: ResolvedToken[],
|
|
97
|
+
mode: ResolvedToken[],
|
|
98
|
+
prefix: string,
|
|
99
|
+
): ResolvedToken[] {
|
|
100
|
+
const baseDecl = new Map(base.map((t) => [t.path, declValue(t, prefix)]));
|
|
101
|
+
return mode.filter((t) => baseDecl.get(t.path) !== declValue(t, prefix));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Emit CSS custom properties for a resolved theme (base + mode scopes). */
|
|
105
|
+
export function emitCssFromResolved(
|
|
106
|
+
resolved: ResolvedTheme,
|
|
107
|
+
options: CssOptions = {},
|
|
108
|
+
): string {
|
|
109
|
+
const prefix = options.prefix ?? "";
|
|
110
|
+
const root = options.rootSelector ?? ":root";
|
|
111
|
+
const modeSelector = options.modeSelector ?? ((m) => `[data-theme="${m}"]`);
|
|
112
|
+
|
|
113
|
+
const blocks: string[] = [
|
|
114
|
+
`/* Generated by \`rastack tokens\` — do not edit by hand. */`,
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// Base + the default mode both live on :root, so an app with no attribute set
|
|
118
|
+
// still gets a complete, sensible theme.
|
|
119
|
+
const defaultMode = resolved.defaultMode;
|
|
120
|
+
const rootTokens =
|
|
121
|
+
defaultMode && resolved.modes[defaultMode]
|
|
122
|
+
? resolved.modes[defaultMode]
|
|
123
|
+
: resolved.base;
|
|
124
|
+
blocks.push(block(root, rootTokens, prefix));
|
|
125
|
+
|
|
126
|
+
for (const [name, tokens] of Object.entries(resolved.modes)) {
|
|
127
|
+
const diff = changed(resolved.base, tokens, prefix);
|
|
128
|
+
if (!diff.length) continue;
|
|
129
|
+
blocks.push(block(modeSelector(name), diff, prefix));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return blocks.join("\n\n") + "\n";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Emit CSS custom properties for a themed token document. */
|
|
136
|
+
export function emitCss(themed: ThemedTokens, options: CssOptions = {}): string {
|
|
137
|
+
return emitCssFromResolved(resolveTheme(themed), options);
|
|
138
|
+
}
|