Release Token Tools 0.2.0

This commit is contained in:
2026-09-02 12:38:34 +02:00
parent 8fd5210cb8
commit b46eb162d4
22 changed files with 777 additions and 109 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add DTCG-compatible token import/export, richer theme composition and
composite-value handling with platform-specific loss evidence.
## 0.1.0 - 2026-09-01
- Initial nested token editor with multi-set themes and ordered overrides.
+8 -1
View File
@@ -1,5 +1,12 @@
# Token Tools
Token Tools imports and exports bounded Design Tokens Community Group
`$type`/`$value` JSON, including aliases, inherited groups, ordered themes and
explicit modes. Alias references resolve recursively inside composite values
with missing-target and full cycle diagnostics. Color, dimension, duration,
font, Bézier, stroke, border, transition, gradient, typography and shadow
tokens can be inspected before loss-aware CSS, Sass, Android or Swift export.
Edit, validate and export design tokens locally in the browser. Token Tools supports plain W3C-style nested JSON and a local multi-set wrapper using `$sets` plus ordered `$themes`.
## Features
@@ -38,7 +45,7 @@ npm run test:browser
npm run package:release -- --force
```
The deterministic release is `release/token-tools-0.1.0.zip` with a SHA-256 sidecar. Node.js 22+ and npm 11 are required.
The deterministic release is `release/token-tools-0.2.0.zip` with a SHA-256 sidecar. Node.js 22+ and npm 11 are required.
## Licence
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity
- Project: Token Tools
- Version: 0.1.0
- Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/token-tools
- Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
- Artifact: `token-tools-0.1.0.zip`
- Artifact: `token-tools-0.2.0.zip`
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+11 -1
View File
@@ -1,5 +1,15 @@
# Architecture
The applied JSON document is separate from the editing draft, so malformed intermediate input never replaces the last valid state. `core/tokens.ts` extracts inherited nested tokens, composes ordered sets, resolves aliases through a guarded graph walk and validates resolved values. Export adapters consume only the selected theme's resolved records and append every unsupported conversion to a loss report.
The applied JSON document is separate from the editing draft, so malformed
intermediate input never replaces the last valid state. `core/tokens.ts`
extracts DTCG `$type`/`$value` tokens, composes ordered sets and explicit
theme/mode overlays, resolves aliases recursively inside composite values
through a guarded graph walk, and reports the complete cycle path. Supported
composites include typography, shadow, border, transition and gradient, plus
font, duration, Bézier and stroke primitives.
The DTCG export preserves alias expressions for the selected composition.
Platform adapters consume resolved records and append every unsupported
conversion to a loss report.
The `$sets`/`$themes` wrapper is local metadata; W3C-style export emits a plain nested `$type`/`$value` object. All Vite assets are relative for nested portal mounting, and release packaging fixes ordering, permissions and timestamps.
+20 -20
View File
@@ -1,22 +1,22 @@
{
"name": "token-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "token-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -67,13 +67,13 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "token-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Edit, validate and export design tokens locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add DTCG-compatible token import/export, richer theme composition and
composite-value handling with platform-specific loss evidence.
## 0.1.0 - 2026-09-01
- Initial nested token editor with multi-set themes and ordered overrides.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+8 -1
View File
@@ -1,5 +1,12 @@
# Token Tools
Token Tools imports and exports bounded Design Tokens Community Group
`$type`/`$value` JSON, including aliases, inherited groups, ordered themes and
explicit modes. Alias references resolve recursively inside composite values
with missing-target and full cycle diagnostics. Color, dimension, duration,
font, Bézier, stroke, border, transition, gradient, typography and shadow
tokens can be inspected before loss-aware CSS, Sass, Android or Swift export.
Edit, validate and export design tokens locally in the browser. Token Tools supports plain W3C-style nested JSON and a local multi-set wrapper using `$sets` plus ordered `$themes`.
## Features
@@ -38,7 +45,7 @@ npm run test:browser
npm run package:release -- --force
```
The deterministic release is `release/token-tools-0.1.0.zip` with a SHA-256 sidecar. Node.js 22+ and npm 11 are required.
The deterministic release is `release/token-tools-0.2.0.zip` with a SHA-256 sidecar. Node.js 22+ and npm 11 are required.
## Licence
+2 -2
View File
@@ -1,10 +1,10 @@
# Source identity
- Project: Token Tools
- Version: 0.1.0
- Version: 0.2.0
- Repository: https://git.add-ideas.de/lotobo/token-tools
- Licence: GPL-3.0-or-later
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
- Artifact: `token-tools-0.1.0.zip`
- Artifact: `token-tools-0.2.0.zip`
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
+11 -1
View File
@@ -1,5 +1,15 @@
# Architecture
The applied JSON document is separate from the editing draft, so malformed intermediate input never replaces the last valid state. `core/tokens.ts` extracts inherited nested tokens, composes ordered sets, resolves aliases through a guarded graph walk and validates resolved values. Export adapters consume only the selected theme's resolved records and append every unsupported conversion to a loss report.
The applied JSON document is separate from the editing draft, so malformed
intermediate input never replaces the last valid state. `core/tokens.ts`
extracts DTCG `$type`/`$value` tokens, composes ordered sets and explicit
theme/mode overlays, resolves aliases recursively inside composite values
through a guarded graph walk, and reports the complete cycle path. Supported
composites include typography, shadow, border, transition and gradient, plus
font, duration, Bézier and stroke primitives.
The DTCG export preserves alias expressions for the selected composition.
Platform adapters consume resolved records and append every unsupported
conversion to a loss report.
The `$sets`/`$themes` wrapper is local metadata; W3C-style export emits a plain nested `$type`/`$value` object. All Vite assets are relative for nested portal mounting, and release packaging fixes ordering, permissions and timestamps.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "token-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+13 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.token-tools",
"name": "Token Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Edit, validate and export design tokens locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,18 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/json", "extensions": [".json", ".tokens"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "text/css", "extensions": [".css", ".scss"] },
{ "mediaType": "application/xml", "extensions": [".xml"] },
{ "mediaType": "text/plain", "extensions": [".swift", ".kt", ".txt"] }
]
},
"capabilities": { "required": [], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+49 -16
View File
@@ -44,7 +44,7 @@ const EXAMPLE = `{
}
}`;
type ExportFormat = "w3c" | "css" | "sass" | "android" | "swift";
type ExportFormat = "dtcg" | "css" | "sass" | "android" | "swift";
export function Workbench() {
const [draft, setDraft] = useState(EXAMPLE);
@@ -55,6 +55,7 @@ export function Workbench() {
[documentText],
);
const [theme, setTheme] = useState("light");
const [mode, setMode] = useState("default");
const [setName, setSetName] = useState("core");
const [selectedPath, setSelectedPath] = useState<string | null>(
"color.brand",
@@ -66,16 +67,16 @@ export function Workbench() {
const [format, setFormat] = useState<ExportFormat>("css");
const [filter, setFilter] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const activeTheme = document.themes[theme]
? theme
: (Object.keys(document.themes)[0] ?? "default");
const activeMode = document.modes[activeTheme]?.[mode]
? mode
: (Object.keys(document.modes[activeTheme] ?? {})[0] ?? "default");
const resolution = useMemo(
() =>
resolveTheme(
document,
document.themes[theme]
? theme
: (Object.keys(document.themes)[0] ?? "default"),
),
[document, theme],
() => resolveTheme(document, activeTheme, activeMode),
[document, activeTheme, activeMode],
);
const exported = useMemo(
() => exportTokens(format, resolution.tokens),
@@ -96,6 +97,7 @@ export function Workbench() {
? setName
: (Object.keys(parsed.sets)[0] ?? "default");
setTheme(nextTheme);
setMode(Object.keys(parsed.modes[nextTheme] ?? {})[0] ?? "default");
setSetName(nextSet);
setSelectedPath(null);
setParseError("");
@@ -231,6 +233,7 @@ export function Workbench() {
ref={inputRef}
className="sr-only"
type="file"
aria-label="Open design token JSON file"
accept="application/json,.json"
onChange={(event) => void importDocument(event.target.files?.[0])}
/>
@@ -336,9 +339,17 @@ export function Workbench() {
{[
"color",
"dimension",
"duration",
"number",
"string",
"boolean",
"fontFamily",
"fontWeight",
"cubicBezier",
"strokeStyle",
"border",
"transition",
"gradient",
"typography",
"shadow",
"unknown",
@@ -389,20 +400,42 @@ export function Workbench() {
<label className="inline-label">
Theme
<select
value={
document.themes[theme] ? theme : Object.keys(document.themes)[0]
}
onChange={(event) => setTheme(event.target.value)}
value={activeTheme}
onChange={(event) => {
const nextTheme = event.target.value;
setTheme(nextTheme);
setMode(
Object.keys(document.modes[nextTheme] ?? {})[0] ?? "default",
);
}}
>
{Object.keys(document.themes).map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<label className="inline-label">
Mode
<select
value={activeMode}
onChange={(event) => setMode(event.target.value)}
>
{Object.keys(document.modes[activeTheme] ?? { default: [] }).map(
(name) => (
<option key={name}>{name}</option>
),
)}
</select>
</label>
</div>
<p className="muted">
Sets: {(document.themes[theme] ?? []).join(" → ") || "none"}. Later
sets override matching paths.
Sets:{" "}
{(
document.modes[activeTheme]?.[activeMode] ??
document.themes[activeTheme] ??
[]
).join(" → ") || "none"}
. Later sets override matching paths.
</p>
{resolution.diagnostics.length > 0 ? (
<ul className="diagnostics">
@@ -460,7 +493,7 @@ export function Workbench() {
setFormat(event.target.value as ExportFormat)
}
>
<option value="w3c">W3C-style JSON</option>
<option value="dtcg">DTCG JSON</option>
<option value="css">CSS variables</option>
<option value="sass">Sass variables</option>
<option value="android">Android XML</option>
+406 -51
View File
@@ -5,9 +5,17 @@ export const MAX_TOKENS = 10_000;
export type TokenType =
| "color"
| "dimension"
| "duration"
| "number"
| "string"
| "boolean"
| "fontFamily"
| "fontWeight"
| "cubicBezier"
| "strokeStyle"
| "border"
| "transition"
| "gradient"
| "typography"
| "shadow"
| "unknown";
@@ -24,6 +32,7 @@ export interface TokenDocument {
raw: Record<string, unknown>;
sets: Record<string, TokenRecord[]>;
themes: Record<string, string[]>;
modes: Record<string, Record<string, string[]>>;
wrapped: boolean;
}
@@ -60,8 +69,8 @@ export function parseTokenDocument(text: string): TokenDocument {
if ("$sets" in raw && !isPlainObject(raw.$sets))
throw new TypeError("$sets must be an object.");
const wrapped = isPlainObject(raw.$sets);
if (!wrapped && "$themes" in raw)
throw new TypeError("$themes requires a $sets object.");
if (!wrapped && ("$themes" in raw || "$modes" in raw))
throw new TypeError("Theme/mode metadata requires a $sets object.");
const sourceSets: Record<string, unknown> = wrapped
? (raw.$sets as Record<string, unknown>)
: { default: raw };
@@ -82,20 +91,56 @@ export function parseTokenDocument(text: string): TokenDocument {
sets[setName] = tokens.sort((a, b) => compareText(a.path, b.path));
}
const themes: Record<string, string[]> = {};
const modes: Record<string, Record<string, string[]>> = {};
if (wrapped && raw.$themes !== undefined) {
if (!isPlainObject(raw.$themes))
throw new TypeError("$themes must be an object.");
for (const [name, value] of Object.entries(raw.$themes)) {
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== "string")
)
throw new TypeError(`Theme ${name} must be an array of set names.`);
themes[name] = value as string[];
if (Array.isArray(value)) {
const setNames = stringArray(value, `Theme ${name}`);
themes[name] = setNames;
modes[name] = { default: setNames };
continue;
}
if (!isPlainObject(value))
throw new TypeError(`Theme ${name} must be an array or object.`);
const base = stringArray(
value.$sets ?? value.sets ?? [],
`Theme ${name} sets`,
);
const rawModes = value.$modes ?? value.modes;
if (rawModes !== undefined && !isPlainObject(rawModes))
throw new TypeError(`Theme ${name} modes must be an object.`);
themes[name] = base;
modes[name] = {};
for (const [modeName, modeSets] of Object.entries(rawModes ?? {}))
modes[name]![modeName] = [
...base,
...stringArray(modeSets, `Theme ${name} mode ${modeName}`),
];
if (!Object.keys(modes[name]!).length) modes[name]!.default = base;
}
}
if (Object.keys(themes).length === 0) themes.default = Object.keys(sets);
return { raw, sets, themes, wrapped };
if (wrapped && raw.$modes !== undefined) {
if (!isPlainObject(raw.$modes))
throw new TypeError("$modes must be an object.");
const base = themes.default ?? Object.keys(sets);
themes.default = base;
modes.default = {};
for (const [name, value] of Object.entries(raw.$modes))
modes.default[name] = [...base, ...stringArray(value, `Mode ${name}`)];
}
if (Object.keys(themes).length === 0) {
themes.default = Object.keys(sets);
modes.default = { default: themes.default };
}
return { raw, sets, themes, modes, wrapped };
}
function stringArray(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
throw new TypeError(`${label} must be an array of set names.`);
return value as string[];
}
function walk(
@@ -105,6 +150,10 @@ function walk(
inheritedType: TokenType | undefined,
output: TokenRecord[],
): void {
if ("$type" in node && "type" in node)
throw new TypeError(
`Token or group ${path.join(".") || set} declares two types.`,
);
const rawType = node.$type ?? node.type;
const normalizedType = normalizeType(rawType);
if (rawType !== undefined && normalizedType === undefined)
@@ -125,6 +174,13 @@ function walk(
`Token ${path.join(".")} cannot also contain child ${children[0]}.`,
);
const value = "$value" in node ? node.$value : node.value;
if (
node.$description !== undefined &&
typeof node.$description !== "string"
)
throw new TypeError(
`Token ${path.join(".")} description must be a string.`,
);
output.push({
path: path.join("."),
set,
@@ -151,9 +207,12 @@ function walk(
export function resolveTheme(
document: TokenDocument,
themeName: string,
modeName?: string,
): { tokens: ResolvedToken[]; diagnostics: Diagnostic[] } {
const diagnostics: Diagnostic[] = [];
const setNames = document.themes[themeName];
const setNames = modeName
? document.modes[themeName]?.[modeName]
: document.themes[themeName];
if (!setNames)
return {
tokens: [],
@@ -188,9 +247,7 @@ export function resolveTheme(
);
visiting.push(path);
try {
const match =
typeof token.value === "string" ? aliasPattern.exec(token.value) : null;
const value = match ? resolve(match[1] ?? "") : token.value;
const value = resolveAliases(token.value, resolve);
cache.set(path, value);
return value;
} finally {
@@ -238,22 +295,38 @@ export function resolveTheme(
return { tokens, diagnostics: dedupe(diagnostics) };
}
function resolveAliases(
value: unknown,
resolve: (path: string) => unknown,
): unknown {
if (typeof value === "string") {
const match = aliasPattern.exec(value);
return match ? resolve(match[1] ?? "") : value;
}
if (Array.isArray(value))
return value.map((item) => resolveAliases(item, resolve));
if (isPlainObject(value))
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
resolveAliases(item, resolve),
]),
);
return value;
}
function validateValue(token: TokenRecord, value: unknown): Diagnostic[] {
const error = (message: string): Diagnostic[] => [
{ level: "error", path: token.path, message },
];
if (
token.type === "color" &&
!(
typeof value === "string" &&
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(value)
)
)
return error("Color must be a 3, 4, 6 or 8-digit hex value.");
if (token.type === "color" && !color(value))
return error("Color must be hex or a bounded DTCG sRGB color object.");
if (token.type === "dimension" && !dimension(value))
return error(
"Dimension must be a number/unit object or CSS-style dimension string.",
);
if (token.type === "duration" && !duration(value))
return error("Duration must use a finite ms or s value.");
if (
token.type === "number" &&
(typeof value !== "number" || !Number.isFinite(value))
@@ -263,26 +336,107 @@ function validateValue(token: TokenRecord, value: unknown): Diagnostic[] {
return error("Boolean token must be true or false.");
if (token.type === "string" && typeof value !== "string")
return error("String token must contain text.");
if (
token.type === "fontFamily" &&
!(
typeof value === "string" ||
(Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item === "string"))
)
)
return error("Font family must be a string or a non-empty string array.");
if (
token.type === "fontWeight" &&
!(
(typeof value === "number" &&
Number.isFinite(value) &&
value >= 1 &&
value <= 1000) ||
(typeof value === "string" &&
/^(?:normal|bold|bolder|lighter|[1-9][0-9]{0,2}|1000)$/u.test(value))
)
)
return error("Font weight must be a named or numeric weight from 11000.");
if (
token.type === "cubicBezier" &&
!(Array.isArray(value) && value.length === 4 && value.every(finite))
)
return error("Cubic Bézier must contain four finite numbers.");
if (token.type === "strokeStyle" && !strokeStyle(value))
return error("Stroke style must be a keyword or a dash-array object.");
if (
token.type === "border" &&
(!isPlainObject(value) ||
!color(value.color) ||
!dimension(value.width) ||
!strokeStyle(value.style))
)
return error("Border requires valid color, width and style properties.");
if (
token.type === "transition" &&
(!isPlainObject(value) ||
!duration(value.duration) ||
(value.delay !== undefined && !duration(value.delay)) ||
!(
Array.isArray(value.timingFunction) &&
value.timingFunction.length === 4 &&
value.timingFunction.every(finite)
))
)
return error(
"Transition requires duration and a four-number timingFunction.",
);
if (
token.type === "gradient" &&
!(
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(stop) =>
isPlainObject(stop) &&
color(stop.color) &&
typeof stop.position === "number" &&
Number.isFinite(stop.position) &&
stop.position >= 0 &&
stop.position <= 1,
)
)
)
return error("Gradient requires at least two color/position stops.");
if (
token.type === "typography" &&
(!isPlainObject(value) ||
value.fontFamily === undefined ||
value.fontSize === undefined)
!fontFamily(value.fontFamily) ||
!dimension(value.fontSize) ||
(value.fontWeight !== undefined && !fontWeight(value.fontWeight)) ||
(value.letterSpacing !== undefined && !dimension(value.letterSpacing)) ||
(value.lineHeight !== undefined &&
!(
(finite(value.lineHeight) && value.lineHeight >= 0) ||
dimension(value.lineHeight)
)))
)
return error("Typography requires at least fontFamily and fontSize.");
return error(
"Typography requires valid fontFamily/fontSize and valid optional weight, spacing and line height.",
);
if (token.type === "shadow") {
const shadows = Array.isArray(value) ? value : [value];
if (
shadows.some(
(shadow) =>
!isPlainObject(shadow) ||
shadow.color === undefined ||
shadow.offsetX === undefined ||
shadow.offsetY === undefined ||
shadow.blur === undefined,
!color(shadow.color) ||
!dimension(shadow.offsetX) ||
!dimension(shadow.offsetY) ||
!dimension(shadow.blur) ||
(shadow.spread !== undefined && !dimension(shadow.spread)) ||
(shadow.inset !== undefined && typeof shadow.inset !== "boolean"),
)
)
return error("Each shadow requires color, offsetX, offsetY and blur.");
return error(
"Each shadow requires valid color, offsetX, offsetY and blur values.",
);
}
return token.type === "unknown"
? [
@@ -297,18 +451,23 @@ function validateValue(token: TokenRecord, value: unknown): Diagnostic[] {
}
export function exportTokens(
format: "w3c" | "css" | "sass" | "android" | "swift",
format: "dtcg" | "w3c" | "css" | "sass" | "android" | "swift",
tokens: readonly ResolvedToken[],
): ExportResult {
const losses: string[] = [];
if (format === "w3c") {
if (format === "dtcg" || format === "w3c") {
const root: Record<string, unknown> = {};
for (const token of tokens)
for (const token of tokens) {
if (token.type === "unknown")
losses.push(
`${token.path}: no standard DTCG type could be inferred; the value is preserved without $type.`,
);
setNested(root, token.path, {
$type: token.type,
$value: token.resolvedValue,
...(token.type === "unknown" ? {} : { $type: token.type }),
$value: token.value,
...(token.description ? { $description: token.description } : {}),
});
}
return {
content:
stableStringify(root, 2, {
@@ -464,11 +623,8 @@ function swiftExport(
function cssValue(token: ResolvedToken, losses: string[]): string | null {
const value = token.resolvedValue;
if (token.type === "color") {
if (
typeof value === "string" &&
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(value)
)
return value;
const rendered = colorText(value);
if (rendered) return rendered;
losses.push(`${token.path}: invalid colour omitted from CSS/Sass.`);
return null;
}
@@ -491,6 +647,85 @@ function cssValue(token: ResolvedToken, losses: string[]): string | null {
losses.push(`${token.path}: invalid dimension omitted from CSS/Sass.`);
return null;
}
if (token.type === "duration") {
const parsed = duration(value);
if (parsed) return `${parsed.value}${parsed.unit}`;
losses.push(`${token.path}: invalid duration omitted from CSS/Sass.`);
return null;
}
if (token.type === "fontFamily") {
const families = Array.isArray(value) ? value : [value];
if (
!families.length ||
families.some((family) => typeof family !== "string")
) {
losses.push(`${token.path}: invalid font family omitted from CSS/Sass.`);
return null;
}
return families.map((family) => cssFontFamily(String(family))).join(", ");
}
if (token.type === "fontWeight") {
const errors: string[] = [];
const rendered = cssFontWeight(value, token.path, errors);
if (errors.length) losses.push(...errors);
return rendered;
}
if (token.type === "cubicBezier") {
if (Array.isArray(value) && value.length === 4 && value.every(finite))
return `cubic-bezier(${value.join(", ")})`;
losses.push(`${token.path}: invalid cubic Bézier omitted from CSS/Sass.`);
return null;
}
if (token.type === "strokeStyle") {
if (typeof value === "string") return value;
losses.push(
`${token.path}: custom dash arrays need property-specific CSS.`,
);
return null;
}
if (token.type === "border" && isPlainObject(value)) {
const width = dimension(value.width),
renderedColor = colorText(value.color);
if (width && renderedColor && typeof value.style === "string")
return `${width.value}${width.unit} ${value.style} ${renderedColor}`;
losses.push(`${token.path}: complex border omitted from CSS/Sass.`);
return null;
}
if (token.type === "transition" && isPlainObject(value)) {
const main = duration(value.duration),
delay =
value.delay === undefined
? { value: 0, unit: "ms" }
: duration(value.delay),
curve = value.timingFunction;
if (
main &&
delay &&
Array.isArray(curve) &&
curve.length === 4 &&
curve.every(finite)
)
return `${main.value}${main.unit} cubic-bezier(${curve.join(", ")}) ${delay.value}${delay.unit}`;
losses.push(`${token.path}: invalid transition omitted from CSS/Sass.`);
return null;
}
if (token.type === "gradient" && Array.isArray(value)) {
const stops = value.map((entry) =>
isPlainObject(entry) &&
colorText(entry.color) &&
typeof entry.position === "number"
? `${colorText(entry.color)} ${entry.position * 100}%`
: null,
);
if (stops.every((stop) => stop !== null)) {
losses.push(
`${token.path}: DTCG gradient stops have no direction; CSS defaults to 90deg.`,
);
return `linear-gradient(90deg, ${stops.join(", ")})`;
}
losses.push(`${token.path}: invalid gradient omitted from CSS/Sass.`);
return null;
}
if (token.type === "typography" && isPlainObject(value)) {
const families = Array.isArray(value.fontFamily)
? value.fontFamily
@@ -548,18 +783,12 @@ function cssValue(token: ResolvedToken, losses: string[]): string | null {
dimension(shadow.blur),
dimension(shadow.spread ?? "0px"),
];
if (
dimensions.some((part) => !part) ||
typeof shadow.color !== "string" ||
!/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(
shadow.color,
)
) {
if (dimensions.some((part) => !part) || !color(shadow.color)) {
losses.push(`${token.path}: invalid shadow omitted from CSS/Sass.`);
return null;
}
converted.push(
`${dimensions.map((part) => `${part!.value}${part!.unit}`).join(" ")} ${shadow.color}`,
`${dimensions.map((part) => `${part!.value}${part!.unit}`).join(" ")} ${colorText(shadow.color)}`,
);
}
return converted.join(", ");
@@ -630,15 +859,25 @@ export function updateToken(
throw new TypeError(
"Path must contain dot-separated letters, digits, underscores or hyphens.",
);
const raw = structuredClone(document.raw);
const raw = cloneJsonObject(document.raw);
const setRoot = document.wrapped
? ensureObject(ensureObject(raw, "$sets"), setName)
: raw;
const previous = oldPath ? getNested(setRoot, oldPath) : undefined;
if (oldPath && oldPath !== path) deleteNested(setRoot, oldPath);
const metadata =
isPlainObject(previous) && ("$value" in previous || "value" in previous)
? Object.fromEntries(
Object.entries(previous).filter(
([key]) =>
key.startsWith("$") && key !== "$type" && key !== "$value",
),
)
: {};
setEditableToken(
setRoot,
path,
{ $type: type, $value: value },
{ ...metadata, $type: type, $value: value },
oldPath === path,
);
return stableStringify(raw, 2, {
@@ -653,7 +892,7 @@ export function deleteToken(
setName: string,
path: string,
): string {
const raw = structuredClone(document.raw);
const raw = cloneJsonObject(document.raw);
const sets = document.wrapped && isPlainObject(raw.$sets) ? raw.$sets : raw;
const setRoot =
document.wrapped && isPlainObject(sets[setName]) ? sets[setName] : raw;
@@ -684,9 +923,17 @@ function normalizeType(value: unknown): TokenType | undefined {
[
"color",
"dimension",
"duration",
"number",
"string",
"boolean",
"fontFamily",
"fontWeight",
"cubicBezier",
"strokeStyle",
"border",
"transition",
"gradient",
"typography",
"shadow",
"unknown",
@@ -695,6 +942,92 @@ function normalizeType(value: unknown): TokenType | undefined {
: undefined;
}
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function fontFamily(value: unknown): boolean {
return (
typeof value === "string" ||
(Array.isArray(value) &&
value.length > 0 &&
value.every((item) => typeof item === "string"))
);
}
function fontWeight(value: unknown): boolean {
return (
(finite(value) && value >= 1 && value <= 1000) ||
(typeof value === "string" &&
/^(?:normal|bold|bolder|lighter|[1-9][0-9]{0,2}|1000)$/u.test(value))
);
}
function duration(value: unknown): { value: number; unit: "ms" | "s" } | null {
if (typeof value === "string") {
const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(ms|s)$/u.exec(value);
return match
? { value: Number(match[1]), unit: match[2] as "ms" | "s" }
: null;
}
if (
isPlainObject(value) &&
finite(value.value) &&
(value.unit === "ms" || value.unit === "s")
)
return { value: value.value, unit: value.unit };
return null;
}
function color(value: unknown): boolean {
return colorText(value) !== null;
}
function colorText(value: unknown): string | null {
if (
typeof value === "string" &&
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(value)
)
return value;
if (
isPlainObject(value) &&
value.colorSpace === "srgb" &&
Array.isArray(value.components) &&
value.components.length === 3 &&
value.components.every((item) => finite(item) && item >= 0 && item <= 1) &&
(value.alpha === undefined ||
(finite(value.alpha) && value.alpha >= 0 && value.alpha <= 1))
) {
const channels = value.components.map((item) =>
Math.round((item as number) * 255)
.toString(16)
.padStart(2, "0"),
);
const alpha =
value.alpha === undefined || value.alpha === 1
? ""
: Math.round(value.alpha * 255)
.toString(16)
.padStart(2, "0");
return `#${channels.join("")}${alpha}`;
}
return null;
}
function strokeStyle(value: unknown): boolean {
if (typeof value === "string")
return ["solid", "dashed", "dotted", "double", "none"].includes(value);
return (
isPlainObject(value) &&
Array.isArray(value.dashArray) &&
value.dashArray.length > 0 &&
value.dashArray.length <= 32 &&
value.dashArray.every((item) => dimension(item) !== null) &&
(value.lineCap === undefined ||
["round", "butt", "square"].includes(String(value.lineCap)))
);
}
function dimension(value: unknown): { value: number; unit: string } | null {
if (typeof value === "string") {
const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%|pt|dp|sp)$/u.exec(
@@ -771,6 +1104,15 @@ function setNested(
cursor[parts.at(-1) ?? path] = value;
}
function getNested(root: Record<string, unknown>, path: string): unknown {
let cursor: unknown = root;
for (const part of path.split(".")) {
if (!isPlainObject(cursor)) return undefined;
cursor = cursor[part];
}
return cursor;
}
function setEditableToken(
root: Record<string, unknown>,
path: string,
@@ -831,6 +1173,19 @@ function ensureObject(
return root[key] as Record<string, unknown>;
}
function cloneJsonObject(
value: Record<string, unknown>,
): Record<string, unknown> {
return safeJsonParse(
stableStringify(value, 0, {
maxTextChars: MAX_TEXT,
maxDepth: 64,
maxNodes: 50_000,
}),
{ maxTextChars: MAX_TEXT, maxDepth: 64, maxNodes: 50_000 },
) as Record<string, unknown>;
}
function escapeXml(value: string): string {
return value
.replace(/&/gu, "&amp;")
+7
View File
@@ -1065,6 +1065,13 @@ body {
.section-heading {
align-items: flex-start;
}
.panel-heading {
flex-wrap: wrap;
}
.panel-heading .inline-label {
min-width: min(11rem, 100%);
flex: 1 1 11rem;
}
.section-heading:has(.toggle) {
flex-direction: column;
}
+31 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.token-tools",
"name": "Token Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Edit, validate and export design tokens locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,36 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/json",
"extensions": [".json", ".tokens"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "text/css",
"extensions": [".css", ".scss"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
},
{
"mediaType": "text/plain",
"extensions": [".swift", ".kt", ".txt"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/token/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+112
View File
@@ -42,6 +42,70 @@ describe("token model", () => {
expect(messages).toMatch(/does not exist/u);
});
it("resolves nested composite aliases and explicit theme modes", () => {
const document = parseTokenDocument(
JSON.stringify({
$sets: {
core: {
color: {
brand: { $type: "color", $value: "#112233" },
},
},
dark: {
color: {
brand: { $type: "color", $value: "#ddeeff" },
},
},
components: {
focus: {
$type: "border",
$value: {
color: "{color.brand}",
width: "2px",
style: "solid",
},
},
},
},
$themes: {
product: {
$sets: ["core", "components"],
$modes: { light: [], dark: ["dark"] },
},
},
}),
);
const result = resolveTheme(document, "product", "dark");
expect(
result.tokens.find((token) => token.path === "focus")?.resolvedValue,
).toEqual({ color: "#ddeeff", width: "2px", style: "solid" });
expect(result.diagnostics).toEqual([]);
});
it("validates and exports DTCG composite token types", () => {
const document = parseTokenDocument(
JSON.stringify({
easing: { $type: "cubicBezier", $value: [0.2, 0, 0, 1] },
motion: {
$type: "transition",
$value: {
duration: "180ms",
delay: "0ms",
timingFunction: "{easing}",
},
},
}),
);
const resolved = resolveTheme(document, "default");
expect(resolved.diagnostics).toEqual([]);
expect(exportTokens("css", resolved.tokens).content).toContain(
"180ms cubic-bezier(0.2, 0, 0, 1) 0ms",
);
const dtcg = exportTokens("dtcg", resolved.tokens).content;
expect(dtcg).toContain('"$type": "transition"');
expect(dtcg).toContain('"{easing}"');
});
it("rejects malformed wrappers and non-standard hex lengths", () => {
expect(() => parseTokenDocument('{"$sets":[]}')).toThrow(
/\$sets must be an object/u,
@@ -125,6 +189,54 @@ describe("token model", () => {
).toBe(false);
});
it("preserves DTCG leaf metadata while editing or renaming", () => {
const document = parseTokenDocument(
'{"brand":{"$type":"color","$value":"#112233","$description":"Brand","$extensions":{"vendor":{"id":1}}}}',
);
const updated = updateToken(
document,
"default",
"brand",
"color.brand",
"color",
"#445566",
);
expect(JSON.parse(updated)).toMatchObject({
color: {
brand: {
$description: "Brand",
$extensions: { vendor: { id: 1 } },
$value: "#445566",
},
},
});
});
it("validates composite members rather than only their presence", () => {
const document = parseTokenDocument(
JSON.stringify({
text: {
$type: "typography",
$value: { fontFamily: 42, fontSize: "large" },
},
depth: {
$type: "shadow",
$value: {
color: "not-a-colour",
offsetX: "0px",
offsetY: "2px",
blur: "4px",
},
},
}),
);
const messages = resolveTheme(document, "default")
.diagnostics.map((diagnostic) => diagnostic.message)
.join(" ");
expect(messages).toMatch(/Typography requires valid/iu);
expect(messages).toMatch(/Each shadow requires valid/iu);
});
it("refuses to create a path through an existing token leaf", () => {
const document = parseTokenDocument(
'{"base":{"$type":"string","$value":"leaf"}}',