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,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
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design studio page — a self-contained, dependency-free HTML app that
|
|
3
|
+
* `rastack design` serves. It **showcases** the design system (colour swatches,
|
|
4
|
+
* type ramp, spacing, radii, shadows, and live component previews built from the
|
|
5
|
+
* tokens themselves) and lets you **edit** token values in place — with a live
|
|
6
|
+
* preview — then save them back to the source, or export the DTCG JSON / CSS.
|
|
7
|
+
*
|
|
8
|
+
* Pure: given a themed doc it returns an HTML string, so it renders identically
|
|
9
|
+
* whether served by the CLI or snapshotted in a test. The page talks to the CLI
|
|
10
|
+
* server over two tiny endpoints: `POST /save` (persist) and it embeds
|
|
11
|
+
* everything else it needs inline.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { emitCss, formatCssValue } from "./css";
|
|
15
|
+
import { cssVarName, resolveTheme } from "./resolve";
|
|
16
|
+
import { ResolvedToken, ThemedTokens } from "./types";
|
|
17
|
+
|
|
18
|
+
/** A per-token descriptor handed to the browser for rendering + editing. */
|
|
19
|
+
interface TokenView {
|
|
20
|
+
path: string;
|
|
21
|
+
cssVar: string;
|
|
22
|
+
type: string | undefined;
|
|
23
|
+
/** CSS declaration value for preview: `var(--alias)` or a literal. */
|
|
24
|
+
decl: string;
|
|
25
|
+
/** The formatted literal value (what a swatch/sample shows). */
|
|
26
|
+
literal: string;
|
|
27
|
+
aliasOf?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toView(prefix: string, tok: ResolvedToken): TokenView {
|
|
32
|
+
return {
|
|
33
|
+
path: tok.path,
|
|
34
|
+
cssVar: cssVarName(tok.path, prefix),
|
|
35
|
+
type: tok.type,
|
|
36
|
+
decl: tok.aliasOf ? `var(${cssVarName(tok.aliasOf, prefix)})` : formatCssValue(tok),
|
|
37
|
+
literal: formatCssValue(tok),
|
|
38
|
+
aliasOf: tok.aliasOf,
|
|
39
|
+
description: tok.$description,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StudioOptions {
|
|
44
|
+
prefix?: string;
|
|
45
|
+
/** Whether the running server accepts `POST /save` (false for module sources). */
|
|
46
|
+
editable?: boolean;
|
|
47
|
+
title?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render the full design-studio HTML document for a themed token set. */
|
|
51
|
+
export function renderStudio(themed: ThemedTokens, options: StudioOptions = {}): string {
|
|
52
|
+
const prefix = options.prefix ?? "";
|
|
53
|
+
const editable = options.editable !== false;
|
|
54
|
+
const title = options.title ?? "Design System";
|
|
55
|
+
const resolved = resolveTheme(themed);
|
|
56
|
+
|
|
57
|
+
const layers: Record<string, TokenView[]> = {
|
|
58
|
+
base: resolved.base.map((t) => toView(prefix, t)),
|
|
59
|
+
};
|
|
60
|
+
for (const [name, toks] of Object.entries(resolved.modes)) {
|
|
61
|
+
layers[name] = toks.map((t) => toView(prefix, t));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const modeNames = Object.keys(resolved.modes);
|
|
65
|
+
const css = emitCss(themed, { prefix });
|
|
66
|
+
|
|
67
|
+
const payload = {
|
|
68
|
+
title,
|
|
69
|
+
prefix,
|
|
70
|
+
editable,
|
|
71
|
+
defaultMode: resolved.defaultMode ?? null,
|
|
72
|
+
modeNames,
|
|
73
|
+
layers,
|
|
74
|
+
raw: themed,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return page(title, css, payload);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function page(title: string, css: string, payload: unknown): string {
|
|
81
|
+
const data = JSON.stringify(payload).replace(/</g, "\\u003c");
|
|
82
|
+
return `<!doctype html>
|
|
83
|
+
<html lang="en" data-theme="${(payload as any).defaultMode ?? ""}">
|
|
84
|
+
<head>
|
|
85
|
+
<meta charset="utf-8" />
|
|
86
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
87
|
+
<title>${escapeHtml(title)} · rastack design</title>
|
|
88
|
+
<style id="tokens-css">${css}</style>
|
|
89
|
+
<style>${STUDIO_CSS}</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<div id="app"></div>
|
|
93
|
+
<script id="studio-data" type="application/json">${data}</script>
|
|
94
|
+
<script>${STUDIO_JS}</script>
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s: string): string {
|
|
101
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] as string));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* -- The studio's own chrome. Deliberately neutral so it frames, not fights,
|
|
105
|
+
the design system it is displaying. --------------------------------- */
|
|
106
|
+
const STUDIO_CSS = `
|
|
107
|
+
* { box-sizing: border-box; }
|
|
108
|
+
body { margin: 0; font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
|
109
|
+
background: #0d1117; color: #e6edf3; -webkit-font-smoothing: antialiased; }
|
|
110
|
+
.studio-header { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 16px;
|
|
111
|
+
padding: 14px 24px; background: #161b22ee; backdrop-filter: blur(8px); border-bottom: 1px solid #30363d; }
|
|
112
|
+
.studio-header h1 { font-size: 15px; font-weight: 650; margin: 0; letter-spacing: .2px; }
|
|
113
|
+
.studio-header .spacer { flex: 1; }
|
|
114
|
+
.pill { font: inherit; font-size: 12px; padding: 6px 12px; border-radius: 999px; border: 1px solid #30363d;
|
|
115
|
+
background: #21262d; color: #e6edf3; cursor: pointer; }
|
|
116
|
+
.pill.active { background: #388bfd33; border-color: #388bfd; color: #cae3ff; }
|
|
117
|
+
.pill:hover { border-color: #58a6ff; }
|
|
118
|
+
.btn { font: inherit; font-size: 12px; font-weight: 600; padding: 6px 14px; border-radius: 6px; border: 1px solid #30363d;
|
|
119
|
+
background: #21262d; color: #e6edf3; cursor: pointer; }
|
|
120
|
+
.btn.primary { background: #238636; border-color: #2ea043; }
|
|
121
|
+
.btn:disabled { opacity: .5; cursor: default; }
|
|
122
|
+
.layout { max-width: 1180px; margin: 0 auto; padding: 28px 24px 96px; }
|
|
123
|
+
section { margin: 40px 0 0; }
|
|
124
|
+
section > h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .12em; color: #8b949e; margin: 0 0 16px;
|
|
125
|
+
padding-bottom: 8px; border-bottom: 1px solid #21262d; }
|
|
126
|
+
.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); }
|
|
127
|
+
.card { background: #161b22; border: 1px solid #21262d; border-radius: 12px; padding: 14px; }
|
|
128
|
+
.swatch { height: 56px; border-radius: 8px; border: 1px solid #ffffff1a; margin-bottom: 10px; }
|
|
129
|
+
.tok-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; color: #7ee787; word-break: break-all; }
|
|
130
|
+
.tok-meta { font-size: 11px; color: #8b949e; margin-top: 2px; }
|
|
131
|
+
.tok-alias { color: #d2a8ff; }
|
|
132
|
+
.row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
|
133
|
+
.row input[type=text] { flex: 1; min-width: 0; font: inherit; font-family: ui-monospace, monospace; font-size: 12px;
|
|
134
|
+
padding: 6px 8px; border-radius: 6px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; }
|
|
135
|
+
.row input[type=color] { width: 34px; height: 30px; padding: 0; border: 1px solid #30363d; border-radius: 6px; background: none; }
|
|
136
|
+
.bar { height: 12px; max-width: 100%; background: var(--_v); border-radius: 4px; }
|
|
137
|
+
.radius-box { height: 56px; background: #388bfd33; border: 1px solid #58a6ff88; }
|
|
138
|
+
.shadow-box { height: 56px; background: #ffffff; border-radius: 8px; margin: 6px 4px 12px; }
|
|
139
|
+
.type-sample { font-size: 20px; margin: 6px 0; color: #e6edf3; }
|
|
140
|
+
.preview { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; }
|
|
141
|
+
.saved-toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: #238636;
|
|
142
|
+
padding: 10px 18px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .25s; pointer-events: none; }
|
|
143
|
+
.saved-toast.show { opacity: 1; }
|
|
144
|
+
.dim-note { font-size: 11px; color: #6e7681; margin-top: 16px; }
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
/* -- The studio client. Renders sections from the embedded payload, applies
|
|
148
|
+
edits live to :root, and posts the mutated doc back to the server. ---- */
|
|
149
|
+
const STUDIO_JS = `
|
|
150
|
+
(function () {
|
|
151
|
+
var data = JSON.parse(document.getElementById("studio-data").textContent);
|
|
152
|
+
var root = document.documentElement;
|
|
153
|
+
var activeLayer = data.defaultMode || "base";
|
|
154
|
+
var app = document.getElementById("app");
|
|
155
|
+
|
|
156
|
+
// Deep clone the raw doc so edits are collected without touching the payload.
|
|
157
|
+
var draft = JSON.parse(JSON.stringify(data.raw));
|
|
158
|
+
var dirty = false;
|
|
159
|
+
|
|
160
|
+
function tokenType(views, path) {
|
|
161
|
+
for (var i = 0; i < views.length; i++) if (views[i].path === path) return views[i].type;
|
|
162
|
+
}
|
|
163
|
+
function setPath(tree, path, value, type) {
|
|
164
|
+
var segs = path.split(".");
|
|
165
|
+
var node = tree;
|
|
166
|
+
for (var i = 0; i < segs.length - 1; i++) { node[segs[i]] = node[segs[i]] || {}; node = node[segs[i]]; }
|
|
167
|
+
var leaf = node[segs[segs.length - 1]] || {};
|
|
168
|
+
leaf.$value = value; if (type && !leaf.$type) leaf.$type = type;
|
|
169
|
+
node[segs[segs.length - 1]] = leaf;
|
|
170
|
+
}
|
|
171
|
+
function layerTree(layer) {
|
|
172
|
+
if (layer === "base") return draft.tokens;
|
|
173
|
+
draft.modes = draft.modes || {};
|
|
174
|
+
draft.modes[layer] = draft.modes[layer] || {};
|
|
175
|
+
return draft.modes[layer];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Apply a layer's declarations to :root so the preview reflects it exactly.
|
|
179
|
+
function applyLayer(layer) {
|
|
180
|
+
var views = data.layers[layer] || data.layers.base;
|
|
181
|
+
views.forEach(function (v) { root.style.setProperty(v.cssVar, v.decl); });
|
|
182
|
+
root.setAttribute("data-theme", layer === "base" ? "" : layer);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function onEdit(view, raw) {
|
|
186
|
+
root.style.setProperty(view.cssVar, raw);
|
|
187
|
+
setPath(layerTree(activeLayer), view.path, raw, view.type);
|
|
188
|
+
dirty = true;
|
|
189
|
+
var btn = document.getElementById("save-btn");
|
|
190
|
+
if (btn) btn.disabled = false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function group(views, pred) { return views.filter(pred); }
|
|
194
|
+
|
|
195
|
+
function sectionEl(title, nodes) {
|
|
196
|
+
if (!nodes.length) return null;
|
|
197
|
+
var s = document.createElement("section");
|
|
198
|
+
var h = document.createElement("h2"); h.textContent = title; s.appendChild(h);
|
|
199
|
+
var g = document.createElement("div"); g.className = "grid";
|
|
200
|
+
nodes.forEach(function (n) { g.appendChild(n); }); s.appendChild(g);
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function cardShell(view, preview) {
|
|
205
|
+
var c = document.createElement("div"); c.className = "card";
|
|
206
|
+
if (preview) c.appendChild(preview);
|
|
207
|
+
var name = document.createElement("div"); name.className = "tok-name"; name.textContent = view.path;
|
|
208
|
+
c.appendChild(name);
|
|
209
|
+
var meta = document.createElement("div"); meta.className = "tok-meta";
|
|
210
|
+
meta.innerHTML = view.aliasOf ? '→ <span class="tok-alias">' + view.aliasOf + "</span>" : escapeHtml(view.literal);
|
|
211
|
+
c.appendChild(meta);
|
|
212
|
+
if (view.description) { var d = document.createElement("div"); d.className = "tok-meta"; d.textContent = view.description; c.appendChild(d); }
|
|
213
|
+
return c;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function editorRow(view, isColor) {
|
|
217
|
+
if (!data.editable || view.aliasOf) return null;
|
|
218
|
+
var row = document.createElement("div"); row.className = "row";
|
|
219
|
+
var text = document.createElement("input"); text.type = "text"; text.value = view.literal;
|
|
220
|
+
if (isColor) {
|
|
221
|
+
var color = document.createElement("input"); color.type = "color";
|
|
222
|
+
try { color.value = toHex(view.literal); } catch (e) {}
|
|
223
|
+
color.oninput = function () { text.value = color.value; onEdit(view, color.value); };
|
|
224
|
+
row.appendChild(color);
|
|
225
|
+
}
|
|
226
|
+
text.oninput = function () { onEdit(view, text.value); };
|
|
227
|
+
row.appendChild(text);
|
|
228
|
+
return row;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function render() {
|
|
232
|
+
var views = data.layers[activeLayer] || data.layers.base;
|
|
233
|
+
app.innerHTML = "";
|
|
234
|
+
app.appendChild(header());
|
|
235
|
+
var wrap = document.createElement("div"); wrap.className = "layout";
|
|
236
|
+
|
|
237
|
+
var colors = group(views, function (v) { return v.type === "color"; }).map(function (v) {
|
|
238
|
+
var sw = document.createElement("div"); sw.className = "swatch"; sw.style.background = "var(" + v.cssVar + ")";
|
|
239
|
+
var c = cardShell(v, sw); var ed = editorRow(v, true); if (ed) c.appendChild(ed); return c;
|
|
240
|
+
});
|
|
241
|
+
add(wrap, sectionEl("Colours", colors));
|
|
242
|
+
|
|
243
|
+
var isRadius = function (v) { return /radius|radii|round/i.test(v.path); };
|
|
244
|
+
var dims = group(views, function (v) { return v.type === "dimension" && !isRadius(v); }).map(function (v) {
|
|
245
|
+
var bar = document.createElement("div"); bar.className = "bar"; bar.style.width = "var(" + v.cssVar + ")"; bar.style.setProperty("--_v", "var(" + v.cssVar + ")");
|
|
246
|
+
bar.style.background = "#58a6ff";
|
|
247
|
+
var c = cardShell(v, bar); var ed = editorRow(v, false); if (ed) c.appendChild(ed); return c;
|
|
248
|
+
});
|
|
249
|
+
add(wrap, sectionEl("Spacing & dimensions", dims));
|
|
250
|
+
|
|
251
|
+
var radii = group(views, function (v) { return isRadius(v) && v.type !== "color"; });
|
|
252
|
+
// radii tokens are dimensions too; show a rounded-box preview for the named ones
|
|
253
|
+
var radiiCards = radii.map(function (v) {
|
|
254
|
+
var box = document.createElement("div"); box.className = "radius-box"; box.style.borderRadius = "var(" + v.cssVar + ")";
|
|
255
|
+
return cardShell(v, box);
|
|
256
|
+
});
|
|
257
|
+
add(wrap, sectionEl("Radii", radiiCards));
|
|
258
|
+
|
|
259
|
+
var shadows = group(views, function (v) { return v.type === "shadow"; }).map(function (v) {
|
|
260
|
+
var box = document.createElement("div"); box.className = "shadow-box"; box.style.boxShadow = "var(" + v.cssVar + ")";
|
|
261
|
+
return cardShell(v, box);
|
|
262
|
+
});
|
|
263
|
+
add(wrap, sectionEl("Shadows", shadows));
|
|
264
|
+
|
|
265
|
+
var fonts = group(views, function (v) { return v.type === "fontFamily" || v.type === "fontWeight"; }).map(function (v) {
|
|
266
|
+
var s = document.createElement("div"); s.className = "type-sample";
|
|
267
|
+
if (v.type === "fontFamily") s.style.fontFamily = "var(" + v.cssVar + ")"; else s.style.fontWeight = "var(" + v.cssVar + ")";
|
|
268
|
+
s.textContent = "The quick brown fox";
|
|
269
|
+
var c = cardShell(v, s); var ed = editorRow(v, false); if (ed) c.appendChild(ed); return c;
|
|
270
|
+
});
|
|
271
|
+
add(wrap, sectionEl("Typography", fonts));
|
|
272
|
+
|
|
273
|
+
var known = { color: 1, dimension: 1, shadow: 1, fontFamily: 1, fontWeight: 1 };
|
|
274
|
+
var other = group(views, function (v) { return !known[v.type]; }).map(function (v) {
|
|
275
|
+
var c = cardShell(v, null); var ed = editorRow(v, false); if (ed) c.appendChild(ed); return c;
|
|
276
|
+
});
|
|
277
|
+
add(wrap, sectionEl("Other tokens", other));
|
|
278
|
+
|
|
279
|
+
add(wrap, componentPreview());
|
|
280
|
+
|
|
281
|
+
var note = document.createElement("div"); note.className = "dim-note";
|
|
282
|
+
note.textContent = data.editable
|
|
283
|
+
? "Edits apply live. Aliased tokens (→) follow their source; edit the source to change them. Save writes back to the token source."
|
|
284
|
+
: "Read-only: this source is a module, not JSON. Export to capture edits.";
|
|
285
|
+
wrap.appendChild(note);
|
|
286
|
+
app.appendChild(wrap);
|
|
287
|
+
applyLayer(activeLayer);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function componentPreview() {
|
|
291
|
+
var s = document.createElement("section");
|
|
292
|
+
var h = document.createElement("h2"); h.textContent = "Components"; s.appendChild(h);
|
|
293
|
+
var box = document.createElement("div"); box.className = "preview";
|
|
294
|
+
box.innerHTML =
|
|
295
|
+
'<button style="font:inherit;font-weight:600;padding:10px 18px;border:none;border-radius:var(--radius-md,8px);' +
|
|
296
|
+
'background:var(--color-accent,#4F6BFF);color:var(--color-on-accent,#fff);cursor:pointer">Primary action</button>' +
|
|
297
|
+
'<button style="font:inherit;font-weight:600;padding:10px 18px;border:1px solid var(--color-border,#30363d);' +
|
|
298
|
+
'border-radius:var(--radius-md,8px);background:transparent;color:var(--color-text,#e6edf3);cursor:pointer">Secondary</button>' +
|
|
299
|
+
'<div style="padding:16px;border-radius:var(--radius-lg,12px);background:var(--color-surface,#161b22);' +
|
|
300
|
+
'border:1px solid var(--color-border,#30363d);box-shadow:var(--shadow-md,none);min-width:200px">' +
|
|
301
|
+
'<div style="font-weight:650;color:var(--color-text,#e6edf3)">Card title</div>' +
|
|
302
|
+
'<div style="color:var(--color-text-muted,#8b949e);font-size:13px;margin-top:4px">Body copy in the muted text tone.</div></div>';
|
|
303
|
+
s.appendChild(box);
|
|
304
|
+
return s;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function header() {
|
|
308
|
+
var h = document.createElement("div"); h.className = "studio-header";
|
|
309
|
+
var title = document.createElement("h1"); title.textContent = data.title; h.appendChild(title);
|
|
310
|
+
var layers = ["base"].concat(data.modeNames);
|
|
311
|
+
layers.forEach(function (layer) {
|
|
312
|
+
var b = document.createElement("button"); b.className = "pill" + (layer === activeLayer ? " active" : "");
|
|
313
|
+
b.textContent = layer; b.onclick = function () { activeLayer = layer; render(); };
|
|
314
|
+
h.appendChild(b);
|
|
315
|
+
});
|
|
316
|
+
var sp = document.createElement("div"); sp.className = "spacer"; h.appendChild(sp);
|
|
317
|
+
var exp = document.createElement("button"); exp.className = "btn"; exp.textContent = "Export JSON";
|
|
318
|
+
exp.onclick = exportJson; h.appendChild(exp);
|
|
319
|
+
if (data.editable) {
|
|
320
|
+
var save = document.createElement("button"); save.className = "btn primary"; save.id = "save-btn";
|
|
321
|
+
save.textContent = "Save"; save.disabled = !dirty; save.onclick = saveDoc; h.appendChild(save);
|
|
322
|
+
}
|
|
323
|
+
return h;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function saveDoc() {
|
|
327
|
+
var btn = document.getElementById("save-btn"); btn.disabled = true; btn.textContent = "Saving…";
|
|
328
|
+
fetch("/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft) })
|
|
329
|
+
.then(function (r) { if (!r.ok) throw new Error(); return r.json(); })
|
|
330
|
+
.then(function () { dirty = false; btn.textContent = "Save"; toast("Saved & recompiled"); })
|
|
331
|
+
.catch(function () { btn.disabled = false; btn.textContent = "Save"; toast("Save failed"); });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function exportJson() {
|
|
335
|
+
var blob = new Blob([JSON.stringify(draft, null, 2)], { type: "application/json" });
|
|
336
|
+
var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "tokens.json"; a.click();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function toast(msg) {
|
|
340
|
+
var el = document.createElement("div"); el.className = "saved-toast show"; el.textContent = msg; document.body.appendChild(el);
|
|
341
|
+
setTimeout(function () { el.classList.remove("show"); setTimeout(function () { el.remove(); }, 300); }, 1600);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function add(parent, el) { if (el) parent.appendChild(el); }
|
|
345
|
+
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); }
|
|
346
|
+
function toHex(v) {
|
|
347
|
+
v = (v || "").trim();
|
|
348
|
+
if (/^#([0-9a-f]{6})$/i.test(v)) return v;
|
|
349
|
+
if (/^#([0-9a-f]{3})$/i.test(v)) return "#" + v.slice(1).split("").map(function (c) { return c + c; }).join("");
|
|
350
|
+
var m = v.match(/rgba?\\(([^)]+)\\)/i);
|
|
351
|
+
if (m) { var p = m[1].split(",").map(function (x) { return parseInt(x, 10); }); return "#" + p.slice(0, 3).map(function (n) { return ("0" + n.toString(16)).slice(-2); }).join(""); }
|
|
352
|
+
return "#000000";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
render();
|
|
356
|
+
})();
|
|
357
|
+
`;
|