From c305f3da430b9f11fa4c5586c8bf119ea17d671a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 12:38:34 +0200 Subject: [PATCH] Release Token Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++ CHANGELOG.md | 5 + README.md | 9 +- SOURCE.md | 4 +- docs/ARCHITECTURE.md | 12 +- package-lock.json | 40 +- package.json | 10 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 5 + public/LICENSES/npm-runtime-licenses.txt | 6 +- public/README.md | 9 +- public/SOURCE.md | 4 +- public/docs/ARCHITECTURE.md | 12 +- public/sw.js | 2 +- public/toolbox-app.json | 14 +- src/components/Workbench.tsx | 65 +++- src/core/tokens.ts | 457 ++++++++++++++++++++--- src/styles.css | 7 + src/toolbox/manifest.source.json | 32 +- src/version.ts | 2 +- tests/browser/responsive.spec.ts | 18 + tests/core/tokens.test.ts | 112 ++++++ 22 files changed, 777 insertions(+), 109 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 tests/browser/responsive.spec.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 29144e1..a5d245e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c9dc122..fe7ae18 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SOURCE.md b/SOURCE.md index f5db587..89ba339 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -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/`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 27b2e8e..fa32358 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/package-lock.json b/package-lock.json index a86e2ba..a01ecb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index ffd7cd0..2d74b33 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 8b2dc3c..69ec030 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 29144e1..a5d245e 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -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. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 45a7c95..4f412f1 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -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 --- diff --git a/public/README.md b/public/README.md index c9dc122..fe7ae18 100644 --- a/public/README.md +++ b/public/README.md @@ -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 diff --git a/public/SOURCE.md b/public/SOURCE.md index f5db587..89ba339 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -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/`. diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 27b2e8e..fa32358 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -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. diff --git a/public/sw.js b/public/sw.js index 5cf3264..8c342d2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 122c049..dc434af 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -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, diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 0e988d3..1e8e898 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -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( "color.brand", @@ -66,16 +67,16 @@ export function Workbench() { const [format, setFormat] = useState("css"); const [filter, setFilter] = useState(""); const inputRef = useRef(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() { +

- 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.

{resolution.diagnostics.length > 0 ? (
    @@ -460,7 +493,7 @@ export function Workbench() { setFormat(event.target.value as ExportFormat) } > - + diff --git a/src/core/tokens.ts b/src/core/tokens.ts index cdcbac4..5faf58b 100644 --- a/src/core/tokens.ts +++ b/src/core/tokens.ts @@ -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; sets: Record; themes: Record; + modes: Record>; 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 = wrapped ? (raw.$sets as Record) : { 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 = {}; + const modes: Record> = {}; 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 = {}; - 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, 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, path: string, @@ -831,6 +1173,19 @@ function ensureObject( return root[key] as Record; } +function cloneJsonObject( + value: Record, +): Record { + return safeJsonParse( + stableStringify(value, 0, { + maxTextChars: MAX_TEXT, + maxDepth: 64, + maxNodes: 50_000, + }), + { maxTextChars: MAX_TEXT, maxDepth: 64, maxNodes: 50_000 }, + ) as Record; +} + function escapeXml(value: string): string { return value .replace(/&/gu, "&") diff --git a/src/styles.css b/src/styles.css index 5fef851..6644a76 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 122c049..70cbdac 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -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, diff --git a/src/version.ts b/src/version.ts index 76162f8..0ba6e37 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = "0.1.0"; +export const APP_VERSION = "0.2.0"; diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts new file mode 100644 index 0000000..03ffe1d --- /dev/null +++ b/tests/browser/responsive.spec.ts @@ -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); +}); diff --git a/tests/core/tokens.test.ts b/tests/core/tokens.test.ts index ff8ed5f..ac40a95 100644 --- a/tests/core/tokens.test.ts +++ b/tests/core/tokens.test.ts @@ -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"}}',