rastack 0.0.24 → 0.0.26

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/rastack-design.d.ts +17 -0
  3. package/dist/rastack-design.js +134 -0
  4. package/dist/rastack-tokens.d.ts +15 -0
  5. package/dist/rastack-tokens.js +43 -0
  6. package/dist/rastack-wasm-build.d.ts +1 -1
  7. package/dist/rastack-wasm-build.js +3 -3
  8. package/dist/rastack.d.ts +2 -0
  9. package/dist/rastack.js +12 -0
  10. package/dist/tokens/color.d.ts +22 -0
  11. package/dist/tokens/color.js +70 -0
  12. package/dist/tokens/compile.d.ts +34 -0
  13. package/dist/tokens/compile.js +103 -0
  14. package/dist/tokens/css.d.ts +30 -0
  15. package/dist/tokens/css.js +102 -0
  16. package/dist/tokens/define.d.ts +71 -0
  17. package/dist/tokens/define.js +98 -0
  18. package/dist/tokens/index.d.ts +17 -0
  19. package/dist/tokens/index.js +33 -0
  20. package/dist/tokens/resolve.d.ts +40 -0
  21. package/dist/tokens/resolve.js +135 -0
  22. package/dist/tokens/studio.d.ts +21 -0
  23. package/dist/tokens/studio.js +328 -0
  24. package/dist/tokens/theme.d.ts +55 -0
  25. package/dist/tokens/theme.js +139 -0
  26. package/dist/tokens/ts.d.ts +15 -0
  27. package/dist/tokens/ts.js +73 -0
  28. package/dist/tokens/types.d.ts +92 -0
  29. package/dist/tokens/types.js +35 -0
  30. package/hooks/query/delete.ts +3 -1
  31. package/jest.config.cjs +6 -0
  32. package/package.json +3 -2
  33. package/src/rastack-design.ts +117 -0
  34. package/src/rastack-tokens.ts +46 -0
  35. package/src/rastack-wasm-build.ts +3 -3
  36. package/src/rastack.ts +12 -0
  37. package/src/tokens/color.ts +74 -0
  38. package/src/tokens/compile.ts +85 -0
  39. package/src/tokens/css.ts +138 -0
  40. package/src/tokens/define.ts +128 -0
  41. package/src/tokens/index.ts +18 -0
  42. package/src/tokens/resolve.ts +170 -0
  43. package/src/tokens/studio.ts +357 -0
  44. package/src/tokens/theme.ts +180 -0
  45. package/src/tokens/ts.ts +80 -0
  46. package/src/tokens/types.ts +125 -0
  47. package/test/tokens.spec.ts +302 -0
  48. package/theme/index.ts +9 -0
  49. package/theme/provider.tsx +157 -0
  50. package/tokens.ts +9 -0
  51. package/tsconfig.json +3 -0
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `rastack/tokens` — the TypeScript-first authoring DSL for design tokens.
3
+ *
4
+ * You author your design system in TypeScript; the compiler
5
+ * (`rastack tokens`) emits the canonical DTCG JSON plus ready-to-use CSS custom
6
+ * properties and a typed theme object. This mirrors the resource DSL
7
+ * (`rastack/define`): TypeScript is the single source of truth.
8
+ *
9
+ * ```ts
10
+ * import { defineTokens, t, ref } from "rastack/tokens";
11
+ *
12
+ * export default defineTokens({
13
+ * tokens: {
14
+ * color: {
15
+ * brand: { 500: t.color("#4F6BFF"), 600: t.color("#3B54E8") },
16
+ * text: t.color(ref("color.brand.500")), // alias
17
+ * },
18
+ * space: { sm: t.dimension(8), md: t.dimension(16) },
19
+ * radius: { md: t.dimension(10) },
20
+ * font: { sans: t.fontFamily(["Inter", "system-ui", "sans-serif"]) },
21
+ * },
22
+ * modes: {
23
+ * light: { color: { bg: t.color("#FFFFFF") } },
24
+ * dark: { color: { bg: t.color("#06080F") } },
25
+ * },
26
+ * defaultMode: "light",
27
+ * });
28
+ * ```
29
+ *
30
+ * The builders are thin: each returns a plain `{ $type, $value }` DTCG token, so
31
+ * a token tree authored with `t.*` is byte-identical to one hand-written as
32
+ * JSON. You can freely mix the two.
33
+ */
34
+
35
+ import {
36
+ DesignToken,
37
+ ShadowValue,
38
+ ThemedTokens,
39
+ TokenDocument,
40
+ TokenType,
41
+ } from "./types";
42
+
43
+ /** An alias reference to another token: `ref("color.brand.500")` → `"{color.brand.500}"`. */
44
+ export function ref(path: string): string {
45
+ return `{${path}}`;
46
+ }
47
+
48
+ /** True when a string is a DTCG alias (`"{a.b.c}"`). */
49
+ export function isAlias(value: unknown): value is string {
50
+ return typeof value === "string" && /^\{[^{}]+\}$/.test(value);
51
+ }
52
+
53
+ /** Strip the braces from an alias: `"{a.b}"` → `"a.b"`. */
54
+ export function aliasTarget(value: string): string {
55
+ return value.slice(1, -1);
56
+ }
57
+
58
+ function token<T extends TokenType>(
59
+ type: T,
60
+ value: DesignToken["$value"],
61
+ description?: string,
62
+ ): DesignToken {
63
+ const out: DesignToken = { $type: type, $value: value };
64
+ if (description) out.$description = description;
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Typed token builders. Each accepts a literal value or an alias produced by
70
+ * {@link ref}, plus an optional `$description`.
71
+ */
72
+ export const t = {
73
+ /** A colour: any CSS colour string (or an alias). */
74
+ color: (value: string, description?: string): DesignToken =>
75
+ token("color", value, description),
76
+
77
+ /**
78
+ * A dimension — spacing, sizing, radius. A bare `number` is treated as `px`
79
+ * (the common case); pass a string for other units (`"1rem"`, `"50%"`).
80
+ */
81
+ dimension: (value: string | number, description?: string): DesignToken =>
82
+ token("dimension", typeof value === "number" ? `${value}px` : value, description),
83
+
84
+ /** A font-family stack. */
85
+ fontFamily: (value: string | string[], description?: string): DesignToken =>
86
+ token("fontFamily", value, description),
87
+
88
+ /** A font weight (numeric `700` or keyword `"bold"`). */
89
+ fontWeight: (value: number | string, description?: string): DesignToken =>
90
+ token("fontWeight", value, description),
91
+
92
+ /** A duration — `"200ms"`, or a bare `number` of milliseconds. */
93
+ duration: (value: string | number, description?: string): DesignToken =>
94
+ token("duration", typeof value === "number" ? `${value}ms` : value, description),
95
+
96
+ /** A cubic-bezier easing curve: `[x1, y1, x2, y2]`. */
97
+ cubicBezier: (
98
+ value: [number, number, number, number],
99
+ description?: string,
100
+ ): DesignToken => token("cubicBezier", value as unknown as string[], description),
101
+
102
+ /** A raw number token (line-height, z-index, opacity, …). */
103
+ number: (value: number, description?: string): DesignToken =>
104
+ token("number", value, description),
105
+
106
+ /** A shadow (or a stack of shadows) → a CSS `box-shadow`. */
107
+ shadow: (value: ShadowValue | ShadowValue[], description?: string): DesignToken =>
108
+ token("shadow", value, description),
109
+ };
110
+
111
+ /**
112
+ * Author a themeable token set. Accepts either a bare token tree or the full
113
+ * `{ tokens, modes, defaultMode }` shape, and always normalises to
114
+ * {@link ThemedTokens}.
115
+ */
116
+ export function defineTokens(input: TokenDocument | ThemedTokens): ThemedTokens {
117
+ if (isThemed(input)) {
118
+ return { modes: {}, ...input };
119
+ }
120
+ return { tokens: input, modes: {} };
121
+ }
122
+
123
+ function isThemed(input: TokenDocument | ThemedTokens): input is ThemedTokens {
124
+ return (
125
+ typeof (input as ThemedTokens).tokens === "object" &&
126
+ (input as ThemedTokens).tokens !== null
127
+ );
128
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `rastack/tokens` core — the design-system framework.
3
+ *
4
+ * A TypeScript-first authoring DSL over the **W3C Design Tokens (DTCG)**
5
+ * standard, a resolver (alias + `$type` inheritance + cycle detection), and
6
+ * pure emitters (CSS custom properties, a typed theme). Everything here is
7
+ * filesystem- and React-free, so it builds for the browser and is what
8
+ * `rastack/theme` runs at runtime. The `rastack tokens` CLI adds file IO on top
9
+ * (see `tools/src/tokens/compile.ts`).
10
+ */
11
+
12
+ export * from "./types";
13
+ export * from "./define";
14
+ export * from "./theme";
15
+ export * from "./color";
16
+ export * from "./resolve";
17
+ export * from "./css";
18
+ export * from "./ts";
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Token resolution — the compiler's core pass.
3
+ *
4
+ * Given a DTCG token tree, produce a flat, ordered list of {@link ResolvedToken}
5
+ * with:
6
+ * - **`$type` inheritance** — a token inherits the nearest ancestor group's
7
+ * `$type` when it doesn't declare its own (a DTCG rule).
8
+ * - **alias resolution** — a `"{a.b.c}"` value is followed to its source,
9
+ * transitively, with cycle detection. The immediate alias target is kept
10
+ * (`aliasOf`) so emitters can preserve the reference.
11
+ * - **stable ordering** — depth-first in author order, so output diffs are
12
+ * minimal.
13
+ */
14
+
15
+ import { aliasTarget, isAlias } from "./define";
16
+ import {
17
+ DesignToken,
18
+ isToken,
19
+ ResolvedToken,
20
+ ThemedTokens,
21
+ TokenDocument,
22
+ TokenType,
23
+ TokenValue,
24
+ } from "./types";
25
+
26
+ /** A flat map from dotted path to the raw token, plus its inherited type. */
27
+ interface RawEntry {
28
+ path: string;
29
+ token: DesignToken;
30
+ /** Type after group inheritance but before alias type-borrowing. */
31
+ ownType: TokenType | undefined;
32
+ }
33
+
34
+ /** Metadata keys on a group that are not children. */
35
+ const META_KEYS = new Set(["$type", "$description", "$value", "$extensions"]);
36
+
37
+ /** Flatten a token tree to raw entries, applying group `$type` inheritance. */
38
+ function flatten(doc: TokenDocument): RawEntry[] {
39
+ const entries: RawEntry[] = [];
40
+
41
+ const walk = (node: any, prefix: string[], inheritedType: TokenType | undefined) => {
42
+ const groupType = (node?.$type as TokenType | undefined) ?? inheritedType;
43
+ for (const key of Object.keys(node)) {
44
+ if (META_KEYS.has(key)) continue;
45
+ const child = node[key];
46
+ const path = [...prefix, key];
47
+ if (isToken(child)) {
48
+ entries.push({
49
+ path: path.join("."),
50
+ token: child,
51
+ ownType: child.$type ?? groupType,
52
+ });
53
+ } else if (child && typeof child === "object") {
54
+ walk(child, path, groupType);
55
+ }
56
+ }
57
+ };
58
+
59
+ walk(doc, [], undefined);
60
+ return entries;
61
+ }
62
+
63
+ /**
64
+ * Resolve a token document into ordered, alias-free {@link ResolvedToken}s.
65
+ * Throws on a dangling alias or an alias cycle — the token equivalent of the
66
+ * resource compiler's dangling-FK / circular-dependency checks.
67
+ */
68
+ export function resolveTokens(doc: TokenDocument): ResolvedToken[] {
69
+ const raw = flatten(doc);
70
+ const byPath = new Map(raw.map((e) => [e.path, e]));
71
+
72
+ // Resolve one path to its literal value + effective type, following aliases.
73
+ const resolveValue = (
74
+ path: string,
75
+ seen: string[],
76
+ ): { value: TokenValue; type: TokenType | undefined } => {
77
+ const entry = byPath.get(path);
78
+ if (!entry) {
79
+ throw new Error(
80
+ `Token "${seen[seen.length - 1] ?? path}" aliases unknown token "${path}".`,
81
+ );
82
+ }
83
+ const value = entry.token.$value;
84
+ if (isAlias(value)) {
85
+ const target = aliasTarget(value);
86
+ if (seen.includes(target)) {
87
+ throw new Error(
88
+ `Alias cycle detected: ${[...seen, target].join(" → ")}.`,
89
+ );
90
+ }
91
+ const resolved = resolveValue(target, [...seen, target]);
92
+ // A pure alias borrows its target's type when it has none of its own.
93
+ return { value: resolved.value, type: entry.ownType ?? resolved.type };
94
+ }
95
+ return { value, type: entry.ownType };
96
+ };
97
+
98
+ return raw.map(({ path, token }) => {
99
+ const value = token.$value;
100
+ const resolved = resolveValue(path, [path]);
101
+ const out: ResolvedToken = {
102
+ path,
103
+ type: resolved.type,
104
+ value: resolved.value,
105
+ };
106
+ if (isAlias(value)) out.aliasOf = aliasTarget(value);
107
+ if (token.$description) out.$description = token.$description;
108
+ return out;
109
+ });
110
+ }
111
+
112
+ /** Deep-merge `override` onto `base`, returning a new document (base untouched). */
113
+ export function mergeTokens(
114
+ base: TokenDocument,
115
+ override: TokenDocument,
116
+ ): TokenDocument {
117
+ const out: any = Array.isArray(base) ? [...base] : { ...base };
118
+ for (const key of Object.keys(override)) {
119
+ const o = (override as any)[key];
120
+ const b = (out as any)[key];
121
+ if (
122
+ o &&
123
+ b &&
124
+ typeof o === "object" &&
125
+ typeof b === "object" &&
126
+ !isToken(o) &&
127
+ !isToken(b)
128
+ ) {
129
+ out[key] = mergeTokens(b, o);
130
+ } else {
131
+ out[key] = o;
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** The resolved base plus, per mode, the resolved base-merged-with-that-mode. */
138
+ export interface ResolvedTheme {
139
+ base: ResolvedToken[];
140
+ modes: Record<string, ResolvedToken[]>;
141
+ defaultMode?: string;
142
+ }
143
+
144
+ /**
145
+ * Resolve a themed token set: the base tree, and every mode as the base
146
+ * deep-merged with that mode's overrides. Callers diff a mode against the base
147
+ * to emit only what changed.
148
+ */
149
+ export function resolveTheme(themed: ThemedTokens): ResolvedTheme {
150
+ const base = resolveTokens(themed.tokens);
151
+ const modes: Record<string, ResolvedToken[]> = {};
152
+ for (const [name, overrides] of Object.entries(themed.modes ?? {})) {
153
+ modes[name] = resolveTokens(mergeTokens(themed.tokens, overrides));
154
+ }
155
+ return { base, modes, defaultMode: themed.defaultMode };
156
+ }
157
+
158
+ /**
159
+ * The CSS custom-property name for a token path.
160
+ * `color.brand.500` → `--color-brand-500`. Segments are lower-cased and any
161
+ * non-word character becomes `-`, so numeric and camelCase keys stay valid.
162
+ */
163
+ export function cssVarName(path: string, prefix = ""): string {
164
+ const body = path
165
+ .split(".")
166
+ .map((s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase())
167
+ .join("-")
168
+ .replace(/[^a-z0-9-]+/g, "-");
169
+ return `--${prefix ? `${prefix}-` : ""}${body}`;
170
+ }