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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineTheme` — the simple, non-designer entry point.
|
|
3
|
+
*
|
|
4
|
+
* State a handful of decisions in plain values — your brand colour, how round
|
|
5
|
+
* the corners are, which font — and get a **complete** design system back:
|
|
6
|
+
* colour scales, semantic tokens (background, surface, text, border, accent…),
|
|
7
|
+
* and both **light and dark** modes, with readable on-colours chosen for you.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { defineTheme } from "rastack/tokens";
|
|
11
|
+
*
|
|
12
|
+
* export default defineTheme({
|
|
13
|
+
* brand: "#4F6BFF", // one colour is enough — we build the 50–950 scale
|
|
14
|
+
* radius: "rounded", // "sharp" | "rounded" | "pill" | a px number
|
|
15
|
+
* font: "Inter",
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* It returns the same {@link ThemedTokens} that `defineTokens` produces, so it
|
|
20
|
+
* plugs straight into `rastack tokens`, `rastack design`, and `rastack/theme`.
|
|
21
|
+
* `defineTheme` is sugar over `defineTokens`; reach for the raw DSL only when you
|
|
22
|
+
* want per-token control. Everything is overridable — pass `colors`, `radius`,
|
|
23
|
+
* `space`, or `extend` to adjust or add anything.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readableOn, scale } from "./color";
|
|
27
|
+
import { defineTokens } from "./define";
|
|
28
|
+
import { mergeTokens } from "./resolve";
|
|
29
|
+
import { ThemedTokens, TokenDocument } from "./types";
|
|
30
|
+
|
|
31
|
+
export interface ThemeConfig {
|
|
32
|
+
/** Your main brand colour, as a hex string. The rest is derived from it. */
|
|
33
|
+
brand: string;
|
|
34
|
+
/** A secondary accent colour. Defaults to `brand`. */
|
|
35
|
+
accent?: string;
|
|
36
|
+
/** The neutral/grey seed used for backgrounds, surfaces, text and borders. */
|
|
37
|
+
neutral?: string;
|
|
38
|
+
/** Corner roundness: a keyword or a base radius in px. Default `"rounded"`. */
|
|
39
|
+
radius?: "sharp" | "rounded" | "pill" | number;
|
|
40
|
+
/** Body font family (name or stack). Default Inter/system-ui. */
|
|
41
|
+
font?: string | string[];
|
|
42
|
+
/** Which modes to generate. Default `"both"`. */
|
|
43
|
+
appearance?: "light" | "dark" | "both";
|
|
44
|
+
/** Status colours (sensible defaults provided). */
|
|
45
|
+
success?: string;
|
|
46
|
+
danger?: string;
|
|
47
|
+
warning?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Override or add any semantic colour, e.g. `{ accent: "#e11d48" }` or
|
|
50
|
+
* `{ "text-muted": "#888" }`. Applied on top of the generated palette.
|
|
51
|
+
*/
|
|
52
|
+
colors?: Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* Escape hatch: raw DTCG tokens deep-merged over everything (per mode via
|
|
55
|
+
* `{ modes: { dark: { … } } }`), for anything the simple config can't express.
|
|
56
|
+
*/
|
|
57
|
+
extend?: Partial<ThemedTokens>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const RADIUS_KEYWORDS = { sharp: 3, rounded: 10, pill: 18 } as const;
|
|
61
|
+
|
|
62
|
+
const DEFAULT_FONT = ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"];
|
|
63
|
+
|
|
64
|
+
/** Turn a plain intent config into a full themed token document. */
|
|
65
|
+
export function defineTheme(config: ThemeConfig): ThemedTokens {
|
|
66
|
+
const brand = scale(config.brand);
|
|
67
|
+
const accentSeed = config.accent ?? config.brand;
|
|
68
|
+
const accent = scale(accentSeed);
|
|
69
|
+
const neutral = scale(config.neutral ?? "#64748B");
|
|
70
|
+
const onAccent = readableOn(accentSeed);
|
|
71
|
+
|
|
72
|
+
const baseRadius =
|
|
73
|
+
typeof config.radius === "number"
|
|
74
|
+
? config.radius
|
|
75
|
+
: RADIUS_KEYWORDS[config.radius ?? "rounded"];
|
|
76
|
+
|
|
77
|
+
const colorScale = (name: string, s: Record<string, string>) =>
|
|
78
|
+
Object.fromEntries(Object.entries(s).map(([step, hex]) => [step, { $value: hex }]));
|
|
79
|
+
|
|
80
|
+
// --- base tree (also the light appearance) ------------------------------
|
|
81
|
+
const base: TokenDocument = {
|
|
82
|
+
color: {
|
|
83
|
+
$type: "color",
|
|
84
|
+
brand: colorScale("brand", brand),
|
|
85
|
+
...(config.accent ? { "accent-scale": colorScale("accent", accent) } : {}),
|
|
86
|
+
// Semantic tokens alias the scales, so overriding a scale flows through.
|
|
87
|
+
accent: { $value: config.accent ? "{color.accent-scale.500}" : "{color.brand.500}" },
|
|
88
|
+
"accent-strong": { $value: config.accent ? "{color.accent-scale.600}" : "{color.brand.600}" },
|
|
89
|
+
"on-accent": { $value: onAccent },
|
|
90
|
+
bg: { $value: "#FFFFFF" },
|
|
91
|
+
surface: { $value: "#FFFFFF" },
|
|
92
|
+
"surface-alt": { $value: neutral["100"] },
|
|
93
|
+
border: { $value: neutral["200"] },
|
|
94
|
+
text: { $value: neutral["900"] },
|
|
95
|
+
"text-muted": { $value: neutral["500"] },
|
|
96
|
+
success: { $value: config.success ?? "#1FA971" },
|
|
97
|
+
danger: { $value: config.danger ?? "#E5484D" },
|
|
98
|
+
warning: { $value: config.warning ?? "#F5A623" },
|
|
99
|
+
},
|
|
100
|
+
space: {
|
|
101
|
+
$type: "dimension",
|
|
102
|
+
xs: { $value: "4px" },
|
|
103
|
+
sm: { $value: "8px" },
|
|
104
|
+
md: { $value: "16px" },
|
|
105
|
+
lg: { $value: "24px" },
|
|
106
|
+
xl: { $value: "40px" },
|
|
107
|
+
},
|
|
108
|
+
radius: {
|
|
109
|
+
$type: "dimension",
|
|
110
|
+
sm: { $value: `${Math.round(baseRadius * 0.6)}px` },
|
|
111
|
+
md: { $value: `${baseRadius}px` },
|
|
112
|
+
lg: { $value: `${Math.round(baseRadius * 1.6)}px` },
|
|
113
|
+
xl: { $value: `${Math.round(baseRadius * 2.4)}px` },
|
|
114
|
+
pill: { $value: "999px" },
|
|
115
|
+
},
|
|
116
|
+
font: {
|
|
117
|
+
family: {
|
|
118
|
+
$type: "fontFamily",
|
|
119
|
+
sans: { $value: config.font ?? DEFAULT_FONT },
|
|
120
|
+
},
|
|
121
|
+
weight: {
|
|
122
|
+
$type: "fontWeight",
|
|
123
|
+
regular: { $value: 400 },
|
|
124
|
+
medium: { $value: 500 },
|
|
125
|
+
bold: { $value: 700 },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Apply user colour overrides onto the semantic layer.
|
|
131
|
+
if (config.colors) {
|
|
132
|
+
for (const [name, hex] of Object.entries(config.colors)) {
|
|
133
|
+
(base.color as any)[name] = { $value: hex };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- dark appearance: only the tokens that differ ------------------------
|
|
138
|
+
const dark: TokenDocument = {
|
|
139
|
+
color: {
|
|
140
|
+
// In dark, the accent reads better a step lighter.
|
|
141
|
+
accent: { $value: config.accent ? "{color.accent-scale.400}" : "{color.brand.400}" },
|
|
142
|
+
"accent-strong": { $value: config.accent ? "{color.accent-scale.300}" : "{color.brand.300}" },
|
|
143
|
+
bg: { $value: neutral["950"] },
|
|
144
|
+
surface: { $value: neutral["900"] },
|
|
145
|
+
"surface-alt": { $value: neutral["800"] },
|
|
146
|
+
border: { $value: neutral["700"] },
|
|
147
|
+
text: { $value: neutral["50"] },
|
|
148
|
+
"text-muted": { $value: neutral["400"] },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const appearance = config.appearance ?? "both";
|
|
153
|
+
const modes: Record<string, TokenDocument> = {};
|
|
154
|
+
if (appearance !== "light") modes.dark = dark;
|
|
155
|
+
|
|
156
|
+
let themed: ThemedTokens = {
|
|
157
|
+
tokens: base,
|
|
158
|
+
modes,
|
|
159
|
+
defaultMode: appearance === "dark" ? "dark" : "light",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// dark-only: fold the dark overrides into the base so :root is dark.
|
|
163
|
+
if (appearance === "dark") {
|
|
164
|
+
themed = { tokens: mergeTokens(base, dark), modes: {}, defaultMode: "dark" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Escape hatch merges last.
|
|
168
|
+
if (config.extend) {
|
|
169
|
+
if (config.extend.tokens) themed.tokens = mergeTokens(themed.tokens, config.extend.tokens);
|
|
170
|
+
if (config.extend.modes) {
|
|
171
|
+
for (const [name, doc] of Object.entries(config.extend.modes)) {
|
|
172
|
+
themed.modes = themed.modes ?? {};
|
|
173
|
+
themed.modes[name] = themed.modes[name] ? mergeTokens(themed.modes[name], doc) : doc;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Normalise through defineTokens so the result is identical in shape.
|
|
179
|
+
return defineTokens(themed);
|
|
180
|
+
}
|
package/src/tokens/ts.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed-theme emitter — the ergonomic, autocompleted output.
|
|
3
|
+
*
|
|
4
|
+
* Emits a `.ts` module exporting a nested `theme` object that mirrors the token
|
|
5
|
+
* tree, whose leaves are `var(--…)` strings, plus a `TokenPath` union of every
|
|
6
|
+
* token name. Application code reads `theme.color.brand[500]` (a real CSS
|
|
7
|
+
* variable reference) with full type-safety and rename-refactoring, while the
|
|
8
|
+
* actual values live in the emitted CSS and stay swappable per mode.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cssVarName } from "./resolve";
|
|
12
|
+
import { ResolvedToken } from "./types";
|
|
13
|
+
|
|
14
|
+
/** A key that is a safe JS identifier can be written bare; otherwise quote it. */
|
|
15
|
+
function keyLiteral(key: string): string {
|
|
16
|
+
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build a nested plain object from dotted paths → `var(--…)` leaf strings. */
|
|
20
|
+
function nest(tokens: ResolvedToken[], prefix: string): Record<string, any> {
|
|
21
|
+
const root: Record<string, any> = {};
|
|
22
|
+
for (const tok of tokens) {
|
|
23
|
+
const segments = tok.path.split(".");
|
|
24
|
+
let node = root;
|
|
25
|
+
segments.forEach((seg, i) => {
|
|
26
|
+
if (i === segments.length - 1) {
|
|
27
|
+
node[seg] = `var(${cssVarName(tok.path, prefix)})`;
|
|
28
|
+
} else {
|
|
29
|
+
node[seg] = node[seg] ?? {};
|
|
30
|
+
node = node[seg];
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serialize(node: Record<string, any>, indent: string): string {
|
|
38
|
+
const inner = indent + " ";
|
|
39
|
+
const entries = Object.entries(node).map(([key, val]) => {
|
|
40
|
+
const k = keyLiteral(key);
|
|
41
|
+
if (typeof val === "string") return `${inner}${k}: ${JSON.stringify(val)},`;
|
|
42
|
+
return `${inner}${k}: ${serialize(val, inner)},`;
|
|
43
|
+
});
|
|
44
|
+
return `{\n${entries.join("\n")}\n${indent}}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TsOptions {
|
|
48
|
+
prefix?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Emit the typed-theme `.ts` source for a resolved base token list. */
|
|
52
|
+
export function emitTs(tokens: ResolvedToken[], options: TsOptions = {}): string {
|
|
53
|
+
const prefix = options.prefix ?? "";
|
|
54
|
+
const tree = nest(tokens, prefix);
|
|
55
|
+
const union =
|
|
56
|
+
tokens.map((t) => ` | ${JSON.stringify(t.path)}`).join("\n") || " never";
|
|
57
|
+
|
|
58
|
+
return `/* Generated by \`rastack tokens\` — do not edit by hand. */
|
|
59
|
+
|
|
60
|
+
/** Every design-token path, for type-safe lookups. */
|
|
61
|
+
export type TokenPath =
|
|
62
|
+
${union};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The design system as a typed tree of CSS-variable references.
|
|
66
|
+
* \`theme.color.brand[500]\` → \`"var(--color-brand-500)"\`.
|
|
67
|
+
*/
|
|
68
|
+
export const theme = ${serialize(tree, "")} as const;
|
|
69
|
+
|
|
70
|
+
/** The CSS custom-property name for a token path. */
|
|
71
|
+
export function cssVar(path: TokenPath): string {
|
|
72
|
+
const body = path
|
|
73
|
+
.split(".")
|
|
74
|
+
.map((s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase())
|
|
75
|
+
.join("-")
|
|
76
|
+
.replace(/[^a-z0-9-]+/g, "-");
|
|
77
|
+
return ${JSON.stringify(prefix ? `--${prefix}-` : "--")} + body;
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
|
|
28
|
+
/** The DTCG `$type`s this framework understands. */
|
|
29
|
+
export type TokenType =
|
|
30
|
+
| "color"
|
|
31
|
+
| "dimension"
|
|
32
|
+
| "fontFamily"
|
|
33
|
+
| "fontWeight"
|
|
34
|
+
| "duration"
|
|
35
|
+
| "cubicBezier"
|
|
36
|
+
| "number"
|
|
37
|
+
| "shadow"
|
|
38
|
+
| "border"
|
|
39
|
+
| "typography"
|
|
40
|
+
| "transition";
|
|
41
|
+
|
|
42
|
+
/** A shadow token value (single shadow or a stack of them). */
|
|
43
|
+
export interface ShadowValue {
|
|
44
|
+
offsetX: string | number;
|
|
45
|
+
offsetY: string | number;
|
|
46
|
+
blur?: string | number;
|
|
47
|
+
spread?: string | number;
|
|
48
|
+
color: string;
|
|
49
|
+
inset?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Anything that can appear as a token `$value` (including aliases as strings). */
|
|
53
|
+
export type TokenValue =
|
|
54
|
+
| string
|
|
55
|
+
| number
|
|
56
|
+
| boolean
|
|
57
|
+
| ShadowValue
|
|
58
|
+
| ShadowValue[]
|
|
59
|
+
| string[]
|
|
60
|
+
| Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
/** A single design token — a group leaf. Identified by the presence of `$value`. */
|
|
63
|
+
export interface DesignToken {
|
|
64
|
+
$value: TokenValue;
|
|
65
|
+
$type?: TokenType;
|
|
66
|
+
$description?: string;
|
|
67
|
+
$extensions?: Record<string, unknown>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A group node: named children (tokens or nested groups) plus optional group
|
|
72
|
+
* metadata. `$type` set on a group is **inherited** by descendant tokens that
|
|
73
|
+
* don't declare their own — the standard DTCG type-inheritance rule.
|
|
74
|
+
*
|
|
75
|
+
* Typed loosely (`any` children) on purpose: the tree is arbitrary-depth data,
|
|
76
|
+
* mirroring how the codebase types OpenAPI documents.
|
|
77
|
+
*/
|
|
78
|
+
export interface TokenGroup {
|
|
79
|
+
$type?: TokenType;
|
|
80
|
+
$description?: string;
|
|
81
|
+
[child: string]: DesignToken | TokenGroup | TokenType | string | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The root of a token tree. */
|
|
85
|
+
export type TokenDocument = TokenGroup;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A themeable token document: a base tree plus named **modes** (e.g. `light`,
|
|
89
|
+
* `dark`) that override a subset of tokens. Modes compile to CSS selector
|
|
90
|
+
* scopes, so a running app flips theme by toggling one attribute.
|
|
91
|
+
*/
|
|
92
|
+
export interface ThemedTokens {
|
|
93
|
+
/** Base tokens — the values that apply in every mode. */
|
|
94
|
+
tokens: TokenDocument;
|
|
95
|
+
/** Per-mode overrides, deep-merged over the base. */
|
|
96
|
+
modes?: Record<string, TokenDocument>;
|
|
97
|
+
/** Which mode is the default (emitted onto `:root`). */
|
|
98
|
+
defaultMode?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A token after alias resolution and `$type` inheritance — ready to emit. */
|
|
102
|
+
export interface ResolvedToken {
|
|
103
|
+
/** Dotted path, e.g. `color.brand.500`. */
|
|
104
|
+
path: string;
|
|
105
|
+
/** The token's `$type` (inherited or inferred). */
|
|
106
|
+
type: TokenType | undefined;
|
|
107
|
+
/** The literal value with every alias followed to its source. */
|
|
108
|
+
value: TokenValue;
|
|
109
|
+
/**
|
|
110
|
+
* If this token is a direct alias of another, the target's dotted path.
|
|
111
|
+
* Emitters use it to preserve the reference (CSS `var(--target)`), so theming
|
|
112
|
+
* cascades instead of being flattened away.
|
|
113
|
+
*/
|
|
114
|
+
aliasOf?: string;
|
|
115
|
+
$description?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when a node is a token leaf (has `$value`) rather than a group. */
|
|
119
|
+
export function isToken(node: unknown): node is DesignToken {
|
|
120
|
+
return (
|
|
121
|
+
typeof node === "object" &&
|
|
122
|
+
node !== null &&
|
|
123
|
+
Object.prototype.hasOwnProperty.call(node, "$value")
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import {
|
|
4
|
+
defineTheme,
|
|
5
|
+
defineTokens,
|
|
6
|
+
emitCss,
|
|
7
|
+
emitTs,
|
|
8
|
+
cssVarName,
|
|
9
|
+
formatCssValue,
|
|
10
|
+
ref,
|
|
11
|
+
resolveTokens,
|
|
12
|
+
resolveTheme,
|
|
13
|
+
t,
|
|
14
|
+
ThemedTokens,
|
|
15
|
+
TokenDocument,
|
|
16
|
+
} from "../src/tokens";
|
|
17
|
+
import { compileTokens, loadTokens } from "../src/tokens/compile";
|
|
18
|
+
import { renderStudio } from "../src/tokens/studio";
|
|
19
|
+
|
|
20
|
+
const workRoot = join(__dirname, "tokens-fixtures");
|
|
21
|
+
|
|
22
|
+
function findTok<T extends { path: string }>(list: T[], path: string): T {
|
|
23
|
+
const found = list.find((x) => x.path === path);
|
|
24
|
+
if (!found) throw new Error(`missing token ${path}`);
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("design tokens — resolution", () => {
|
|
29
|
+
it("inherits a group $type down to its tokens", () => {
|
|
30
|
+
const doc: TokenDocument = {
|
|
31
|
+
color: { $type: "color", brand: { 500: { $value: "#4F6BFF" } } },
|
|
32
|
+
};
|
|
33
|
+
const resolved = resolveTokens(doc);
|
|
34
|
+
expect(findTok(resolved, "color.brand.500").type).toBe("color");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("resolves alias values transitively and records the immediate target", () => {
|
|
38
|
+
const doc: TokenDocument = {
|
|
39
|
+
color: {
|
|
40
|
+
$type: "color",
|
|
41
|
+
brand: { 500: { $value: "#4F6BFF" } },
|
|
42
|
+
accent: { $value: ref("color.brand.500") },
|
|
43
|
+
cta: { $value: ref("color.accent") },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const resolved = resolveTokens(doc);
|
|
47
|
+
const accent = findTok(resolved, "color.accent");
|
|
48
|
+
const cta = findTok(resolved, "color.cta");
|
|
49
|
+
expect(accent.value).toBe("#4F6BFF");
|
|
50
|
+
expect(accent.aliasOf).toBe("color.brand.500");
|
|
51
|
+
// transitive: cta -> accent -> brand.500
|
|
52
|
+
expect(cta.value).toBe("#4F6BFF");
|
|
53
|
+
expect(cta.aliasOf).toBe("color.accent");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("throws on a dangling alias", () => {
|
|
57
|
+
const doc: TokenDocument = { color: { a: { $type: "color", $value: ref("color.missing") } } };
|
|
58
|
+
expect(() => resolveTokens(doc)).toThrow(/unknown token "color.missing"/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws on an alias cycle", () => {
|
|
62
|
+
const doc: TokenDocument = {
|
|
63
|
+
a: { $type: "color", $value: ref("b") },
|
|
64
|
+
b: { $type: "color", $value: ref("a") },
|
|
65
|
+
};
|
|
66
|
+
expect(() => resolveTokens(doc)).toThrow(/cycle/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("names CSS variables from dotted paths (kebab, camel-split, prefix)", () => {
|
|
70
|
+
expect(cssVarName("color.brand.500")).toBe("--color-brand-500");
|
|
71
|
+
expect(cssVarName("color.surfaceAlt")).toBe("--color-surface-alt");
|
|
72
|
+
expect(cssVarName("color.accent", "rs")).toBe("--rs-color-accent");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("design tokens — DSL builders", () => {
|
|
77
|
+
it("t.dimension coerces bare numbers to px; t.duration to ms", () => {
|
|
78
|
+
const doc = defineTokens({
|
|
79
|
+
space: { md: t.dimension(16) },
|
|
80
|
+
motion: { fast: t.duration(150) },
|
|
81
|
+
}).tokens;
|
|
82
|
+
const resolved = resolveTokens(doc);
|
|
83
|
+
expect(findTok(resolved, "space.md").value).toBe("16px");
|
|
84
|
+
expect(findTok(resolved, "motion.fast").value).toBe("150ms");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("defineTokens normalises a bare tree and a themed shape alike", () => {
|
|
88
|
+
const bare = defineTokens({ color: { bg: t.color("#fff") } });
|
|
89
|
+
expect(bare.tokens.color).toBeDefined();
|
|
90
|
+
expect(bare.modes).toEqual({});
|
|
91
|
+
|
|
92
|
+
const themed = defineTokens({ tokens: { color: { bg: t.color("#fff") } }, defaultMode: "light" });
|
|
93
|
+
expect(themed.defaultMode).toBe("light");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("design tokens — CSS emission", () => {
|
|
98
|
+
const themed: ThemedTokens = {
|
|
99
|
+
tokens: {
|
|
100
|
+
color: {
|
|
101
|
+
$type: "color",
|
|
102
|
+
brand: { 500: { $value: "#4F6BFF" } },
|
|
103
|
+
accent: { $value: ref("color.brand.500") },
|
|
104
|
+
bg: { $value: "#FFFFFF" },
|
|
105
|
+
},
|
|
106
|
+
space: { $type: "dimension", md: { $value: "16px" } },
|
|
107
|
+
font: { family: { $type: "fontFamily", sans: { $value: ["Inter", "sans-serif"] } } },
|
|
108
|
+
shadow: { $type: "shadow", sm: { $value: { offsetX: "0", offsetY: "1px", blur: "2px", color: "#0001" } } },
|
|
109
|
+
},
|
|
110
|
+
modes: { dark: { color: { bg: { $value: "#06080F" } } } },
|
|
111
|
+
defaultMode: "light",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const css = emitCss(themed);
|
|
115
|
+
|
|
116
|
+
it("emits :root custom properties", () => {
|
|
117
|
+
expect(css).toMatch(/:root\s*\{/);
|
|
118
|
+
expect(css).toContain("--color-brand-500: #4F6BFF;");
|
|
119
|
+
expect(css).toContain("--space-md: 16px;");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("preserves aliases as var() references so themes cascade", () => {
|
|
123
|
+
expect(css).toContain("--color-accent: var(--color-brand-500);");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("quotes font families with spaces and joins the stack", () => {
|
|
127
|
+
expect(css).toContain('--font-family-sans: Inter, sans-serif;');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("renders shadows as box-shadow strings", () => {
|
|
131
|
+
expect(css).toContain("--shadow-sm: 0 1px 2px #0001;");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("scopes a mode override to its selector and omits unchanged tokens", () => {
|
|
135
|
+
expect(css).toContain('[data-theme="dark"]');
|
|
136
|
+
// only bg changed in dark
|
|
137
|
+
const darkBlock = css.slice(css.indexOf('[data-theme="dark"]'));
|
|
138
|
+
expect(darkBlock).toContain("--color-bg: #06080F;");
|
|
139
|
+
expect(darkBlock).not.toContain("--color-brand-500");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("respects a variable prefix", () => {
|
|
143
|
+
expect(emitCss(themed, { prefix: "rs" })).toContain("--rs-color-brand-500:");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("design tokens — typed TS emission", () => {
|
|
148
|
+
const base = resolveTokens({
|
|
149
|
+
color: { $type: "color", brand: { 500: { $value: "#4F6BFF" } }, surfaceAlt: { $value: "#eee" } },
|
|
150
|
+
});
|
|
151
|
+
const source = emitTs(base);
|
|
152
|
+
|
|
153
|
+
it("emits a nested theme tree of var() references", () => {
|
|
154
|
+
expect(source).toContain('"500": "var(--color-brand-500)"');
|
|
155
|
+
expect(source).toContain('surfaceAlt: "var(--color-surface-alt)"');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("emits a TokenPath union of every token", () => {
|
|
159
|
+
expect(source).toContain('| "color.brand.500"');
|
|
160
|
+
expect(source).toContain('| "color.surfaceAlt"');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("design tokens — compile to disk", () => {
|
|
165
|
+
const outDir = join(workRoot, "out");
|
|
166
|
+
const jsonSrc = join(workRoot, "sample.tokens.json");
|
|
167
|
+
|
|
168
|
+
beforeAll(() => {
|
|
169
|
+
mkdirSync(workRoot, { recursive: true });
|
|
170
|
+
writeFileSync(
|
|
171
|
+
jsonSrc,
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
tokens: { color: { $type: "color", accent: { $value: "#4F6BFF" } } },
|
|
174
|
+
modes: { dark: { color: { accent: { $value: "#8BA4FF" } } } },
|
|
175
|
+
defaultMode: "light",
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
afterAll(() => rmSync(workRoot, { recursive: true, force: true }));
|
|
180
|
+
|
|
181
|
+
it("writes tokens.rastack.json, tokens.css and tokens.ts", () => {
|
|
182
|
+
const { files } = compileTokens(jsonSrc, outDir);
|
|
183
|
+
expect(files.map((f) => f.split("/").pop())).toEqual([
|
|
184
|
+
"tokens.rastack.json",
|
|
185
|
+
"tokens.css",
|
|
186
|
+
"tokens.ts",
|
|
187
|
+
]);
|
|
188
|
+
for (const f of files) expect(existsSync(f)).toBe(true);
|
|
189
|
+
expect(readFileSync(join(outDir, "tokens.css"), "utf8")).toContain("--color-accent: #4F6BFF;");
|
|
190
|
+
expect(readFileSync(join(outDir, "tokens.ts"), "utf8")).toContain('| "color.accent"');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("loadTokens parses a bare DTCG tree as well as a themed doc", () => {
|
|
194
|
+
const bareSrc = join(workRoot, "bare.tokens.json");
|
|
195
|
+
writeFileSync(bareSrc, JSON.stringify({ color: { $type: "color", bg: { $value: "#fff" } } }));
|
|
196
|
+
const themed = loadTokens(bareSrc);
|
|
197
|
+
expect(themed.tokens.color).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("design tokens — studio page", () => {
|
|
202
|
+
const themed = defineTokens({
|
|
203
|
+
tokens: { color: { brand: { 500: t.color("#4F6BFF") }, accent: t.color(ref("color.brand.500")) } },
|
|
204
|
+
modes: { dark: { color: { brand: { 500: t.color("#8BA4FF") } } } },
|
|
205
|
+
defaultMode: "light",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("renders a self-contained HTML page embedding the tokens + CSS", () => {
|
|
209
|
+
const html = renderStudio(themed, { editable: true });
|
|
210
|
+
expect(html).toMatch(/^<!doctype html>/);
|
|
211
|
+
expect(html).toContain("--color-brand-500: #4F6BFF;");
|
|
212
|
+
expect(html).toContain('id="studio-data"');
|
|
213
|
+
// the embedded payload carries the layers + raw doc for the editor
|
|
214
|
+
expect(html).toContain('"color.accent"');
|
|
215
|
+
expect(html).toContain('"editable":true');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("design tokens — defineTheme() intent layer", () => {
|
|
220
|
+
it("derives a full 50–950 brand scale from one colour, with 500 = the seed", () => {
|
|
221
|
+
const resolved = resolveTheme(defineTheme({ brand: "#4F6BFF" }));
|
|
222
|
+
expect(findTok(resolved.base, "color.brand.500").value).toBe("#4F6BFF");
|
|
223
|
+
for (const step of ["50", "100", "200", "300", "400", "600", "700", "800", "900", "950"]) {
|
|
224
|
+
expect(findTok(resolved.base, `color.brand.${step}`).value).toMatch(/^#[0-9A-F]{6}$/);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("generates semantic tokens plus a dark mode by default", () => {
|
|
229
|
+
const themed = defineTheme({ brand: "#4F6BFF" });
|
|
230
|
+
expect(Object.keys(themed.modes ?? {})).toEqual(["dark"]);
|
|
231
|
+
const resolved = resolveTheme(themed);
|
|
232
|
+
for (const name of ["bg", "surface", "border", "text", "text-muted", "accent", "on-accent"]) {
|
|
233
|
+
expect(findTok(resolved.base, `color.${name}`)).toBeDefined();
|
|
234
|
+
}
|
|
235
|
+
// The accent is an alias of the brand ramp, and reads lighter in dark.
|
|
236
|
+
expect(findTok(resolved.base, "color.accent").aliasOf).toBe("color.brand.500");
|
|
237
|
+
expect(findTok(resolved.modes.dark, "color.accent").aliasOf).toBe("color.brand.400");
|
|
238
|
+
// dark bg is dark, light bg is white
|
|
239
|
+
expect(findTok(resolved.base, "color.bg").value).toBe("#FFFFFF");
|
|
240
|
+
expect(findTok(resolved.modes.dark, "color.bg").value).not.toBe("#FFFFFF");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("picks a readable on-accent colour by contrast", () => {
|
|
244
|
+
expect(findTok(resolveTheme(defineTheme({ brand: "#FACC15" })).base, "color.on-accent").value).toBe("#0B1020");
|
|
245
|
+
expect(findTok(resolveTheme(defineTheme({ brand: "#1D4ED8" })).base, "color.on-accent").value).toBe("#FFFFFF");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("maps radius keywords and honours a numeric radius", () => {
|
|
249
|
+
expect(findTok(resolveTheme(defineTheme({ brand: "#4F6BFF", radius: "sharp" })).base, "color.brand.500")).toBeDefined();
|
|
250
|
+
const r = resolveTheme(defineTheme({ brand: "#4F6BFF", radius: 20 }));
|
|
251
|
+
expect(findTok(r.base, "radius.md").value).toBe("20px");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("lowers to the same ThemedTokens shape and compiles to valid CSS", () => {
|
|
255
|
+
const css = emitCss(defineTheme({ brand: "#4F6BFF", font: "Poppins" }));
|
|
256
|
+
expect(css).toContain("--color-brand-500: #4F6BFF;");
|
|
257
|
+
expect(css).toContain("--color-accent: var(--color-brand-500);");
|
|
258
|
+
expect(css).toContain('[data-theme="dark"]');
|
|
259
|
+
expect(css).toContain("--font-family-sans: Poppins;");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("applies colour overrides and the extend escape hatch", () => {
|
|
263
|
+
const themed = defineTheme({
|
|
264
|
+
brand: "#4F6BFF",
|
|
265
|
+
colors: { success: "#00A86B" },
|
|
266
|
+
extend: { tokens: { z: { index: { modal: { $type: "number", $value: 1000 } } } } },
|
|
267
|
+
});
|
|
268
|
+
const base = resolveTheme(themed).base;
|
|
269
|
+
expect(findTok(base, "color.success").value).toBe("#00A86B");
|
|
270
|
+
expect(findTok(base, "z.index.modal").value).toBe(1000);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("appearance:'light' emits no dark mode; 'dark' makes :root dark", () => {
|
|
274
|
+
expect(Object.keys(defineTheme({ brand: "#4F6BFF", appearance: "light" }).modes ?? {})).toEqual([]);
|
|
275
|
+
const dark = defineTheme({ brand: "#4F6BFF", appearance: "dark" });
|
|
276
|
+
expect(dark.defaultMode).toBe("dark");
|
|
277
|
+
expect(findTok(resolveTheme(dark).base, "color.bg").value).not.toBe("#FFFFFF");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("design tokens — the repo's own theme.tokens.ts + .json agree", () => {
|
|
282
|
+
it("the TS authoring source resolves and matches the JSON source", () => {
|
|
283
|
+
// Executed by ts-jest, importing the package by its subpath name.
|
|
284
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
285
|
+
const tsThemed = require("../../tokens/theme.tokens").default as ThemedTokens;
|
|
286
|
+
const jsonThemed = loadTokens(join(__dirname, "..", "..", "tokens", "theme.tokens.json"));
|
|
287
|
+
|
|
288
|
+
const fromTs = resolveTheme(tsThemed);
|
|
289
|
+
const fromJson = resolveTheme(jsonThemed);
|
|
290
|
+
|
|
291
|
+
// Same set of token paths and the same emitted CSS values (composite types
|
|
292
|
+
// like shadows may be authored with numbers in TS and strings in JSON, but
|
|
293
|
+
// must render identically).
|
|
294
|
+
const map = (r: ReturnType<typeof resolveTheme>) =>
|
|
295
|
+
Object.fromEntries(r.base.map((tok) => [tok.path, formatCssValue(tok)]));
|
|
296
|
+
expect(map(fromTs)).toEqual(map(fromJson));
|
|
297
|
+
|
|
298
|
+
// Alias cascades through the dark mode (accent follows brand.500).
|
|
299
|
+
const darkAccent = findTok(fromTs.modes.dark, "color.accent");
|
|
300
|
+
expect(darkAccent.value).toBe("#8BA4FF");
|
|
301
|
+
});
|
|
302
|
+
});
|