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
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial nested token editor with multi-set themes and ordered overrides.
|
- Initial nested token editor with multi-set themes and ordered overrides.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Token Tools
|
# 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`.
|
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
|
## Features
|
||||||
@@ -38,7 +45,7 @@ npm run test:browser
|
|||||||
npm run package:release -- --force
|
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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Token Tools
|
- Project: Token Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Repository: https://git.add-ideas.de/lotobo/token-tools
|
- Repository: https://git.add-ideas.de/lotobo/token-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
- 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/`.
|
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
|
# 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.
|
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",
|
"name": "token-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "token-tools",
|
"name": "token-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -42,24 +42,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
"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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
"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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"license": "GPL-3.0-or-later"
|
"license": "GPL-3.0-or-later"
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"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",
|
"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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -67,13 +67,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"node_modules/@add-ideas/toolbox-testkit": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
"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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "token-tools",
|
"name": "token-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Edit, validate and export design tokens locally in the browser.",
|
"description": "Edit, validate and export design tokens locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,14 +39,14 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Initial nested token editor with multi-set themes and ordered overrides.
|
- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,12 @@
|
|||||||
# Token Tools
|
# 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`.
|
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
|
## Features
|
||||||
@@ -38,7 +45,7 @@ npm run test:browser
|
|||||||
npm run package:release -- --force
|
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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
# Source identity
|
# Source identity
|
||||||
|
|
||||||
- Project: Token Tools
|
- Project: Token Tools
|
||||||
- Version: 0.1.0
|
- Version: 0.2.0
|
||||||
- Repository: https://git.add-ideas.de/lotobo/token-tools
|
- Repository: https://git.add-ideas.de/lotobo/token-tools
|
||||||
- Licence: GPL-3.0-or-later
|
- Licence: GPL-3.0-or-later
|
||||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
- 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/`.
|
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
|
# 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.
|
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_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"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+13
-1
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.token-tools",
|
"id": "de.add-ideas.token-tools",
|
||||||
"name": "Token Tools",
|
"name": "Token Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Edit, validate and export design tokens locally.",
|
"description": "Edit, validate and export design tokens locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,18 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"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() {
|
export function Workbench() {
|
||||||
const [draft, setDraft] = useState(EXAMPLE);
|
const [draft, setDraft] = useState(EXAMPLE);
|
||||||
@@ -55,6 +55,7 @@ export function Workbench() {
|
|||||||
[documentText],
|
[documentText],
|
||||||
);
|
);
|
||||||
const [theme, setTheme] = useState("light");
|
const [theme, setTheme] = useState("light");
|
||||||
|
const [mode, setMode] = useState("default");
|
||||||
const [setName, setSetName] = useState("core");
|
const [setName, setSetName] = useState("core");
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(
|
const [selectedPath, setSelectedPath] = useState<string | null>(
|
||||||
"color.brand",
|
"color.brand",
|
||||||
@@ -66,16 +67,16 @@ export function Workbench() {
|
|||||||
const [format, setFormat] = useState<ExportFormat>("css");
|
const [format, setFormat] = useState<ExportFormat>("css");
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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(
|
const resolution = useMemo(
|
||||||
() =>
|
() => resolveTheme(document, activeTheme, activeMode),
|
||||||
resolveTheme(
|
[document, activeTheme, activeMode],
|
||||||
document,
|
|
||||||
document.themes[theme]
|
|
||||||
? theme
|
|
||||||
: (Object.keys(document.themes)[0] ?? "default"),
|
|
||||||
),
|
|
||||||
[document, theme],
|
|
||||||
);
|
);
|
||||||
const exported = useMemo(
|
const exported = useMemo(
|
||||||
() => exportTokens(format, resolution.tokens),
|
() => exportTokens(format, resolution.tokens),
|
||||||
@@ -96,6 +97,7 @@ export function Workbench() {
|
|||||||
? setName
|
? setName
|
||||||
: (Object.keys(parsed.sets)[0] ?? "default");
|
: (Object.keys(parsed.sets)[0] ?? "default");
|
||||||
setTheme(nextTheme);
|
setTheme(nextTheme);
|
||||||
|
setMode(Object.keys(parsed.modes[nextTheme] ?? {})[0] ?? "default");
|
||||||
setSetName(nextSet);
|
setSetName(nextSet);
|
||||||
setSelectedPath(null);
|
setSelectedPath(null);
|
||||||
setParseError("");
|
setParseError("");
|
||||||
@@ -231,6 +233,7 @@ export function Workbench() {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
|
aria-label="Open design token JSON file"
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
onChange={(event) => void importDocument(event.target.files?.[0])}
|
onChange={(event) => void importDocument(event.target.files?.[0])}
|
||||||
/>
|
/>
|
||||||
@@ -336,9 +339,17 @@ export function Workbench() {
|
|||||||
{[
|
{[
|
||||||
"color",
|
"color",
|
||||||
"dimension",
|
"dimension",
|
||||||
|
"duration",
|
||||||
"number",
|
"number",
|
||||||
"string",
|
"string",
|
||||||
"boolean",
|
"boolean",
|
||||||
|
"fontFamily",
|
||||||
|
"fontWeight",
|
||||||
|
"cubicBezier",
|
||||||
|
"strokeStyle",
|
||||||
|
"border",
|
||||||
|
"transition",
|
||||||
|
"gradient",
|
||||||
"typography",
|
"typography",
|
||||||
"shadow",
|
"shadow",
|
||||||
"unknown",
|
"unknown",
|
||||||
@@ -389,20 +400,42 @@ export function Workbench() {
|
|||||||
<label className="inline-label">
|
<label className="inline-label">
|
||||||
Theme
|
Theme
|
||||||
<select
|
<select
|
||||||
value={
|
value={activeTheme}
|
||||||
document.themes[theme] ? theme : Object.keys(document.themes)[0]
|
onChange={(event) => {
|
||||||
}
|
const nextTheme = event.target.value;
|
||||||
onChange={(event) => setTheme(event.target.value)}
|
setTheme(nextTheme);
|
||||||
|
setMode(
|
||||||
|
Object.keys(document.modes[nextTheme] ?? {})[0] ?? "default",
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.keys(document.themes).map((name) => (
|
{Object.keys(document.themes).map((name) => (
|
||||||
<option key={name}>{name}</option>
|
<option key={name}>{name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Sets: {(document.themes[theme] ?? []).join(" → ") || "none"}. Later
|
Sets:{" "}
|
||||||
sets override matching paths.
|
{(
|
||||||
|
document.modes[activeTheme]?.[activeMode] ??
|
||||||
|
document.themes[activeTheme] ??
|
||||||
|
[]
|
||||||
|
).join(" → ") || "none"}
|
||||||
|
. Later sets override matching paths.
|
||||||
</p>
|
</p>
|
||||||
{resolution.diagnostics.length > 0 ? (
|
{resolution.diagnostics.length > 0 ? (
|
||||||
<ul className="diagnostics">
|
<ul className="diagnostics">
|
||||||
@@ -460,7 +493,7 @@ export function Workbench() {
|
|||||||
setFormat(event.target.value as ExportFormat)
|
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="css">CSS variables</option>
|
||||||
<option value="sass">Sass variables</option>
|
<option value="sass">Sass variables</option>
|
||||||
<option value="android">Android XML</option>
|
<option value="android">Android XML</option>
|
||||||
|
|||||||
+406
-51
@@ -5,9 +5,17 @@ export const MAX_TOKENS = 10_000;
|
|||||||
export type TokenType =
|
export type TokenType =
|
||||||
| "color"
|
| "color"
|
||||||
| "dimension"
|
| "dimension"
|
||||||
|
| "duration"
|
||||||
| "number"
|
| "number"
|
||||||
| "string"
|
| "string"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
|
| "fontFamily"
|
||||||
|
| "fontWeight"
|
||||||
|
| "cubicBezier"
|
||||||
|
| "strokeStyle"
|
||||||
|
| "border"
|
||||||
|
| "transition"
|
||||||
|
| "gradient"
|
||||||
| "typography"
|
| "typography"
|
||||||
| "shadow"
|
| "shadow"
|
||||||
| "unknown";
|
| "unknown";
|
||||||
@@ -24,6 +32,7 @@ export interface TokenDocument {
|
|||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
sets: Record<string, TokenRecord[]>;
|
sets: Record<string, TokenRecord[]>;
|
||||||
themes: Record<string, string[]>;
|
themes: Record<string, string[]>;
|
||||||
|
modes: Record<string, Record<string, string[]>>;
|
||||||
wrapped: boolean;
|
wrapped: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +69,8 @@ export function parseTokenDocument(text: string): TokenDocument {
|
|||||||
if ("$sets" in raw && !isPlainObject(raw.$sets))
|
if ("$sets" in raw && !isPlainObject(raw.$sets))
|
||||||
throw new TypeError("$sets must be an object.");
|
throw new TypeError("$sets must be an object.");
|
||||||
const wrapped = isPlainObject(raw.$sets);
|
const wrapped = isPlainObject(raw.$sets);
|
||||||
if (!wrapped && "$themes" in raw)
|
if (!wrapped && ("$themes" in raw || "$modes" in raw))
|
||||||
throw new TypeError("$themes requires a $sets object.");
|
throw new TypeError("Theme/mode metadata requires a $sets object.");
|
||||||
const sourceSets: Record<string, unknown> = wrapped
|
const sourceSets: Record<string, unknown> = wrapped
|
||||||
? (raw.$sets as Record<string, unknown>)
|
? (raw.$sets as Record<string, unknown>)
|
||||||
: { default: raw };
|
: { default: raw };
|
||||||
@@ -82,20 +91,56 @@ export function parseTokenDocument(text: string): TokenDocument {
|
|||||||
sets[setName] = tokens.sort((a, b) => compareText(a.path, b.path));
|
sets[setName] = tokens.sort((a, b) => compareText(a.path, b.path));
|
||||||
}
|
}
|
||||||
const themes: Record<string, string[]> = {};
|
const themes: Record<string, string[]> = {};
|
||||||
|
const modes: Record<string, Record<string, string[]>> = {};
|
||||||
if (wrapped && raw.$themes !== undefined) {
|
if (wrapped && raw.$themes !== undefined) {
|
||||||
if (!isPlainObject(raw.$themes))
|
if (!isPlainObject(raw.$themes))
|
||||||
throw new TypeError("$themes must be an object.");
|
throw new TypeError("$themes must be an object.");
|
||||||
for (const [name, value] of Object.entries(raw.$themes)) {
|
for (const [name, value] of Object.entries(raw.$themes)) {
|
||||||
if (
|
if (Array.isArray(value)) {
|
||||||
!Array.isArray(value) ||
|
const setNames = stringArray(value, `Theme ${name}`);
|
||||||
value.some((item) => typeof item !== "string")
|
themes[name] = setNames;
|
||||||
)
|
modes[name] = { default: setNames };
|
||||||
throw new TypeError(`Theme ${name} must be an array of set names.`);
|
continue;
|
||||||
themes[name] = value as string[];
|
}
|
||||||
|
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);
|
if (wrapped && raw.$modes !== undefined) {
|
||||||
return { raw, sets, themes, wrapped };
|
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(
|
function walk(
|
||||||
@@ -105,6 +150,10 @@ function walk(
|
|||||||
inheritedType: TokenType | undefined,
|
inheritedType: TokenType | undefined,
|
||||||
output: TokenRecord[],
|
output: TokenRecord[],
|
||||||
): void {
|
): 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 rawType = node.$type ?? node.type;
|
||||||
const normalizedType = normalizeType(rawType);
|
const normalizedType = normalizeType(rawType);
|
||||||
if (rawType !== undefined && normalizedType === undefined)
|
if (rawType !== undefined && normalizedType === undefined)
|
||||||
@@ -125,6 +174,13 @@ function walk(
|
|||||||
`Token ${path.join(".")} cannot also contain child ${children[0]}.`,
|
`Token ${path.join(".")} cannot also contain child ${children[0]}.`,
|
||||||
);
|
);
|
||||||
const value = "$value" in node ? node.$value : node.value;
|
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({
|
output.push({
|
||||||
path: path.join("."),
|
path: path.join("."),
|
||||||
set,
|
set,
|
||||||
@@ -151,9 +207,12 @@ function walk(
|
|||||||
export function resolveTheme(
|
export function resolveTheme(
|
||||||
document: TokenDocument,
|
document: TokenDocument,
|
||||||
themeName: string,
|
themeName: string,
|
||||||
|
modeName?: string,
|
||||||
): { tokens: ResolvedToken[]; diagnostics: Diagnostic[] } {
|
): { tokens: ResolvedToken[]; diagnostics: Diagnostic[] } {
|
||||||
const diagnostics: Diagnostic[] = [];
|
const diagnostics: Diagnostic[] = [];
|
||||||
const setNames = document.themes[themeName];
|
const setNames = modeName
|
||||||
|
? document.modes[themeName]?.[modeName]
|
||||||
|
: document.themes[themeName];
|
||||||
if (!setNames)
|
if (!setNames)
|
||||||
return {
|
return {
|
||||||
tokens: [],
|
tokens: [],
|
||||||
@@ -188,9 +247,7 @@ export function resolveTheme(
|
|||||||
);
|
);
|
||||||
visiting.push(path);
|
visiting.push(path);
|
||||||
try {
|
try {
|
||||||
const match =
|
const value = resolveAliases(token.value, resolve);
|
||||||
typeof token.value === "string" ? aliasPattern.exec(token.value) : null;
|
|
||||||
const value = match ? resolve(match[1] ?? "") : token.value;
|
|
||||||
cache.set(path, value);
|
cache.set(path, value);
|
||||||
return value;
|
return value;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -238,22 +295,38 @@ export function resolveTheme(
|
|||||||
return { tokens, diagnostics: dedupe(diagnostics) };
|
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[] {
|
function validateValue(token: TokenRecord, value: unknown): Diagnostic[] {
|
||||||
const error = (message: string): Diagnostic[] => [
|
const error = (message: string): Diagnostic[] => [
|
||||||
{ level: "error", path: token.path, message },
|
{ level: "error", path: token.path, message },
|
||||||
];
|
];
|
||||||
if (
|
if (token.type === "color" && !color(value))
|
||||||
token.type === "color" &&
|
return error("Color must be hex or a bounded DTCG sRGB color object.");
|
||||||
!(
|
|
||||||
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 === "dimension" && !dimension(value))
|
if (token.type === "dimension" && !dimension(value))
|
||||||
return error(
|
return error(
|
||||||
"Dimension must be a number/unit object or CSS-style dimension string.",
|
"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 (
|
if (
|
||||||
token.type === "number" &&
|
token.type === "number" &&
|
||||||
(typeof value !== "number" || !Number.isFinite(value))
|
(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.");
|
return error("Boolean token must be true or false.");
|
||||||
if (token.type === "string" && typeof value !== "string")
|
if (token.type === "string" && typeof value !== "string")
|
||||||
return error("String token must contain text.");
|
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 (
|
if (
|
||||||
token.type === "typography" &&
|
token.type === "typography" &&
|
||||||
(!isPlainObject(value) ||
|
(!isPlainObject(value) ||
|
||||||
value.fontFamily === undefined ||
|
!fontFamily(value.fontFamily) ||
|
||||||
value.fontSize === undefined)
|
!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") {
|
if (token.type === "shadow") {
|
||||||
const shadows = Array.isArray(value) ? value : [value];
|
const shadows = Array.isArray(value) ? value : [value];
|
||||||
if (
|
if (
|
||||||
shadows.some(
|
shadows.some(
|
||||||
(shadow) =>
|
(shadow) =>
|
||||||
!isPlainObject(shadow) ||
|
!isPlainObject(shadow) ||
|
||||||
shadow.color === undefined ||
|
!color(shadow.color) ||
|
||||||
shadow.offsetX === undefined ||
|
!dimension(shadow.offsetX) ||
|
||||||
shadow.offsetY === undefined ||
|
!dimension(shadow.offsetY) ||
|
||||||
shadow.blur === undefined,
|
!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"
|
return token.type === "unknown"
|
||||||
? [
|
? [
|
||||||
@@ -297,18 +451,23 @@ function validateValue(token: TokenRecord, value: unknown): Diagnostic[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function exportTokens(
|
export function exportTokens(
|
||||||
format: "w3c" | "css" | "sass" | "android" | "swift",
|
format: "dtcg" | "w3c" | "css" | "sass" | "android" | "swift",
|
||||||
tokens: readonly ResolvedToken[],
|
tokens: readonly ResolvedToken[],
|
||||||
): ExportResult {
|
): ExportResult {
|
||||||
const losses: string[] = [];
|
const losses: string[] = [];
|
||||||
if (format === "w3c") {
|
if (format === "dtcg" || format === "w3c") {
|
||||||
const root: Record<string, unknown> = {};
|
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, {
|
setNested(root, token.path, {
|
||||||
$type: token.type,
|
...(token.type === "unknown" ? {} : { $type: token.type }),
|
||||||
$value: token.resolvedValue,
|
$value: token.value,
|
||||||
...(token.description ? { $description: token.description } : {}),
|
...(token.description ? { $description: token.description } : {}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content:
|
content:
|
||||||
stableStringify(root, 2, {
|
stableStringify(root, 2, {
|
||||||
@@ -464,11 +623,8 @@ function swiftExport(
|
|||||||
function cssValue(token: ResolvedToken, losses: string[]): string | null {
|
function cssValue(token: ResolvedToken, losses: string[]): string | null {
|
||||||
const value = token.resolvedValue;
|
const value = token.resolvedValue;
|
||||||
if (token.type === "color") {
|
if (token.type === "color") {
|
||||||
if (
|
const rendered = colorText(value);
|
||||||
typeof value === "string" &&
|
if (rendered) return rendered;
|
||||||
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(value)
|
|
||||||
)
|
|
||||||
return value;
|
|
||||||
losses.push(`${token.path}: invalid colour omitted from CSS/Sass.`);
|
losses.push(`${token.path}: invalid colour omitted from CSS/Sass.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -491,6 +647,85 @@ function cssValue(token: ResolvedToken, losses: string[]): string | null {
|
|||||||
losses.push(`${token.path}: invalid dimension omitted from CSS/Sass.`);
|
losses.push(`${token.path}: invalid dimension omitted from CSS/Sass.`);
|
||||||
return null;
|
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)) {
|
if (token.type === "typography" && isPlainObject(value)) {
|
||||||
const families = Array.isArray(value.fontFamily)
|
const families = Array.isArray(value.fontFamily)
|
||||||
? value.fontFamily
|
? value.fontFamily
|
||||||
@@ -548,18 +783,12 @@ function cssValue(token: ResolvedToken, losses: string[]): string | null {
|
|||||||
dimension(shadow.blur),
|
dimension(shadow.blur),
|
||||||
dimension(shadow.spread ?? "0px"),
|
dimension(shadow.spread ?? "0px"),
|
||||||
];
|
];
|
||||||
if (
|
if (dimensions.some((part) => !part) || !color(shadow.color)) {
|
||||||
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,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
losses.push(`${token.path}: invalid shadow omitted from CSS/Sass.`);
|
losses.push(`${token.path}: invalid shadow omitted from CSS/Sass.`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
converted.push(
|
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(", ");
|
return converted.join(", ");
|
||||||
@@ -630,15 +859,25 @@ export function updateToken(
|
|||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
"Path must contain dot-separated letters, digits, underscores or hyphens.",
|
"Path must contain dot-separated letters, digits, underscores or hyphens.",
|
||||||
);
|
);
|
||||||
const raw = structuredClone(document.raw);
|
const raw = cloneJsonObject(document.raw);
|
||||||
const setRoot = document.wrapped
|
const setRoot = document.wrapped
|
||||||
? ensureObject(ensureObject(raw, "$sets"), setName)
|
? ensureObject(ensureObject(raw, "$sets"), setName)
|
||||||
: raw;
|
: raw;
|
||||||
|
const previous = oldPath ? getNested(setRoot, oldPath) : undefined;
|
||||||
if (oldPath && oldPath !== path) deleteNested(setRoot, oldPath);
|
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(
|
setEditableToken(
|
||||||
setRoot,
|
setRoot,
|
||||||
path,
|
path,
|
||||||
{ $type: type, $value: value },
|
{ ...metadata, $type: type, $value: value },
|
||||||
oldPath === path,
|
oldPath === path,
|
||||||
);
|
);
|
||||||
return stableStringify(raw, 2, {
|
return stableStringify(raw, 2, {
|
||||||
@@ -653,7 +892,7 @@ export function deleteToken(
|
|||||||
setName: string,
|
setName: string,
|
||||||
path: string,
|
path: string,
|
||||||
): string {
|
): string {
|
||||||
const raw = structuredClone(document.raw);
|
const raw = cloneJsonObject(document.raw);
|
||||||
const sets = document.wrapped && isPlainObject(raw.$sets) ? raw.$sets : raw;
|
const sets = document.wrapped && isPlainObject(raw.$sets) ? raw.$sets : raw;
|
||||||
const setRoot =
|
const setRoot =
|
||||||
document.wrapped && isPlainObject(sets[setName]) ? sets[setName] : raw;
|
document.wrapped && isPlainObject(sets[setName]) ? sets[setName] : raw;
|
||||||
@@ -684,9 +923,17 @@ function normalizeType(value: unknown): TokenType | undefined {
|
|||||||
[
|
[
|
||||||
"color",
|
"color",
|
||||||
"dimension",
|
"dimension",
|
||||||
|
"duration",
|
||||||
"number",
|
"number",
|
||||||
"string",
|
"string",
|
||||||
"boolean",
|
"boolean",
|
||||||
|
"fontFamily",
|
||||||
|
"fontWeight",
|
||||||
|
"cubicBezier",
|
||||||
|
"strokeStyle",
|
||||||
|
"border",
|
||||||
|
"transition",
|
||||||
|
"gradient",
|
||||||
"typography",
|
"typography",
|
||||||
"shadow",
|
"shadow",
|
||||||
"unknown",
|
"unknown",
|
||||||
@@ -695,6 +942,92 @@ function normalizeType(value: unknown): TokenType | undefined {
|
|||||||
: 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 {
|
function dimension(value: unknown): { value: number; unit: string } | null {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(px|rem|em|%|pt|dp|sp)$/u.exec(
|
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;
|
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(
|
function setEditableToken(
|
||||||
root: Record<string, unknown>,
|
root: Record<string, unknown>,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -831,6 +1173,19 @@ function ensureObject(
|
|||||||
return root[key] as Record<string, unknown>;
|
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 {
|
function escapeXml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/gu, "&")
|
.replace(/&/gu, "&")
|
||||||
|
|||||||
@@ -1065,6 +1065,13 @@ body {
|
|||||||
.section-heading {
|
.section-heading {
|
||||||
align-items: flex-start;
|
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) {
|
.section-heading:has(.toggle) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.token-tools",
|
"id": "de.add-ideas.token-tools",
|
||||||
"name": "Token Tools",
|
"name": "Token Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Edit, validate and export design tokens locally.",
|
"description": "Edit, validate and export design tokens locally.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,36 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"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);
|
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", () => {
|
it("rejects malformed wrappers and non-standard hex lengths", () => {
|
||||||
expect(() => parseTokenDocument('{"$sets":[]}')).toThrow(
|
expect(() => parseTokenDocument('{"$sets":[]}')).toThrow(
|
||||||
/\$sets must be an object/u,
|
/\$sets must be an object/u,
|
||||||
@@ -125,6 +189,54 @@ describe("token model", () => {
|
|||||||
).toBe(false);
|
).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", () => {
|
it("refuses to create a path through an existing token leaf", () => {
|
||||||
const document = parseTokenDocument(
|
const document = parseTokenDocument(
|
||||||
'{"base":{"$type":"string","$value":"leaf"}}',
|
'{"base":{"$type":"string","$value":"leaf"}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user