Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c305f3da43 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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/`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 1–1000.");
|
||||
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, "&")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"}}',
|
||||
|
||||
Reference in New Issue
Block a user