diff --git a/splitsh.json b/splitsh.json
index 774d00626ef..8fa4f9d7ce6 100644
--- a/splitsh.json
+++ b/splitsh.json
@@ -6,6 +6,7 @@
"ux-dropzone": "src/Dropzone",
"ux-chartjs": "src/Chartjs",
"ux-cropperjs": "src/Cropperjs",
+ "ux-design-tokens": "src/DesignTokens",
"ux-icons": "src/Icons",
"ux-live-component": "src/LiveComponent",
"ux-map": {
diff --git a/src/DesignTokens/.gitattributes b/src/DesignTokens/.gitattributes
new file mode 100644
index 00000000000..f9a8f2adcb4
--- /dev/null
+++ b/src/DesignTokens/.gitattributes
@@ -0,0 +1,6 @@
+/.git* export-ignore
+/.symfony.bundle.yaml export-ignore
+/doc export-ignore
+/phpstan.dist.neon export-ignore
+/phpunit.dist.xml export-ignore
+/tests export-ignore
diff --git a/src/DesignTokens/.gitignore b/src/DesignTokens/.gitignore
new file mode 100644
index 00000000000..9e5e4be323d
--- /dev/null
+++ b/src/DesignTokens/.gitignore
@@ -0,0 +1,8 @@
+/config/reference.php
+/config/schema.json
+/vendor/
+/composer.lock
+/phpunit.xml
+/.phpunit.cache
+
+/var
diff --git a/src/DesignTokens/.symfony.bundle.yaml b/src/DesignTokens/.symfony.bundle.yaml
new file mode 100644
index 00000000000..9e3566394b9
--- /dev/null
+++ b/src/DesignTokens/.symfony.bundle.yaml
@@ -0,0 +1,3 @@
+branches: ['3.x']
+maintained_branches: ['3.x']
+doc_dir: 'doc'
diff --git a/src/DesignTokens/CHANGELOG.md b/src/DesignTokens/CHANGELOG.md
new file mode 100644
index 00000000000..dc0aeb30bd7
--- /dev/null
+++ b/src/DesignTokens/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## 3.6.0
+
+- Add the component.
diff --git a/src/DesignTokens/LICENSE b/src/DesignTokens/LICENSE
new file mode 100644
index 00000000000..94b768f8ae8
--- /dev/null
+++ b/src/DesignTokens/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026-present Fabien Potencier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/DesignTokens/README.md b/src/DesignTokens/README.md
new file mode 100644
index 00000000000..04b7f64a73b
--- /dev/null
+++ b/src/DesignTokens/README.md
@@ -0,0 +1,140 @@
+# Symfony UX Design Tokens
+
+**EXPERIMENTAL** This bundle is currently experimental and is likely to change,
+possibly significantly, before its first stable release.
+
+A design token is a named design decision. Write it once in a
+[DTCG 2025.10](https://www.designtokens.org/tr/2025.10/) file and this bundle hands
+the resolved value to CSS, Twig, PHP and your other build tools. Resolution
+happens on the server, so a theme per user, per brand or per tenant is a
+Resolver input rather than a rebuild.
+
+## Installation
+
+```bash
+composer require symfony/ux-design-tokens
+```
+
+## Usage
+
+Write the tokens in a DTCG file, `design/brand.tokens.json`. Keep it outside
+`assets/`, where AssetMapper would publish it:
+
+```json
+{
+ "color": {
+ "action": {
+ "$type": "color",
+ "primary": {
+ "$value": { "colorSpace": "oklch", "components": [0.62, 0.18, 255] }
+ }
+ }
+ },
+ "dimension": {
+ "radius": {
+ "$type": "dimension",
+ "control": {
+ "$value": { "value": 0.375, "unit": "rem" }
+ }
+ }
+ }
+}
+```
+
+Point the bundle at it. The configuration is required: without it,
+`ux_token_css()` renders an empty `:root {}` block.
+
+```yaml
+# config/packages/ux_design_tokens.yaml
+ux_design_tokens:
+ paths:
+ - '%kernel.project_dir%/design/brand.tokens.json'
+```
+
+Render the variables once, in the document head. With TwigBundle enabled:
+
+```twig
+{# templates/base.html.twig #}
+{{ ux_token_css() }}
+```
+
+```html
+
+```
+
+Stylesheets read those variables and never mention a token file. Changing the
+source changes the interface without touching CSS. When the sheet is the same
+for every visitor, `ux_token_stylesheet()` links it as a versioned AssetMapper
+asset instead of inlining it.
+
+Custom properties do not reach everywhere: an email, a PDF, an SVG attribute,
+or PHP that needs the value itself. The registry serves the same tokens, typed:
+
+```php
+// src/Mailer/BrandMailer.php
+namespace App\Mailer;
+
+use Symfony\UX\DesignTokens\TokenRegistryInterface;
+
+final class BrandMailer
+{
+ public function __construct(private readonly TokenRegistryInterface $tokens)
+ {
+ }
+
+ public function accent(): string
+ {
+ // "oklch(62% 0.18 255)"
+ return (string) $this->tokens->get('color.action.primary');
+ }
+}
+```
+
+Casting is the output edge. Before it the value keeps its DTCG structure, so a
+consumer reads the color space and the components instead of parsing a string.
+
+## What it provides
+
+- DTCG Format, Color and Resolver Module 2025.10 support
+- Typed PHP value objects and a lazy, cached `TokenRegistryInterface` service
+- Twig functions, generated CSS custom properties and versioned AssetMapper
+ assets
+- Light and dark color schemes in one stylesheet: the dark block holds only
+ the custom properties that change
+- Lint, fix, import and export commands, and a debug command
+ naming the source file behind every resolved value
+- DTCG, CSS, JavaScript, Tailwind CSS v4 and DESIGN.md exports
+- Tailwind CSS v4 `@theme` import that lists what DTCG cannot represent
+- Executable conformance fixtures derived from the reports
+
+## Interoperability
+
+The reference is the [DTCG 2025.10](https://www.designtokens.org/tr/2025.10/)
+specification, in its Format, Color and Resolver modules. Other design tools
+and build pipelines read the same files.
+
+## Documentation
+
+Read the [documentation](doc/index.rst) in this repository.
+
+> [!IMPORTANT]
+> **This repository is a READ-ONLY sub-tree split**.\
+> See https://github.com/symfony/ux to create issues or submit pull requests.
+
+## Resources
+
+- [Documentation](https://symfony.com/bundles/ux-design-tokens/current/index.html)
+- [Report issues](https://github.com/symfony/ux/issues) and
+ [send Pull Requests](https://github.com/symfony/ux/pulls)
+ in the [main Symfony UX repository](https://github.com/symfony/ux)
diff --git a/src/DesignTokens/composer.json b/src/DesignTokens/composer.json
new file mode 100644
index 00000000000..431bb34cca9
--- /dev/null
+++ b/src/DesignTokens/composer.json
@@ -0,0 +1,68 @@
+{
+ "name": "symfony/ux-design-tokens",
+ "description": "Renders DTCG design tokens as CSS variables, Twig and PHP values.",
+ "type": "symfony-bundle",
+ "license": "MIT",
+ "keywords": [
+ "symfony-ux",
+ "design-tokens",
+ "dtcg",
+ "css",
+ "twig"
+ ],
+ "authors": [
+ {
+ "name": "Simon André",
+ "email": "smn.andre@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "homepage": "https://symfony.com",
+ "require": {
+ "php": ">=8.4",
+ "psr/log": "^1.1|^2|^3",
+ "symfony/cache-contracts": "^3.5",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/console": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/filesystem": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/service-contracts": "^3.5"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^11.1|^12.0",
+ "symfony/asset-mapper": "^7.4|^8.0",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/framework-bundle": "^7.4|^8.0",
+ "symfony/twig-bundle": "^7.4|^8.0",
+ "symfony/yaml": "^7.4|^8.0",
+ "twig/twig": "^3.24|^4.0"
+ },
+ "conflict": {
+ "twig/twig": "<3.24"
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\UX\\DesignTokens\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Symfony\\UX\\DesignTokens\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "thanks": {
+ "name": "symfony/ux",
+ "url": "https://github.com/symfony/ux"
+ }
+ },
+ "minimum-stability": "dev"
+}
diff --git a/src/DesignTokens/config/asset_mapper.php b/src/DesignTokens/config/asset_mapper.php
new file mode 100644
index 00000000000..9236d7fbc7b
--- /dev/null
+++ b/src/DesignTokens/config/asset_mapper.php
@@ -0,0 +1,29 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent;
+use Symfony\UX\DesignTokens\EventListener\WarmDesignTokensOnAssetCompileListener;
+
+return static function (ContainerConfigurator $container): void {
+ $container->services()
+ ->set('.ux_design_tokens.asset_compile_listener', WarmDesignTokensOnAssetCompileListener::class)
+ ->args([
+ service('.ux_design_tokens.cache_warmer'),
+ param('kernel.build_dir'),
+ ])
+ ->tag('kernel.event_listener', [
+ 'event' => PreAssetsCompileEvent::class,
+ 'method' => '__invoke',
+ ])
+ ;
+};
diff --git a/src/DesignTokens/config/cache.php b/src/DesignTokens/config/cache.php
new file mode 100644
index 00000000000..8991682960e
--- /dev/null
+++ b/src/DesignTokens/config/cache.php
@@ -0,0 +1,21 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+return static function (ContainerConfigurator $container): void {
+ $container->services()
+ ->set('.ux_design_tokens.cache')
+ ->parent('cache.system')
+ ->private()
+ ->tag('cache.pool')
+ ;
+};
diff --git a/src/DesignTokens/config/services.php b/src/DesignTokens/config/services.php
new file mode 100644
index 00000000000..55e10235acd
--- /dev/null
+++ b/src/DesignTokens/config/services.php
@@ -0,0 +1,172 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use Symfony\Component\Yaml\Yaml;
+use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter;
+use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\Command\DebugTokensCommand;
+use Symfony\UX\DesignTokens\Command\ExportCommand;
+use Symfony\UX\DesignTokens\Command\ImportCommand;
+use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand;
+use Symfony\UX\DesignTokens\Generator\ColorScheme;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Generator\DtcgGenerator;
+use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface;
+use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader;
+use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\TokenRegistry;
+use Symfony\UX\DesignTokens\TokenRegistryInterface;
+use Symfony\UX\DesignTokens\Validation\DtcgValidator;
+use Symfony\UX\DesignTokens\Validation\Normalizer;
+
+return static function (ContainerConfigurator $container): void {
+ $container->services()
+ ->set('.ux_design_tokens.document_loader', JsonDocumentLoader::class)
+ ->args([
+ param('kernel.project_dir'),
+ param('.ux_design_tokens.allowed_roots'),
+ ])
+
+ ->alias(DocumentLoaderInterface::class, '.ux_design_tokens.document_loader')
+
+ // Stateful: each consumer needs its own instance.
+ ->set('.ux_design_tokens.token_tree_builder', TokenTreeBuilder::class)
+ ->share(false)
+ ->args([
+ service(DocumentLoaderInterface::class),
+ ])
+
+ ->set('.ux_design_tokens.validator', DtcgValidator::class)
+ ->args([
+ service('.ux_design_tokens.token_tree_builder'),
+ null,
+ service(DocumentLoaderInterface::class),
+ ])
+
+ ->set('.ux_design_tokens.normalizer', Normalizer::class)
+ ->args([
+ service('.ux_design_tokens.validator'),
+ ])
+
+ ->set('.ux_design_tokens.resolver.configured', ConfiguredTokenResolver::class)
+ ->args([
+ service(DocumentLoaderInterface::class),
+ param('.ux_design_tokens.paths'),
+ param('.ux_design_tokens.resolver_path'),
+ service('.ux_design_tokens.cache')->nullOnInvalid(),
+ param('kernel.debug'),
+ ])
+
+ ->alias('.ux_design_tokens.resolver', '.ux_design_tokens.resolver.configured')
+
+ ->alias(TokenResolverInterface::class, '.ux_design_tokens.resolver')
+
+ ->set('.ux_design_tokens.registry', TokenRegistry::class)
+ ->args([
+ service('.ux_design_tokens.resolver'),
+ param('.ux_design_tokens.resolver_inputs'),
+ ])
+ ->tag('kernel.reset', ['method' => 'reset'])
+
+ ->alias(TokenRegistryInterface::class, '.ux_design_tokens.registry')
+
+ ->set('.ux_design_tokens.importer.tailwind', ThemeImporter::class)
+ ->tag('ux_design_tokens.importer', ['format' => 'tailwind'])
+
+ ->set('.ux_design_tokens.generator.dtcg', DtcgGenerator::class)
+ ->tag('ux_design_tokens.generator', ['format' => 'dtcg'])
+
+ ->set('.ux_design_tokens.generator.css', CssGenerator::class)
+ ->args([
+ param('.ux_design_tokens.css_prefix'),
+ ])
+ ->tag('ux_design_tokens.generator', ['format' => 'css'])
+
+ ->set('.ux_design_tokens.generator.javascript', JavaScriptGenerator::class)
+ ->tag('ux_design_tokens.generator', ['format' => 'javascript'])
+
+ ->set('.ux_design_tokens.generator.tailwind', ThemeGenerator::class)
+ ->tag('ux_design_tokens.generator', ['format' => 'tailwind'])
+
+ ->set('.ux_design_tokens.color_scheme', ColorScheme::class)
+ ->args([
+ param('.ux_design_tokens.color_scheme.modifier'),
+ param('.ux_design_tokens.color_scheme.light'),
+ param('.ux_design_tokens.color_scheme.dark'),
+ ])
+
+ ->set('.ux_design_tokens.stylesheet_cache', StylesheetCache::class)
+ ->args([
+ service('.ux_design_tokens.resolver'),
+ service('.ux_design_tokens.generator.css'),
+ param('kernel.build_dir'),
+ param('.ux_design_tokens.resolver_inputs'),
+ service('.ux_design_tokens.color_scheme'),
+ param('kernel.debug'),
+ abstract_arg('fingerprint of the configuration, set by the bundle'),
+ ])
+
+ ->set('.ux_design_tokens.cache_warmer', DesignTokensCacheWarmer::class)
+ ->args([
+ service('.ux_design_tokens.stylesheet_cache'),
+ ])
+ ->tag('kernel.cache_warmer')
+
+ ->set('.ux_design_tokens.command.export', ExportCommand::class)
+ ->args([
+ service('.ux_design_tokens.registry'),
+ tagged_locator('ux_design_tokens.generator', 'format'),
+ param('.ux_design_tokens.css_prefix'),
+ service('.ux_design_tokens.color_scheme'),
+ ])
+ ->tag('console.command')
+
+ ->set('.ux_design_tokens.command.import', ImportCommand::class)
+ ->args([
+ tagged_locator('ux_design_tokens.importer', 'format'),
+ service('.ux_design_tokens.token_tree_builder'),
+ ])
+ ->tag('console.command')
+
+ ->set('.ux_design_tokens.command.lint', LintDesignTokensCommand::class)
+ ->args([
+ service('.ux_design_tokens.validator'),
+ service('.ux_design_tokens.normalizer'),
+ service('.ux_design_tokens.registry'),
+ param('.ux_design_tokens.paths'),
+ param('.ux_design_tokens.resolver_path'),
+ ])
+ ->tag('console.command')
+
+ ->set('.ux_design_tokens.command.debug', DebugTokensCommand::class)
+ ->args([
+ service('.ux_design_tokens.registry'),
+ service('.ux_design_tokens.resolver.configured'),
+ param('.ux_design_tokens.resolver_inputs'),
+ ])
+ ->tag('console.command')
+ ;
+
+ if (class_exists(Yaml::class)) {
+ $container->services()
+ ->set('.ux_design_tokens.generator.design_md', DesignMdGenerator::class)
+ ->tag('ux_design_tokens.generator', ['format' => 'design.md'])
+ ;
+ }
+};
diff --git a/src/DesignTokens/config/twig.php b/src/DesignTokens/config/twig.php
new file mode 100644
index 00000000000..5adec17b5e1
--- /dev/null
+++ b/src/DesignTokens/config/twig.php
@@ -0,0 +1,33 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use Symfony\UX\DesignTokens\Twig\DesignTokenExtension;
+use Symfony\UX\DesignTokens\Twig\DesignTokenRuntime;
+
+return static function (ContainerConfigurator $container): void {
+ $container->services()
+ ->set('.ux_design_tokens.twig_extension', DesignTokenExtension::class)
+ ->tag('twig.extension')
+
+ ->set('.ux_design_tokens.twig_runtime', DesignTokenRuntime::class)
+ ->args([
+ service('.ux_design_tokens.registry'),
+ service('.ux_design_tokens.color_scheme'),
+ service('.ux_design_tokens.stylesheet_cache'),
+ service('asset_mapper')->nullOnInvalid(),
+ service('.ux_design_tokens.generator.css'),
+ ])
+ ->tag('kernel.reset', ['method' => 'reset'])
+ ->tag('twig.runtime')
+ ;
+};
diff --git a/src/DesignTokens/doc/index.rst b/src/DesignTokens/doc/index.rst
new file mode 100644
index 00000000000..ca48f48b1ef
--- /dev/null
+++ b/src/DesignTokens/doc/index.rst
@@ -0,0 +1,618 @@
+Symfony UX Design Tokens
+========================
+
+.. caution::
+
+ **EXPERIMENTAL** This bundle is currently experimental and is likely to
+ change, possibly significantly, before its first stable release.
+
+A design token is a named design decision, such as a brand color or a spacing
+step. This bundle reads tokens written in the `DTCG 2025.10`_ format of the
+W3C Design Tokens Community Group, and hands their resolved values to CSS,
+Twig, PHP and other build tools.
+
+* **One source.** A brand color used in a stylesheet, a template, an email and
+ a design file lives in the token file, and everything else reads it.
+* **Values, not strings.** In PHP, a token keeps its DTCG structure: a color
+ has a color space and components. CSS is one projection among several.
+* **Resolution on the server.** A theme per user, brand or tenant is a Resolver
+ input, not a rebuild.
+
+Installation
+------------
+
+.. code-block:: terminal
+
+ $ composer require symfony/ux-design-tokens
+
+With Symfony Flex, the bundle is registered for you. Without Flex, register it
+in ``config/bundles.php``::
+
+ // config/bundles.php
+ return [
+ // ...
+ Symfony\UX\DesignTokens\UXDesignTokensBundle::class => ['all' => true],
+ ];
+
+Writing a token file
+--------------------
+
+A DTCG file is JSON: a tree of groups, where a node with ``$value`` is a token
+and its path is its position in the tree. Values are objects, not CSS strings:
+
+.. code-block:: json
+
+ {
+ "color": {
+ "palette": {
+ "$type": "color",
+ "blue-500": { "$value": { "colorSpace": "oklch", "components": [0.62, 0.18, 255] } },
+ "slate-900": { "$value": { "colorSpace": "oklch", "components": [0.24, 0.02, 255] } }
+ },
+ "action": {
+ "primary": { "$value": "{color.palette.blue-500}" }
+ },
+ "content": {
+ "default": { "$value": "{color.palette.slate-900}" }
+ }
+ },
+ "dimension": {
+ "$type": "dimension",
+ "radius": { "control": { "$value": { "value": 0.375, "unit": "rem" } } },
+ "spacing": { "md": { "$value": { "value": 1, "unit": "rem" } } }
+ }
+ }
+
+``$type`` set on a group applies to every token under it.
+``color.action.primary`` is an alias: it names a role and points at a palette
+token instead of repeating its value. A ``$ref`` JSON Pointer can also address
+a token, a group or a member of a value, in the same file or in another local
+file. Cycles, missing targets and values that do not match their type fail
+with the path of the token.
+
+The bundle supports the 13 DTCG token types: ``color``, ``dimension``,
+``number``, ``duration``, ``fontFamily``, ``fontWeight``, ``cubicBezier``,
+``strokeStyle``, ``border``, ``transition``, ``shadow``, ``gradient`` and
+``typography``. The last six are composite values made of the others.
+
+Keep token files outside ``assets/``, since AssetMapper publishes every file
+under that directory. The examples use a ``design/`` directory at the project
+root.
+
+Starting from a design tool
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Tokens Studio exports DTCG. Figma exports a variable collection as DTCG from
+the context menu of the collection in the variables panel, with these limits:
+
+* one file per mode, and nothing that ties the modes together;
+* an alias is exported as the value it points to; the link survives only in
+ ``$extensions.com.figma.aliasData``;
+* a number variable is a ``number`` without unit, even for a spacing or a
+ radius;
+* a string variable has the ``string`` type, which DTCG does not define, so the
+ file fails validation until that variable is removed;
+* numbers keep the 32-bit precision Figma stores, such as
+ ``0.699999988079071`` for ``0.7``.
+
+A Resolver document then ties the files together: a collection with one mode
+becomes a set, a collection with several modes a modifier. See
+`Themes with a Resolver`_.
+
+A Tailwind CSS v4 theme converts to DTCG with ``ux:design-tokens:import``; see
+`Importing a Tailwind theme`_.
+
+Configuration
+-------------
+
+.. code-block:: yaml
+
+ # config/packages/ux_design_tokens.yaml
+ ux_design_tokens:
+ paths:
+ - '%kernel.project_dir%/design/base.tokens.json'
+ - '%kernel.project_dir%/design/brand.tokens.json'
+ color_scheme:
+ modifier: scheme
+ light: light
+ dark: dark
+ css_prefix: dt
+
+========================= ================ =========== ===============================
+Option Type Default Purpose
+========================= ================ =========== ===============================
+``paths`` list of strings ``[]`` Token files, merged in order
+``resolver.path`` string or null ``null`` Resolver document
+``resolver.inputs`` map ``{}`` Context selected per modifier
+``color_scheme.modifier`` string ``scheme`` Modifier of the color schemes
+``color_scheme.light`` string ``light`` Context written to ``:root``
+``color_scheme.dark`` string ``dark`` Context written for dark
+``css_prefix`` string ``dt`` Prefix of the CSS variables
+========================= ================ =========== ===============================
+
+The bundle reads nothing until ``paths`` or ``resolver.path`` is set.
+
+Files in ``paths`` are merged in order: a later file overrides the tokens it
+redefines and keeps the others. Aliases are resolved after the merge, so an
+alias sees the final value. A list value, such as a ``fontFamily``, is
+replaced as a whole.
+
+A path is absolute or relative to the project directory, and may use container
+parameters. Every configured file is tracked, so editing it rebuilds the
+container. Environment variables work in paths and Resolver inputs:
+
+.. code-block:: yaml
+
+ # config/packages/ux_design_tokens.yaml
+ ux_design_tokens:
+ resolver:
+ path: '%env(resolve:DESIGN_TOKENS_RESOLVER)%'
+ inputs:
+ scheme: '%env(DESIGN_TOKENS_SCHEME)%'
+
+.. caution::
+
+ A path built from an environment variable must be **absolute**, or start
+ with ``%kernel.project_dir%``: its value is unknown while the container
+ compiles. Such a file is not tracked either, so run ``cache:clear`` after
+ changing it.
+
+A ``$ref`` can point at another document, and that document is read as data.
+Reads are confined to the project directory and the directories of the
+configured files, symlinks resolved. To read documents from elsewhere, such as
+an object store, decorate the service aliased on
+``Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface``.
+
+Each resolution is stored in the system cache the first time it is needed, and
+``cache:clear`` empties it. In debug mode, a resolution is computed again when
+one of the documents it read changes.
+
+Rendering the tokens as CSS
+---------------------------
+
+With TwigBundle installed, call ``ux_token_css()`` once in the document head:
+
+.. code-block:: twig
+
+ {# templates/base.html.twig #}
+ {% block stylesheets %}
+ {{ ux_token_css() }}
+ {% endblock %}
+
+It returns a complete ``
+
+Stylesheets read the variables and never mention the token file:
+
+.. code-block:: css
+
+ /* assets/styles/app.css */
+ .button {
+ background: var(--dt-color-action-primary);
+ color: var(--dt-color-content-default);
+ border-radius: var(--dt-dimension-radius-control);
+ padding: var(--dt-dimension-spacing-md);
+ }
+
+A few rules shape the output:
+
+* Aliases are resolved first: ``--dt-color-action-primary`` holds the value,
+ not a ``var()`` chain.
+* Color, duration, number and ``px`` dimension tokens get an ``@property``
+ rule, which types the variable and makes it animatable. A ``rem`` dimension
+ gets none, since an ``@property`` initial value must not depend on a font
+ size.
+* ``\3C`` is the CSS escape of ``<``, so no token value can close the
+ ``
+
+The page follows the operating system until ``data-theme="light"`` or
+``data-theme="dark"`` is set on ````. The comparison runs variable by
+variable, after composite tokens are split: a border whose color alone changes
+writes its value and its color, not its width.
+
+Linking a stylesheet instead of inlining it
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``ux_token_css()`` writes the CSS into every response. When the stylesheet is
+the same for every visitor, ``ux_token_stylesheet()`` links it instead, so the
+browser caches it. It needs AssetMapper:
+
+.. code-block:: twig
+
+ {# templates/base.html.twig #}
+ {{ ux_token_stylesheet() }}
+
+.. code-block:: html
+
+
+
+The file lives in the build directory. ``cache:warmup`` writes it, a debug
+kernel writes it again when a source changes, and ``asset-map:compile`` picks
+it up like any other asset. ``ux_token_css()`` inlines the same file while it
+is up to date.
+
+To produce the CSS in PHP, without the ``';
+ }
+
+ public function reset(): void
+ {
+ $this->stylesheetContents = false;
+ }
+
+ /**
+ * An inline tag never writes to the build directory, which may be read-only.
+ *
+ * @param array $inputs
+ */
+ private function css(array $inputs): string
+ {
+ if ([] === $inputs) {
+ if (false === $this->stylesheetContents) {
+ $this->stylesheetContents = $this->stylesheets?->read();
+ }
+ if (null !== $this->stylesheetContents) {
+ return $this->stylesheetContents;
+ }
+ }
+
+ $contexts = $this->colorScheme->contexts($this->tokenRegistry->getModifiers(), $inputs);
+ if (null === $contexts) {
+ return $this->css->generate($this->tokenRegistry->all($inputs));
+ }
+
+ return $this->css->generate($this->tokenRegistry->all($contexts[0]), [
+ GeneratorInterface::DARK_TOKENS => $this->tokenRegistry->all($contexts[1]),
+ ]);
+ }
+}
diff --git a/src/DesignTokens/src/UXDesignTokensBundle.php b/src/DesignTokens/src/UXDesignTokensBundle.php
new file mode 100644
index 00000000000..fbd8a05e578
--- /dev/null
+++ b/src/DesignTokens/src/UXDesignTokensBundle.php
@@ -0,0 +1,282 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens;
+
+use Symfony\Component\AssetMapper\AssetMapperInterface;
+use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
+use Symfony\Component\Config\Resource\FileResource;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Filesystem\Path;
+use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+use Symfony\UX\DesignTokens\Exception\RuntimeException;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Importer\ImporterInterface;
+
+/**
+ * @author Simon André
+ */
+final class UXDesignTokensBundle extends AbstractBundle
+{
+ public const ASSET_NAMESPACE = 'design-tokens';
+
+ protected string $extensionAlias = 'ux_design_tokens';
+
+ public function getPath(): string
+ {
+ return \dirname(__DIR__);
+ }
+
+ public function configure(DefinitionConfigurator $definition): void
+ {
+ $definition->rootNode()
+ ->children()
+ ->arrayNode('paths')
+ ->info('DTCG token files, merged in order.')
+ ->scalarPrototype()->cannotBeEmpty()->end()
+ ->defaultValue([])
+ ->end()
+ ->arrayNode('resolver')
+ ->info('A DTCG 2025.10 Resolver document and the contexts it selects by default.')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('path')
+ ->defaultNull()
+ ->cannotBeEmpty()
+ ->info('Path to a .resolver.json document.')
+ ->end()
+ ->arrayNode('inputs')
+ ->info('Context selected for each modifier, such as {scheme: dark}.')
+ ->normalizeKeys(false)
+ ->variablePrototype()
+ ->validate()
+ ->ifTrue(static fn (mixed $value): bool => !\is_string($value) && !\is_int($value) && !\is_float($value))
+ ->thenInvalid('Resolver input values must be strings or numbers.')
+ ->end()
+ ->end()
+ ->defaultValue([])
+ ->end()
+ ->end()
+ ->validate()
+ ->ifTrue(static fn (array $resolver): bool => [] !== $resolver['inputs'] && null === $resolver['path'])
+ ->thenInvalid('"resolver.inputs" requires "resolver.path": inputs select contexts of a Resolver document.')
+ ->end()
+ ->end()
+ ->arrayNode('color_scheme')
+ ->info('The Resolver modifier whose contexts the CSS output writes as the light and dark color schemes.')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('modifier')
+ ->defaultValue('scheme')
+ ->cannotBeEmpty()
+ ->info('Name of the modifier.')
+ ->end()
+ ->scalarNode('light')
+ ->defaultValue('light')
+ ->cannotBeEmpty()
+ ->info('Context written to :root.')
+ ->end()
+ ->scalarNode('dark')
+ ->defaultValue('dark')
+ ->cannotBeEmpty()
+ ->info('Context whose differences are written for prefers-color-scheme: dark and [data-theme="dark"].')
+ ->end()
+ ->end()
+ ->end()
+ ->scalarNode('css_prefix')
+ ->defaultValue('dt')
+ ->cannotBeEmpty()
+ ->validate()
+ ->ifTrue(static fn (mixed $value): bool => !\is_string($value) || !preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $value))
+ ->thenInvalid('The CSS prefix must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.')
+ ->end()
+ ->info('Application-owned prefix for generated CSS custom properties.')
+ ->end()
+ ->end()
+ ;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ $projectDir = $builder->getParameter('kernel.project_dir');
+ \assert(\is_string($projectDir));
+
+ $pathsConfig = $config['paths'] ?? [];
+ \assert(\is_array($pathsConfig));
+ /** @var list $pathsConfig */
+ $paths = array_map(
+ fn (string $path): string => $this->resolveProjectPath($path, $projectDir, $builder),
+ $pathsConfig,
+ );
+ $resolver = $config['resolver'];
+ \assert(\is_array($resolver));
+ $colorScheme = $config['color_scheme'];
+ \assert(\is_array($colorScheme));
+ $resolverPath = \is_string($resolver['path'] ?? null)
+ ? $this->resolveProjectPath($resolver['path'], $projectDir, $builder)
+ : null;
+
+ $container->parameters()
+ ->set('.ux_design_tokens.paths', $paths)
+ ->set('.ux_design_tokens.resolver_path', $resolverPath)
+ ->set('.ux_design_tokens.allowed_roots', $this->allowedRoots($paths, $resolverPath, $projectDir))
+ ->set('.ux_design_tokens.resolver_inputs', $resolver['inputs'])
+ ->set('.ux_design_tokens.color_scheme.modifier', $colorScheme['modifier'])
+ ->set('.ux_design_tokens.color_scheme.light', $colorScheme['light'])
+ ->set('.ux_design_tokens.color_scheme.dark', $colorScheme['dark'])
+ ->set('.ux_design_tokens.css_prefix', $config['css_prefix'])
+ ;
+
+ // A path built from an environment variable is unknown until runtime.
+ foreach ([...$paths, $resolverPath] as $path) {
+ if (!\is_string($path) || $this->holdsEnvPlaceholder($path, $builder)) {
+ continue;
+ }
+ if (is_file($path)) {
+ $builder->addResource(new FileResource($path));
+ } else {
+ $builder->fileExists($path);
+ }
+ }
+
+ $builder->registerForAutoconfiguration(GeneratorInterface::class)
+ ->addTag('ux_design_tokens.generator');
+
+ $builder->registerForAutoconfiguration(ImporterInterface::class)
+ ->addTag('ux_design_tokens.importer');
+
+ $container->import('../config/services.php');
+
+ // Invalidates a stylesheet written with another configuration.
+ $builder->getDefinition('.ux_design_tokens.stylesheet_cache')->replaceArgument(6, hash('xxh128', serialize([
+ $paths,
+ $resolverPath,
+ $resolver['inputs'],
+ $colorScheme,
+ $config['css_prefix'],
+ ])));
+
+ $bundles = $builder->hasParameter('kernel.bundles') ? $builder->getParameter('kernel.bundles') : [];
+
+ if (\is_array($bundles) && isset($bundles['FrameworkBundle'])) {
+ $container->import('../config/cache.php');
+ }
+
+ if (\is_array($bundles) && isset($bundles['TwigBundle'])) {
+ $container->import('../config/twig.php');
+ }
+
+ if ($this->isAssetMapperAvailable($builder)) {
+ $container->import('../config/asset_mapper.php');
+ }
+ }
+
+ /**
+ * @param list $paths
+ *
+ * @return list
+ */
+ private function allowedRoots(array $paths, ?string $resolverPath, string $projectDir): array
+ {
+ $roots = [];
+ foreach ([$projectDir, ...array_map('dirname', $paths), null !== $resolverPath ? \dirname($resolverPath) : null] as $directory) {
+ if (\is_string($directory) && false !== $real = realpath($directory)) {
+ $roots[$real] = true;
+ }
+ }
+
+ return array_keys($roots);
+ }
+
+ private function holdsEnvPlaceholder(string $value, ContainerBuilder $builder): bool
+ {
+ if (str_contains($value, '%env(')) {
+ return true;
+ }
+
+ $envs = [];
+ $builder->resolveEnvPlaceholders($value, null, $envs);
+
+ return [] !== $envs;
+ }
+
+ public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ if (!$this->isAssetMapperAvailable($builder)) {
+ return;
+ }
+
+ $buildDir = $builder->getParameter('kernel.build_dir');
+ \assert(\is_string($buildDir));
+
+ // AssetMapper refuses to start on a mapped directory that does not exist.
+ $directory = StylesheetCache::directory($buildDir);
+ try {
+ new Filesystem()->mkdir($directory);
+ } catch (IOExceptionInterface $e) {
+ throw new RuntimeException(\sprintf('Could not create the design token stylesheet directory "%s".', $directory), previous: $e);
+ }
+
+ $builder->prependExtensionConfig('framework', [
+ 'asset_mapper' => [
+ 'paths' => [
+ $directory => self::ASSET_NAMESPACE,
+ ],
+ ],
+ ]);
+ }
+
+ private function isAssetMapperAvailable(ContainerBuilder $builder): bool
+ {
+ if (!interface_exists(AssetMapperInterface::class)) {
+ return false;
+ }
+
+ // Before Symfony 8.2, FrameworkBundle provided the AssetMapper configuration.
+ if (!$builder->hasParameter('kernel.bundles_metadata')) {
+ return false;
+ }
+ $bundlesMetadata = $builder->getParameter('kernel.bundles_metadata');
+ if (!\is_array($bundlesMetadata)) {
+ return false;
+ }
+ if (isset($bundlesMetadata['AssetMapperBundle'])) {
+ return true;
+ }
+
+ $framework = $bundlesMetadata['FrameworkBundle'] ?? null;
+ $path = \is_array($framework) ? $framework['path'] ?? null : null;
+
+ return \is_string($path) && is_file($path.'/Resources/config/asset_mapper.php');
+ }
+
+ private function resolveProjectPath(string $path, string $projectDir, ContainerBuilder $builder): string
+ {
+ if ($this->holdsEnvPlaceholder($path, $builder)) {
+ return $path;
+ }
+
+ $resolved = $builder->getParameterBag()->resolveValue($path);
+ if (!\is_string($resolved)) {
+ throw new InvalidArgumentException(\sprintf('The design token path "%s" must resolve to a string.', $path));
+ }
+
+ return Path::isAbsolute($resolved) ? $resolved : Path::join($projectDir, $resolved);
+ }
+}
diff --git a/src/DesignTokens/src/Validation/ColorRangeInspector.php b/src/DesignTokens/src/Validation/ColorRangeInspector.php
new file mode 100644
index 00000000000..551120a15a6
--- /dev/null
+++ b/src/DesignTokens/src/Validation/ColorRangeInspector.php
@@ -0,0 +1,79 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Validation;
+
+use Symfony\UX\DesignTokens\Token\ColorToken;
+use Symfony\UX\DesignTokens\TokenTree;
+
+/**
+ * Color 4.2 component ranges are advice: they are reported, not enforced.
+ *
+ * @author Simon André
+ *
+ * @internal
+ */
+final class ColorRangeInspector
+{
+ /**
+ * @param array $resolvedTokens
+ *
+ * @return list one human-readable line per component out of range
+ */
+ public function inspect(array $resolvedTokens): array
+ {
+ $warnings = [];
+
+ foreach (TokenTree::flatten($resolvedTokens) as $path => $token) {
+ if (!$token instanceof ColorToken) {
+ continue;
+ }
+ $value = $token->getValue();
+ if (!\is_array($value) || !\is_string($space = $value['colorSpace'] ?? null)) {
+ continue;
+ }
+ $components = $value['components'] ?? null;
+ if (!\is_array($components)) {
+ continue;
+ }
+
+ foreach (array_values($components) as $index => $component) {
+ if (!\is_int($component) && !\is_float($component)) {
+ continue;
+ }
+ if (null !== $range = self::outOfRange($space, $index, $component)) {
+ $warnings[] = \sprintf('%s: component %d is %s, outside the usual range %s for "%s".', $path, $index, $component, $range, $space);
+ }
+ }
+ }
+
+ return $warnings;
+ }
+
+ /** @return string|null the expected range when the component falls outside it */
+ private static function outOfRange(string $space, int $index, int|float $component): ?string
+ {
+ return match (true) {
+ \in_array($space, ['srgb', 'srgb-linear', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020', 'xyz-d65', 'xyz-d50'], true) => self::between($component, 0, 1),
+ \in_array($space, ['hsl', 'hwb'], true) && 0 === $index => null,
+ \in_array($space, ['hsl', 'hwb'], true) => self::between($component, 0, 100),
+ \in_array($space, ['lab', 'lch'], true) && 0 === $index => self::between($component, 0, 100),
+ \in_array($space, ['oklab', 'oklch'], true) && 0 === $index => self::between($component, 0, 1),
+ \in_array($space, ['lch', 'oklch'], true) && 1 === $index => $component < 0 ? '[0, ∞)' : null,
+ default => null,
+ };
+ }
+
+ private static function between(int|float $component, int $min, int $max): ?string
+ {
+ return $component < $min || $component > $max ? \sprintf('[%d, %d]', $min, $max) : null;
+ }
+}
diff --git a/src/DesignTokens/src/Validation/DtcgValidator.php b/src/DesignTokens/src/Validation/DtcgValidator.php
new file mode 100644
index 00000000000..109f15b7d83
--- /dev/null
+++ b/src/DesignTokens/src/Validation/DtcgValidator.php
@@ -0,0 +1,136 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Validation;
+
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+use Symfony\UX\DesignTokens\Exception\RuntimeException;
+use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface;
+use Symfony\UX\DesignTokens\Resolver\ResolverDocument;
+use Symfony\UX\DesignTokens\Resolver\ResolverSource;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+
+/**
+ * @author Simon André
+ *
+ * @internal
+ */
+final class DtcgValidator
+{
+ private readonly ColorRangeInspector $colorRanges;
+
+ public function __construct(
+ private readonly TokenTreeBuilder $resolver,
+ ?ColorRangeInspector $colorRanges = null,
+ private readonly ?DocumentLoaderInterface $documentLoader = null,
+ ) {
+ $this->colorRanges = $colorRanges ?? new ColorRangeInspector();
+ }
+
+ /**
+ * @param bool $partial whether a Resolver completes the document with other
+ * sources, so that its references may point outside it
+ *
+ * @return list warnings worth surfacing, the document is valid either way
+ */
+ public function validateFile(string $path, bool $partial = false): array
+ {
+ if (!is_file($path)) {
+ throw new RuntimeException(\sprintf('Design token document not found: "%s".', $path));
+ }
+ try {
+ $json = new Filesystem()->readFile($path);
+ } catch (IOExceptionInterface $e) {
+ throw new RuntimeException(\sprintf('Could not read design token document: "%s".', $path), previous: $e);
+ }
+
+ $kind = match (true) {
+ str_ends_with($path, '.resolver.json') => 'resolver',
+ str_ends_with($path, '.tokens.json'), str_ends_with($path, '.tokens') => 'tokens',
+ default => null,
+ };
+ if (null === $kind) {
+ throw new InvalidArgumentException(\sprintf('Expected a .tokens.json, .tokens, or .resolver.json file, got "%s".', $path));
+ }
+
+ return $this->validateJson($json, $path, \dirname($path), $kind, $partial);
+ }
+
+ /**
+ * @param 'tokens'|'resolver'|null $kind
+ * @param bool $partial see validateFile()
+ *
+ * @return list warnings worth surfacing, the document is valid either way
+ */
+ public function validateJson(string $json, string $source = 'stdin', string $basePath = '', ?string $kind = null, bool $partial = false): array
+ {
+ try {
+ // Decoded as objects only to tell a top-level {} from a [].
+ $object = json_decode($json, false, 512, \JSON_THROW_ON_ERROR);
+ $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
+ } catch (\JsonException $error) {
+ throw new RuntimeException(\sprintf('Invalid JSON in "%s": %s', $source, $error->getMessage()), 0, $error);
+ }
+ if (!$object instanceof \stdClass || !\is_array($data)) {
+ throw new RuntimeException(\sprintf('DTCG document "%s" must be a JSON object at the top level.', $source));
+ }
+
+ return $this->validate($data, $source, $basePath, $kind, $partial);
+ }
+
+ /**
+ * @param array $data
+ * @param 'tokens'|'resolver'|null $kind
+ * @param bool $partial see validateFile()
+ *
+ * @return list warnings worth surfacing, the document is valid either way
+ */
+ public function validate(array $data, string $source = 'memory', string $basePath = '', ?string $kind = null, bool $partial = false): array
+ {
+ $kind ??= $this->looksLikeResolver($data) ? 'resolver' : 'tokens';
+ if ('resolver' === $kind) {
+ return $this->validateResolver($data, $basePath);
+ }
+
+ $source = new ResolverSource($data, $basePath, \in_array($source, ['stdin', 'memory'], true) ? null : $source);
+
+ return $this->colorRanges->inspect($partial ? $this->resolver->resolvePartial($source) : $this->resolver->resolveSources([$source]));
+ }
+
+ /**
+ * @param array $data
+ *
+ * @return list
+ */
+ private function validateResolver(array $data, string $basePath): array
+ {
+ $warnings = [];
+ $document = new ResolverDocument($data, $basePath, $this->documentLoader);
+ foreach ($document->getPermutations() as $inputs) {
+ $resolved = $this->resolver->resolveSources($document->sourceDescriptors($inputs));
+ foreach ($this->colorRanges->inspect($resolved) as $warning) {
+ $warnings[$warning] = true;
+ }
+ }
+
+ return array_keys($warnings);
+ }
+
+ /** @param array $data */
+ private function looksLikeResolver(array $data): bool
+ {
+ return '2025.10' === ($data['version'] ?? null)
+ && \is_array($data['resolutionOrder'] ?? null)
+ && (\array_key_exists('sets', $data) || \array_key_exists('modifiers', $data));
+ }
+}
diff --git a/src/DesignTokens/src/Validation/Normalizer.php b/src/DesignTokens/src/Validation/Normalizer.php
new file mode 100644
index 00000000000..dccb672cf6d
--- /dev/null
+++ b/src/DesignTokens/src/Validation/Normalizer.php
@@ -0,0 +1,65 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Validation;
+
+use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+use Symfony\UX\DesignTokens\Exception\RuntimeException;
+
+/**
+ * @author Simon André
+ *
+ * @internal
+ */
+final class Normalizer
+{
+ public function __construct(private readonly DtcgValidator $validator)
+ {
+ }
+
+ /**
+ * @param 'tokens'|'resolver'|null $kind
+ * @param bool $partial see DtcgValidator::validateFile()
+ */
+ public function normalize(string $json, string $source = 'stdin', string $basePath = '', ?string $kind = null, bool $partial = false): string
+ {
+ $this->validator->validateJson($json, $source, $basePath, $kind, $partial);
+
+ $document = json_decode($json, false, 512, \JSON_THROW_ON_ERROR);
+
+ return json_encode(
+ $document,
+ \JSON_PRETTY_PRINT | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR,
+ )."\n";
+ }
+
+ /** @param bool $partial see DtcgValidator::validateFile() */
+ public function normalizeFile(string $path, bool $partial = false): string
+ {
+ if (!is_file($path)) {
+ throw new RuntimeException(\sprintf('Design token document not found: "%s".', $path));
+ }
+ try {
+ $json = new Filesystem()->readFile($path);
+ } catch (IOExceptionInterface $e) {
+ throw new RuntimeException(\sprintf('Could not read design token document: "%s".', $path), previous: $e);
+ }
+ $kind = match (true) {
+ str_ends_with($path, '.resolver.json') => 'resolver',
+ str_ends_with($path, '.tokens.json'), str_ends_with($path, '.tokens') => 'tokens',
+ default => throw new InvalidArgumentException(\sprintf('Expected a .tokens.json, .tokens, or .resolver.json file, got "%s".', $path)),
+ };
+
+ return $this->normalize($json, $path, \dirname($path), $kind, $partial);
+ }
+}
diff --git a/src/DesignTokens/src/Validation/TokenValueValidator.php b/src/DesignTokens/src/Validation/TokenValueValidator.php
new file mode 100644
index 00000000000..da3e84759ca
--- /dev/null
+++ b/src/DesignTokens/src/Validation/TokenValueValidator.php
@@ -0,0 +1,306 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Validation;
+
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+
+/**
+ * @author Simon André
+ *
+ * @internal
+ */
+final class TokenValueValidator
+{
+ /** @var list */
+ public const TYPES = [
+ 'color', 'dimension', 'fontFamily', 'fontWeight', 'duration',
+ 'cubicBezier', 'number', 'strokeStyle', 'border', 'transition',
+ 'shadow', 'gradient', 'typography',
+ ];
+
+ private const COLOR_SPACES = [
+ 'srgb', 'srgb-linear', 'hsl', 'hwb', 'lab', 'lch', 'oklab',
+ 'oklch', 'display-p3', 'a98-rgb', 'prophoto-rgb', 'rec2020',
+ 'xyz-d65', 'xyz-d50',
+ ];
+
+ public const FONT_WEIGHTS = [
+ 'thin', 'hairline', 'extra-light', 'ultra-light', 'light', 'normal',
+ 'regular', 'book', 'medium', 'semi-bold', 'demi-bold', 'bold',
+ 'extra-bold', 'ultra-bold', 'black', 'heavy', 'extra-black',
+ 'ultra-black',
+ ];
+
+ public function validate(string $type, mixed $value, string $path = ''): void
+ {
+ if (!\in_array($type, self::TYPES, true)) {
+ throw new InvalidArgumentException(\sprintf('Unknown DTCG token type "%s" at "%s".', $type, $path));
+ }
+
+ match ($type) {
+ 'color' => $this->color($value, $path),
+ 'dimension' => $this->unit($value, ['px', 'rem'], $path, 'dimension'),
+ 'duration' => $this->unit($value, ['ms', 's'], $path, 'duration'),
+ 'number' => $this->number($value, $path),
+ 'fontFamily' => $this->fontFamily($value, $path),
+ 'fontWeight' => $this->fontWeight($value, $path),
+ 'cubicBezier' => $this->cubicBezier($value, $path),
+ 'strokeStyle' => $this->strokeStyle($value, $path),
+ 'gradient' => $this->gradient($value, $path),
+ 'border' => $this->border($value, $path),
+ 'shadow' => $this->shadow($value, $path),
+ 'transition' => $this->transition($value, $path),
+ 'typography' => $this->typography($value, $path),
+ };
+ }
+
+ private function color(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail('a structured color', $path);
+ }
+ $this->keys($value, ['colorSpace', 'components'], ['alpha', 'hex'], $path);
+
+ $space = $value['colorSpace'] ?? null;
+ if (!\is_string($space) || !\in_array($space, self::COLOR_SPACES, true)) {
+ $this->fail('a supported color space', $path.'.colorSpace');
+ }
+
+ $components = $value['components'] ?? null;
+ if (!\is_array($components) || !array_is_list($components) || 3 !== \count($components)) {
+ $this->fail('exactly three color components', $path.'.components');
+ }
+ foreach ($components as $index => $component) {
+ if ('none' === $component) {
+ continue;
+ }
+ if ((!\is_int($component) && !\is_float($component)) || (\is_float($component) && !is_finite($component))) {
+ $this->fail(\sprintf('a numeric color component at index %d', $index), $path.'.components');
+ }
+ // Component ranges are advice (Color 4.2); only the hue has a hard rule.
+ if (!$this->validHue($space, $index, $component)) {
+ $this->fail(\sprintf('a hue below 360 at index %d', $index), $path.'.components');
+ }
+ }
+
+ if (\array_key_exists('alpha', $value)
+ && (!$this->isNumber($value['alpha']) || $value['alpha'] < 0 || $value['alpha'] > 1)) {
+ $this->fail('an alpha value between 0 and 1', $path.'.alpha');
+ }
+ if (\array_key_exists('hex', $value)
+ && (!\is_string($value['hex']) || 1 !== preg_match('/^#[0-9a-fA-F]{6}$/D', $value['hex']))) {
+ $this->fail('a six-digit hexadecimal color fallback', $path.'.hex');
+ }
+ }
+
+ /** Color 4.2: "360 MUST NOT be used" for a hue. */
+ private function validHue(string $space, int $index, int|float $component): bool
+ {
+ return match (true) {
+ \in_array($space, ['hsl', 'hwb'], true) && 0 === $index,
+ \in_array($space, ['lch', 'oklch'], true) && 2 === $index => $component >= 0 && $component < 360,
+ default => true,
+ };
+ }
+
+ /** @param list $units */
+ private function unit(mixed $value, array $units, string $path, string $type): void
+ {
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail(\sprintf('a structured %s', $type), $path);
+ }
+ $this->keys($value, ['value', 'unit'], [], $path);
+ if (!$this->isNumber($value['value'])) {
+ $this->fail('a numeric value', $path.'.value');
+ }
+ if (!\in_array($value['unit'], $units, true)) {
+ $this->fail('unit '.implode('|', $units), $path.'.unit');
+ }
+ }
+
+ private function number(mixed $value, string $path): void
+ {
+ if (!$this->isNumber($value)) {
+ $this->fail('a finite number', $path);
+ }
+ }
+
+ private function fontFamily(mixed $value, string $path): void
+ {
+ if (\is_string($value) && !$this->isCurlyReference($value)) {
+ return;
+ }
+ if (!\is_array($value) || !array_is_list($value) || [] === $value) {
+ $this->fail('a string or non-empty list of strings', $path);
+ }
+ foreach ($value as $index => $family) {
+ if (!\is_string($family) || $this->isCurlyReference($family)) {
+ $this->fail('a string font family', $path.'['.$index.']');
+ }
+ }
+ }
+
+ private function fontWeight(mixed $value, string $path): void
+ {
+ if ($this->isNumber($value) && $value >= 1 && $value <= 1000) {
+ return;
+ }
+ if (\is_string($value) && \in_array($value, self::FONT_WEIGHTS, true)) {
+ return;
+ }
+ $this->fail('a font weight from 1 to 1000 or a standard keyword', $path);
+ }
+
+ private function cubicBezier(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || !array_is_list($value) || 4 !== \count($value)) {
+ $this->fail('a list of four cubic Bezier coordinates', $path);
+ }
+ foreach ($value as $index => $coordinate) {
+ if (!$this->isNumber($coordinate) || (0 === $index % 2 && ($coordinate < 0 || $coordinate > 1))) {
+ $this->fail('a valid cubic Bezier coordinate', $path.'['.$index.']');
+ }
+ }
+ }
+
+ private function strokeStyle(mixed $value, string $path): void
+ {
+ if (\is_string($value)) {
+ if (!\in_array($value, ['solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'outset', 'inset'], true)) {
+ $this->fail('a valid stroke style keyword', $path);
+ }
+
+ return;
+ }
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail('a structured stroke style', $path);
+ }
+ $this->keys($value, ['dashArray', 'lineCap'], [], $path);
+ if (!\is_array($value['dashArray']) || !array_is_list($value['dashArray']) || [] === $value['dashArray']) {
+ $this->fail('a non-empty stroke dash array', $path.'.dashArray');
+ }
+ foreach ($value['dashArray'] as $index => $dimension) {
+ $this->unit($dimension, ['px', 'rem'], $path.'.dashArray['.$index.']', 'dimension');
+ }
+ if (!\in_array($value['lineCap'], ['butt', 'round', 'square'], true)) {
+ $this->fail('a valid line cap', $path.'.lineCap');
+ }
+ }
+
+ private function gradient(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || !array_is_list($value) || [] === $value) {
+ $this->fail('a non-empty list of gradient stops', $path);
+ }
+ foreach ($value as $index => $stop) {
+ if (!\is_array($stop) || array_is_list($stop)) {
+ $this->fail('a gradient stop', $path.'['.$index.']');
+ }
+ $this->keys($stop, ['color', 'position'], [], $path.'['.$index.']');
+ $this->color($stop['color'], $path.'['.$index.'].color');
+ if (!$this->isNumber($stop['position'])) {
+ $this->fail('a finite gradient position', $path.'['.$index.'].position');
+ }
+ }
+ }
+
+ private function border(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail('a border object', $path);
+ }
+ $this->keys($value, ['color', 'width', 'style'], [], $path);
+ $this->color($value['color'], $path.'.color');
+ $this->unit($value['width'], ['px', 'rem'], $path.'.width', 'dimension');
+ $this->strokeStyle($value['style'], $path.'.style');
+ }
+
+ private function shadow(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || [] === $value) {
+ $this->fail('a shadow object or non-empty list of shadows', $path);
+ }
+ $shadows = array_is_list($value) ? $value : [$value];
+ foreach ($shadows as $index => $shadow) {
+ $itemPath = array_is_list($value) ? $path.'['.$index.']' : $path;
+ if (!\is_array($shadow) || array_is_list($shadow)) {
+ $this->fail('a shadow object', $itemPath);
+ }
+ $this->keys($shadow, ['color', 'offsetX', 'offsetY', 'blur', 'spread'], ['inset'], $itemPath);
+ $this->color($shadow['color'], $itemPath.'.color');
+ foreach (['offsetX', 'offsetY', 'blur', 'spread'] as $field) {
+ $this->unit($shadow[$field], ['px', 'rem'], $itemPath.'.'.$field, 'dimension');
+ }
+ if (\array_key_exists('inset', $shadow) && !\is_bool($shadow['inset'])) {
+ $this->fail('a boolean inset value', $itemPath.'.inset');
+ }
+ }
+ }
+
+ private function transition(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail('a transition object', $path);
+ }
+ $this->keys($value, ['duration', 'delay', 'timingFunction'], [], $path);
+ foreach (['duration', 'delay'] as $field) {
+ $this->unit($value[$field], ['ms', 's'], $path.'.'.$field, 'duration');
+ }
+ $this->cubicBezier($value['timingFunction'], $path.'.timingFunction');
+ }
+
+ private function typography(mixed $value, string $path): void
+ {
+ if (!\is_array($value) || array_is_list($value)) {
+ $this->fail('a typography object', $path);
+ }
+ $this->keys($value, ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'], [], $path);
+ $this->fontFamily($value['fontFamily'], $path.'.fontFamily');
+ $this->unit($value['fontSize'], ['px', 'rem'], $path.'.fontSize', 'dimension');
+ $this->fontWeight($value['fontWeight'], $path.'.fontWeight');
+ $this->unit($value['letterSpacing'], ['px', 'rem'], $path.'.letterSpacing', 'dimension');
+ $this->number($value['lineHeight'], $path.'.lineHeight');
+ }
+
+ /**
+ * @param array $value
+ * @param list $required
+ * @param list $optional
+ */
+ private function keys(array $value, array $required, array $optional, string $path): void
+ {
+ foreach ($required as $key) {
+ if (!\array_key_exists($key, $value)) {
+ $this->fail(\sprintf('required property "%s"', $key), $path);
+ }
+ }
+ $unknown = array_diff(array_keys($value), $required, $optional);
+ if ([] !== $unknown) {
+ $this->fail('no unknown properties ('.implode(', ', $unknown).')', $path);
+ }
+ }
+
+ private function isNumber(mixed $value): bool
+ {
+ return (\is_int($value) || \is_float($value)) && (!\is_float($value) || is_finite($value));
+ }
+
+ private function isCurlyReference(mixed $value): bool
+ {
+ return \is_string($value) && 1 === preg_match('/^\{[^{}]+\}$/D', $value);
+ }
+
+ private function fail(string $expected, string $path): never
+ {
+ throw new InvalidArgumentException(\sprintf('Expected %s for DTCG token at "%s".', $expected, $path));
+ }
+}
diff --git a/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php b/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php
new file mode 100644
index 00000000000..6b427775bdb
--- /dev/null
+++ b/src/DesignTokens/tests/Documentation/DocumentationExamplesTest.php
@@ -0,0 +1,152 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Documentation;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Yaml\Yaml;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\TokenRegistry;
+use Symfony\UX\DesignTokens\Twig\DesignTokenExtension;
+use Twig\Environment;
+use Twig\Error\SyntaxError;
+use Twig\Loader\ArrayLoader;
+use Twig\Source;
+
+final class DocumentationExamplesTest extends TestCase
+{
+ /** @return iterable */
+ public static function examples(): iterable
+ {
+ $root = \dirname(__DIR__, 2);
+ $files = [$root.'/README.md'];
+
+ foreach ($files as $file) {
+ $contents = (string) file_get_contents($file);
+ preg_match_all('/^```(json|yaml|php|twig|css)\h*\R(.*?)^```\h*$/ms', $contents, $matches, \PREG_OFFSET_CAPTURE);
+
+ foreach ($matches[1] as $index => [$language]) {
+ [$example, $offset] = $matches[2][$index];
+ $line = 1 + substr_count(substr($contents, 0, $offset), "\n");
+ $name = \sprintf('%s:%d (%s)', basename($file), $line, $language);
+
+ yield $name => [$language, $example, $name];
+ }
+ }
+
+ foreach (glob($root.'/doc/*.rst') ?: [] as $file) {
+ $contents = (string) file_get_contents($file);
+
+ foreach (self::rstExamples($contents) as [$language, $example, $line]) {
+ $name = \sprintf('%s:%d (%s)', basename($file), $line, $language);
+
+ yield $name => [$language, $example, $name];
+ }
+ }
+ }
+
+ #[DataProvider('examples')]
+ public function testDisplayedExampleIsSyntacticallyExecutable(string $language, string $example, string $name): void
+ {
+ try {
+ match ($language) {
+ 'json' => self::validateJson($example),
+ 'yaml' => Yaml::parse($example),
+ 'php' => \PhpToken::tokenize(str_starts_with(ltrim($example), ' self::parseTwig($example, $name),
+ 'css' => self::assertBalancedCss($example),
+ };
+ } catch (\JsonException|\ParseError|SyntaxError $error) {
+ self::fail($name.': '.$error->getMessage());
+ }
+
+ self::addToAssertionCount(1);
+ }
+
+ /** @return iterable */
+ private static function rstExamples(string $contents): iterable
+ {
+ $lines = preg_split('/\R/', $contents);
+ \assert(\is_array($lines));
+
+ for ($index = 0; $index < \count($lines); ++$index) {
+ $literalPhp = str_ends_with($lines[$index], '::');
+ if (!$literalPhp && 1 !== preg_match('/^\.\. code-block:: (json|yaml|php|twig|html\+twig|css)$/', $lines[$index], $matches)) {
+ continue;
+ }
+
+ $line = $index + 1;
+ while (isset($lines[$index + 1]) && ('' === $lines[$index + 1] || 1 === preg_match('/^ :[\w.-]+:(\s|$)/', $lines[$index + 1]))) {
+ ++$index;
+ }
+
+ $example = [];
+ while (isset($lines[$index + 1]) && ('' === $lines[$index + 1] || str_starts_with($lines[$index + 1], ' '))) {
+ ++$index;
+ $example[] = '' === $lines[$index] ? '' : substr($lines[$index], 4);
+ }
+
+ while ([] !== $example && '' === $example[array_key_last($example)]) {
+ array_pop($example);
+ }
+
+ if ($literalPhp && !str_ends_with($example[0] ?? '', '.php')) {
+ continue;
+ }
+
+ yield [$literalPhp ? 'php' : $matches[1], implode("\n", $example)."\n", $line];
+ }
+ }
+
+ private static function assertBalancedCss(string $css): null
+ {
+ self::assertSame(substr_count($css, '{'), substr_count($css, '}'), 'CSS braces are not balanced.');
+
+ return null;
+ }
+
+ private static function parseTwig(string $twig, string $name): null
+ {
+ $environment = new Environment(new ArrayLoader());
+ $environment->addExtension(new DesignTokenExtension(new TokenRegistry()));
+ $environment->parse($environment->tokenize(new Source($twig, $name)));
+
+ return null;
+ }
+
+ private static function validateJson(string $json): null
+ {
+ $document = json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
+
+ if (\is_array($document) && !isset($document['$schema']) && self::containsToken($document)) {
+ new TokenTreeBuilder()->resolve($document);
+ }
+
+ return null;
+ }
+
+ /** @param array $node */
+ private static function containsToken(array $node): bool
+ {
+ if (\array_key_exists('$value', $node)) {
+ return true;
+ }
+
+ foreach ($node as $value) {
+ if (\is_array($value) && self::containsToken($value)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/RecordingLogger.php b/src/DesignTokens/tests/Fixtures/RecordingLogger.php
new file mode 100644
index 00000000000..180d5205e7b
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/RecordingLogger.php
@@ -0,0 +1,25 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Fixtures;
+
+use Psr\Log\AbstractLogger;
+
+final class RecordingLogger extends AbstractLogger
+{
+ /** @var list}> */
+ public array $records = [];
+
+ public function log($level, \Stringable|string $message, array $context = []): void
+ {
+ $this->records[] = ['level' => (string) $level, 'message' => (string) $message, 'context' => $context];
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/Registries.php b/src/DesignTokens/tests/Fixtures/Registries.php
new file mode 100644
index 00000000000..3650ab45115
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/Registries.php
@@ -0,0 +1,52 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Fixtures;
+
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader;
+use Symfony\UX\DesignTokens\TokenRegistry;
+
+final class Registries
+{
+ /**
+ * @param array $document
+ * @param array $defaultInputs
+ */
+ public static function fromArray(array $document, array $defaultInputs = []): TokenRegistry
+ {
+ return new TokenRegistry(new ConfiguredTokenResolver(new ArrayDocumentLoader(['memory.tokens.json' => $document]), ['memory.tokens.json']), $defaultInputs);
+ }
+
+ public static function fromJson(string $json): TokenRegistry
+ {
+ $document = json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
+ \assert(\is_array($document));
+
+ return self::fromArray($document);
+ }
+
+ /**
+ * @param list $paths
+ * @param array $resolverInputs
+ */
+ public static function fromFiles(array $paths = [], ?string $resolverPath = null, array $resolverInputs = [], ?CacheInterface $cache = null, bool $debug = false): TokenRegistry
+ {
+ return new TokenRegistry(new ConfiguredTokenResolver(new JsonDocumentLoader(), $paths, $resolverPath, $cache, $debug), $resolverInputs);
+ }
+
+ public static function colorScheme(string $resolver = 'theme.resolver.json'): TokenRegistry
+ {
+ return self::fromFiles(resolverPath: __DIR__.'/color-scheme/'.$resolver);
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/ResolverDocuments.php b/src/DesignTokens/tests/Fixtures/ResolverDocuments.php
new file mode 100644
index 00000000000..e648ac263b8
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/ResolverDocuments.php
@@ -0,0 +1,66 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Fixtures;
+
+use Symfony\UX\DesignTokens\Resolver\ResolverDocument;
+use Symfony\UX\DesignTokens\Resolver\ResolverSource;
+
+final class ResolverDocuments
+{
+ /**
+ * @param array $inputs
+ *
+ * @return list>
+ */
+ public static function sources(ResolverDocument $document, array $inputs = []): array
+ {
+ return array_map(static fn (ResolverSource $source): array => $source->tokens, $document->sourceDescriptors($inputs));
+ }
+
+ /**
+ * @param array $inputs
+ *
+ * @return array
+ */
+ public static function merged(ResolverDocument $document, array $inputs = []): array
+ {
+ $merged = [];
+ foreach (self::sources($document, $inputs) as $source) {
+ $merged = self::merge($merged, $source);
+ }
+
+ return $merged;
+ }
+
+ /**
+ * @param array $base
+ * @param array $override
+ *
+ * @return array
+ */
+ private static function merge(array $base, array $override): array
+ {
+ foreach ($override as $key => $value) {
+ $base[$key] = isset($base[$key]) && \is_array($base[$key]) && \is_array($value) && !self::isToken($base[$key]) && !self::isToken($value) && !array_is_list($value)
+ ? self::merge($base[$key], $value)
+ : $value;
+ }
+
+ return $base;
+ }
+
+ /** @param array $value */
+ private static function isToken(array $value): bool
+ {
+ return \array_key_exists('$value', $value) || \array_key_exists('$ref', $value);
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php b/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php
new file mode 100644
index 00000000000..c54909996ef
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/TemporaryDirectory.php
@@ -0,0 +1,54 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Fixtures;
+
+use Symfony\Component\Filesystem\Filesystem;
+
+final class TemporaryDirectory
+{
+ private readonly string $root;
+ private readonly Filesystem $filesystem;
+
+ public function __construct()
+ {
+ $this->filesystem = new Filesystem();
+ $this->root = sys_get_temp_dir().'/dt-tests-'.bin2hex(random_bytes(5));
+ $this->filesystem->mkdir($this->root);
+ }
+
+ public function path(string $name = ''): string
+ {
+ return '' === $name ? $this->root : $this->root.'/'.$name;
+ }
+
+ /** @param string|array $contents */
+ public function write(string $name, string|array $contents): string
+ {
+ $path = $this->path($name);
+ $this->filesystem->dumpFile($path, \is_array($contents) ? json_encode($contents, \JSON_THROW_ON_ERROR) : $contents);
+
+ return $path;
+ }
+
+ public function mkdir(string $name): string
+ {
+ $path = $this->path($name);
+ $this->filesystem->mkdir($path);
+
+ return $path;
+ }
+
+ public function remove(): void
+ {
+ $this->filesystem->remove($this->root);
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/TokenValues.php b/src/DesignTokens/tests/Fixtures/TokenValues.php
new file mode 100644
index 00000000000..d8533c3dc1b
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/TokenValues.php
@@ -0,0 +1,73 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Fixtures;
+
+final class TokenValues
+{
+ /** @return array{colorSpace: string, components: list} */
+ public static function color(float $red = 0.2, float $green = 0.4, float $blue = 0.8): array
+ {
+ return ['colorSpace' => 'srgb', 'components' => [$red, $green, $blue]];
+ }
+
+ /** @return array{value: int|float, unit: string} */
+ public static function dimension(int|float $value, string $unit = 'px'): array
+ {
+ return ['value' => $value, 'unit' => $unit];
+ }
+
+ /** @return array */
+ public static function shadow(bool $inset = false): array
+ {
+ return [
+ 'color' => self::color(),
+ 'offsetX' => self::dimension(0),
+ 'offsetY' => self::dimension(2),
+ 'blur' => self::dimension(4),
+ 'spread' => self::dimension(0),
+ 'inset' => $inset,
+ ];
+ }
+
+ /** @return array */
+ public static function border(): array
+ {
+ return ['width' => self::dimension(1), 'style' => 'solid', 'color' => self::color()];
+ }
+
+ /** @return array */
+ public static function transition(): array
+ {
+ return [
+ 'duration' => self::dimension(250, 'ms'),
+ 'timingFunction' => [0.4, 0, 0.2, 1],
+ 'delay' => self::dimension(0, 'ms'),
+ ];
+ }
+
+ /**
+ * @param array $overrides
+ *
+ * @return array
+ */
+ public static function typography(array $overrides = []): array
+ {
+ return [
+ 'fontFamily' => ['Inter', 'sans-serif'],
+ 'fontSize' => self::dimension(32),
+ 'fontWeight' => 700,
+ 'letterSpacing' => self::dimension(0),
+ 'lineHeight' => 1.2,
+ ...$overrides,
+ ];
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/base.tokens.json b/src/DesignTokens/tests/Fixtures/base.tokens.json
new file mode 100644
index 00000000000..5b4ac796221
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/base.tokens.json
@@ -0,0 +1,34 @@
+{
+ "$description": "Fixture token file for tests",
+ "color": {
+ "$type": "color",
+ "brand": {
+ "primary": { "$value": { "colorSpace": "srgb", "components": [0.231, 0.51, 0.965] }, "$description": "Brand blue" },
+ "secondary": { "$value": { "colorSpace": "srgb", "components": [0.388, 0.4, 0.945] } }
+ },
+ "neutral": {
+ "white": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } },
+ "black": { "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } }
+ }
+ },
+ "size": {
+ "$type": "dimension",
+ "base": { "$value": { "value": 8, "unit": "px" } },
+ "large": { "$value": { "value": 16, "unit": "px" } }
+ },
+ "typography": {
+ "heading": {
+ "$type": "typography",
+ "$value": {
+ "fontFamily": ["Inter", "sans-serif"],
+ "fontSize": "2rem",
+ "fontWeight": "700",
+ "lineHeight": "1.2"
+ }
+ }
+ },
+ "alias": {
+ "$type": "color",
+ "accent": { "$value": "{color.brand.primary}" }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json
new file mode 100644
index 00000000000..fc1aa9ec0ac
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/color-scheme/dark.tokens.json
@@ -0,0 +1,27 @@
+{
+ "color": {
+ "action": {
+ "$type": "color",
+ "primary": { "$value": "{color.palette.primary-dark}" },
+ "primary-hover": { "$value": "{color.palette.primary-dark}" },
+ "on-primary": { "$value": "{color.palette.ink}" }
+ },
+ "content": {
+ "$type": "color",
+ "default": { "$value": "{color.palette.surface-light}" },
+ "muted": { "$value": "{color.palette.muted-dark}" }
+ },
+ "surface": {
+ "$type": "color",
+ "canvas": { "$value": "{color.palette.ink}" },
+ "subtle": { "$value": "{color.palette.surface-dark}" }
+ },
+ "border": { "$type": "color", "default": { "$value": "{color.palette.muted-light}" } },
+ "focus": { "$type": "color", "ring": { "$value": "{color.palette.primary-dark}" } },
+ "selection": {
+ "$type": "color",
+ "background": { "$value": "{color.palette.primary-dark}" },
+ "foreground": { "$value": "{color.palette.ink}" }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json
new file mode 100644
index 00000000000..642665e009c
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/color-scheme/foundation.tokens.json
@@ -0,0 +1,34 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "white": { "$value": { "colorSpace": "srgb", "components": [1, 1, 1] } },
+ "ink": { "$value": { "colorSpace": "srgb", "components": [0.01, 0.02, 0.05] } },
+ "muted-light": { "$value": { "colorSpace": "srgb", "components": [0.4, 0.45, 0.55] } },
+ "muted-dark": { "$value": { "colorSpace": "srgb", "components": [0.6, 0.65, 0.75] } },
+ "surface-light": { "$value": { "colorSpace": "srgb", "components": [0.95, 0.96, 0.98] } },
+ "surface-dark": { "$value": { "colorSpace": "srgb", "components": [0.1, 0.15, 0.2] } },
+ "border": { "$value": { "colorSpace": "srgb", "components": [0.7, 0.75, 0.8] } },
+ "primary-light": { "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.8] } },
+ "primary-dark": { "$value": { "colorSpace": "srgb", "components": [0.5, 0.6, 0.9] } }
+ }
+ },
+ "font": {
+ "family": { "ui": { "$type": "fontFamily", "$value": ["Inter", "sans-serif"] } },
+ "size": { "body": { "$type": "dimension", "$value": { "value": 1, "unit": "rem" } } }
+ },
+ "number": {
+ "line-height": { "body": { "$type": "number", "$value": 1.5 } }
+ },
+ "dimension": {
+ "spacing": {
+ "$type": "dimension",
+ "xs": { "$value": { "value": 0.25, "unit": "rem" } },
+ "sm": { "$value": { "value": 0.5, "unit": "rem" } },
+ "md": { "$value": { "value": 0.75, "unit": "rem" } },
+ "lg": { "$value": { "value": 1, "unit": "rem" } }
+ },
+ "radius": { "control": { "$type": "dimension", "$value": { "value": 0.5, "unit": "rem" } } },
+ "border": { "width": { "$type": "dimension", "$value": { "value": 1, "unit": "px" } } }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json b/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json
new file mode 100644
index 00000000000..60ac9ab38f3
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/color-scheme/light.tokens.json
@@ -0,0 +1,27 @@
+{
+ "color": {
+ "action": {
+ "$type": "color",
+ "primary": { "$value": "{color.palette.primary-light}" },
+ "primary-hover": { "$value": "{color.palette.primary-light}" },
+ "on-primary": { "$value": "{color.palette.white}" }
+ },
+ "content": {
+ "$type": "color",
+ "default": { "$value": "{color.palette.ink}" },
+ "muted": { "$value": "{color.palette.muted-light}" }
+ },
+ "surface": {
+ "$type": "color",
+ "canvas": { "$value": "{color.palette.white}" },
+ "subtle": { "$value": "{color.palette.surface-light}" }
+ },
+ "border": { "$type": "color", "default": { "$value": "{color.palette.border}" } },
+ "focus": { "$type": "color", "ring": { "$value": "{color.palette.primary-light}" } },
+ "selection": {
+ "$type": "color",
+ "background": { "$value": "{color.palette.primary-light}" },
+ "foreground": { "$value": "{color.palette.white}" }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json b/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json
new file mode 100644
index 00000000000..076d8d57091
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/color-scheme/named-theme.resolver.json
@@ -0,0 +1,61 @@
+{
+ "version": "2025.10",
+ "sets": {
+ "foundation": {
+ "sources": [
+ { "$ref": "foundation.tokens.json" }
+ ]
+ }
+ },
+ "modifiers": {
+ "mode": {
+ "contexts": {
+ "day": [
+ { "$ref": "light.tokens.json" }
+ ],
+ "night": [
+ { "$ref": "dark.tokens.json" }
+ ]
+ },
+ "default": "day"
+ },
+ "package": {
+ "contexts": {
+ "default": [{ "$ref": "foundation.tokens.json" }],
+ "map": [{
+ "color": {
+ "action": {
+ "primary": {
+ "$type": "color",
+ "$value": { "colorSpace": "srgb", "components": [0.1, 0.7, 0.5] }
+ }
+ }
+ }
+ }]
+ },
+ "default": "default"
+ },
+ "surface": {
+ "contexts": {
+ "showcase": [{ "$ref": "foundation.tokens.json" }],
+ "docs": [{
+ "font": {
+ "size": {
+ "body": {
+ "$type": "dimension",
+ "$value": { "value": 1.125, "unit": "rem" }
+ }
+ }
+ }
+ }]
+ },
+ "default": "showcase"
+ }
+ },
+ "resolutionOrder": [
+ { "$ref": "#/sets/foundation" },
+ { "$ref": "#/modifiers/mode" },
+ { "$ref": "#/modifiers/package" },
+ { "$ref": "#/modifiers/surface" }
+ ]
+}
diff --git a/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json b/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json
new file mode 100644
index 00000000000..e56d5365cb9
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/color-scheme/theme.resolver.json
@@ -0,0 +1,61 @@
+{
+ "version": "2025.10",
+ "sets": {
+ "foundation": {
+ "sources": [
+ { "$ref": "foundation.tokens.json" }
+ ]
+ }
+ },
+ "modifiers": {
+ "scheme": {
+ "contexts": {
+ "light": [
+ { "$ref": "light.tokens.json" }
+ ],
+ "dark": [
+ { "$ref": "dark.tokens.json" }
+ ]
+ },
+ "default": "light"
+ },
+ "package": {
+ "contexts": {
+ "default": [{ "$ref": "foundation.tokens.json" }],
+ "map": [{
+ "color": {
+ "action": {
+ "primary": {
+ "$type": "color",
+ "$value": { "colorSpace": "srgb", "components": [0.1, 0.7, 0.5] }
+ }
+ }
+ }
+ }]
+ },
+ "default": "default"
+ },
+ "surface": {
+ "contexts": {
+ "showcase": [{ "$ref": "foundation.tokens.json" }],
+ "docs": [{
+ "font": {
+ "size": {
+ "body": {
+ "$type": "dimension",
+ "$value": { "value": 1.125, "unit": "rem" }
+ }
+ }
+ }
+ }]
+ },
+ "default": "showcase"
+ }
+ },
+ "resolutionOrder": [
+ { "$ref": "#/sets/foundation" },
+ { "$ref": "#/modifiers/scheme" },
+ { "$ref": "#/modifiers/package" },
+ { "$ref": "#/modifiers/surface" }
+ ]
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json b/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json
new file mode 100644
index 00000000000..d2af37112f3
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/format-color-invalid-values.json
@@ -0,0 +1,40 @@
+{
+ "color/legacy-string": ["color", "#000000"],
+ "color/unknown-space": ["color", {"colorSpace": "rgb", "components": [0, 0, 0]}],
+ "color/missing-components": ["color", {"colorSpace": "srgb"}],
+ "color/two-components": ["color", {"colorSpace": "srgb", "components": [0, 0]}],
+ "color/component-type": ["color", {"colorSpace": "srgb", "components": [true, 0, 0]}],
+ "color/srgb-below": ["color", {"colorSpace": "srgb", "components": [-0.001, 0, 0]}],
+ "color/srgb-above": ["color", {"colorSpace": "srgb", "components": [1.001, 0, 0]}],
+ "color/hue-negative": ["color", {"colorSpace": "hsl", "components": [-0.001, 0, 0]}],
+ "color/hue-360-exclusive": ["color", {"colorSpace": "hsl", "components": [360, 0, 0]}],
+ "color/hsl-percent-above": ["color", {"colorSpace": "hsl", "components": [0, 100.001, 0]}],
+ "color/lab-lightness-above": ["color", {"colorSpace": "lab", "components": [100.001, 0, 0]}],
+ "color/lch-negative-chroma": ["color", {"colorSpace": "lch", "components": [50, -0.001, 0]}],
+ "color/oklab-lightness-above": ["color", {"colorSpace": "oklab", "components": [1.001, 0, 0]}],
+ "color/alpha-below": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": -0.001}],
+ "color/alpha-above": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": 1.001}],
+ "color/hex-short": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "hex": "#000"}],
+ "color/hex-alpha": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "hex": "#000000ff"}],
+ "dimension/string": ["dimension", "1px"],
+ "dimension/unknown-unit": ["dimension", {"value": 1, "unit": "em"}],
+ "duration/unknown-unit": ["duration", {"value": 1, "unit": "min"}],
+ "font-family/empty-list": ["fontFamily", []],
+ "font-family/non-string": ["fontFamily", ["Inter", 42]],
+ "font-weight/below": ["fontWeight", 0],
+ "font-weight/above": ["fontWeight", 1001],
+ "font-weight/case": ["fontWeight", "Bold"],
+ "cubic-bezier/arity": ["cubicBezier", [0, 0, 1]],
+ "cubic-bezier/x-below": ["cubicBezier", [-0.001, 0, 1, 1]],
+ "number/string": ["number", "1"],
+ "stroke-style/keyword": ["strokeStyle", "hidden"],
+ "stroke-style/empty-dashes": ["strokeStyle", {"dashArray": [], "lineCap": "round"}],
+ "border/missing-style": ["border", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "width": {"value": 1, "unit": "px"}}],
+ "transition/extra-property": ["transition", {"duration": {"value": 100, "unit": "ms"}, "delay": {"value": 0, "unit": "ms"}, "timingFunction": [0, 0, 1, 1], "property": "opacity"}],
+ "shadow/empty-list": ["shadow", []],
+ "shadow/inset-number": ["shadow", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "offsetX": {"value": 0, "unit": "px"}, "offsetY": {"value": 1, "unit": "px"}, "blur": {"value": 2, "unit": "px"}, "spread": {"value": 0, "unit": "px"}, "inset": 1}],
+ "gradient/empty-list": ["gradient", []],
+ "gradient/non-finite-position-surrogate": ["gradient", [{"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "position": "Infinity"}]],
+ "typography/missing-letter-spacing": ["typography", {"fontFamily": "Inter", "fontSize": {"value": 16, "unit": "px"}, "fontWeight": 400, "lineHeight": 1.5}],
+ "unknown-type": ["string", "legacy"]
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json b/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json
new file mode 100644
index 00000000000..536f122b853
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/format-color-valid-values.json
@@ -0,0 +1,35 @@
+{
+ "color/srgb-min": ["color", {"colorSpace": "srgb", "components": [0, 0, 0], "alpha": 0, "hex": "#000000"}],
+ "color/srgb-max": ["color", {"colorSpace": "srgb", "components": [1, 1, 1], "alpha": 1, "hex": "#FFFFFF"}],
+ "color/srgb-linear": ["color", {"colorSpace": "srgb-linear", "components": [0.1, 0.5, 0.9]}],
+ "color/hsl-none": ["color", {"colorSpace": "hsl", "components": ["none", 0, 100]}],
+ "color/hsl-upper-exclusive": ["color", {"colorSpace": "hsl", "components": [359.999, 100, 100]}],
+ "color/hwb": ["color", {"colorSpace": "hwb", "components": [0, 100, 0]}],
+ "color/lab-unbounded": ["color", {"colorSpace": "lab", "components": [100, -1000000, 1000000]}],
+ "color/lch-unbounded-chroma": ["color", {"colorSpace": "lch", "components": [50, 1000000, 359.999]}],
+ "color/oklab-unbounded": ["color", {"colorSpace": "oklab", "components": [1, -1000000, 1000000]}],
+ "color/oklch-unbounded-chroma": ["color", {"colorSpace": "oklch", "components": [0.5, 1000000, 359.999]}],
+ "color/display-p3": ["color", {"colorSpace": "display-p3", "components": [0, 0.5, 1]}],
+ "color/a98-rgb": ["color", {"colorSpace": "a98-rgb", "components": [0, 0.5, 1]}],
+ "color/prophoto-rgb": ["color", {"colorSpace": "prophoto-rgb", "components": [0, 0.5, 1]}],
+ "color/rec2020": ["color", {"colorSpace": "rec2020", "components": [0, 0.5, 1]}],
+ "color/xyz-d65": ["color", {"colorSpace": "xyz-d65", "components": [0, 0.5, 1]}],
+ "color/xyz-d50": ["color", {"colorSpace": "xyz-d50", "components": [0, 0.5, 1]}],
+ "dimension/negative": ["dimension", {"value": -1.5, "unit": "rem"}],
+ "dimension/zero-unit-required": ["dimension", {"value": 0, "unit": "px"}],
+ "duration/negative-allowed": ["duration", {"value": -1.5, "unit": "s"}],
+ "font-family/string": ["fontFamily", "Inter"],
+ "font-family/list": ["fontFamily", ["Inter", "sans-serif"]],
+ "font-weight/min": ["fontWeight", 1],
+ "font-weight/max": ["fontWeight", 1000],
+ "font-weight/keyword": ["fontWeight", "extra-black"],
+ "cubic-bezier/unbounded-y": ["cubicBezier", [0, -1000000, 1, 1000000]],
+ "number/negative": ["number", -1.5],
+ "stroke-style/keyword": ["strokeStyle", "inset"],
+ "stroke-style/object": ["strokeStyle", {"dashArray": [{"value": 1, "unit": "px"}], "lineCap": "square"}],
+ "border/object": ["border", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "width": {"value": 1, "unit": "px"}, "style": "solid"}],
+ "transition/object": ["transition", {"duration": {"value": 100, "unit": "ms"}, "delay": {"value": 0, "unit": "ms"}, "timingFunction": [0, 0, 1, 1]}],
+ "shadow/single": ["shadow", {"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "offsetX": {"value": 0, "unit": "px"}, "offsetY": {"value": 1, "unit": "px"}, "blur": {"value": 2, "unit": "px"}, "spread": {"value": 0, "unit": "px"}, "inset": false}],
+ "gradient/out-of-range-is-clamped": ["gradient", [{"color": {"colorSpace": "srgb", "components": [0, 0, 0]}, "position": -99}, {"color": {"colorSpace": "srgb", "components": [1, 1, 1]}, "position": 42}]],
+ "typography/object": ["typography", {"fontFamily": "Inter", "fontSize": {"value": 16, "unit": "px"}, "fontWeight": 400, "letterSpacing": {"value": 0, "unit": "px"}, "lineHeight": 1.5}]
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json b/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json
new file mode 100644
index 00000000000..68c5f542a23
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/format-invalid-legacy-values.tokens.json
@@ -0,0 +1,12 @@
+{
+ "legacy": {
+ "dimension": {
+ "$type": "dimension",
+ "$value": "16px"
+ },
+ "color": {
+ "$type": "color",
+ "$value": "#3366ff"
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json b/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json
new file mode 100644
index 00000000000..93df9710eed
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/format-valid.tokens.json
@@ -0,0 +1,37 @@
+{
+ "$description": "Minimal DTCG 2025.10 format fixture",
+ "brand": {
+ "$type": "color",
+ "primary": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [0.2, 0.4, 0.8],
+ "alpha": 1
+ }
+ }
+ },
+ "space": {
+ "$type": "dimension",
+ "$root": {
+ "$value": {
+ "value": 16,
+ "unit": "px"
+ }
+ },
+ "large": {
+ "$value": {
+ "value": 2,
+ "unit": "rem"
+ }
+ }
+ },
+ "motion": {
+ "duration": {
+ "$type": "duration",
+ "$value": {
+ "value": 250,
+ "unit": "ms"
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json
new file mode 100644
index 00000000000..6977f4c5a1f
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-complete.resolver.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json",
+ "version": "2025.10",
+ "sets": {
+ "foundation": {
+ "sources": [
+ {
+ "color": {
+ "surface": { "$type": "number", "$value": 0 }
+ },
+ "space": {
+ "control": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } }
+ }
+ }
+ ]
+ }
+ },
+ "modifiers": {
+ "theme": {
+ "contexts": {
+ "light": [{ "color": { "surface": { "$type": "number", "$value": 1 } } }],
+ "dark": [{ "color": { "surface": { "$type": "number", "$value": 2 } } }]
+ },
+ "default": "light"
+ }
+ },
+ "resolutionOrder": [
+ { "$ref": "#/sets/foundation" },
+ { "$ref": "#/modifiers/theme" },
+ {
+ "type": "modifier",
+ "name": "density",
+ "contexts": {
+ "comfortable": [{ "space": { "control": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } } } }],
+ "compact": [{ "space": { "control": { "$type": "dimension", "$value": { "value": 4, "unit": "px" } } } }]
+ }
+ },
+ {
+ "type": "set",
+ "name": "component",
+ "sources": [{ "component": { "precedence": { "$type": "number", "$value": 3 } } }]
+ }
+ ]
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json
new file mode 100644
index 00000000000..31b9ea10582
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-invalid-cases.json
@@ -0,0 +1,181 @@
+{
+ "missing version": {
+ "message": "must declare version",
+ "document": {
+ "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }]
+ }
+ },
+ "report table date is not the schema identifier": {
+ "message": "must declare version",
+ "document": {
+ "version": "2025-10-01",
+ "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }]
+ }
+ },
+ "report prose date is not the schema identifier": {
+ "message": "must declare version",
+ "document": {
+ "version": "2025-11-01",
+ "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }]
+ }
+ },
+ "unknown root property": {
+ "message": "unsupported property",
+ "document": {
+ "version": "2025.10",
+ "unknown": true,
+ "resolutionOrder": [{ "type": "set", "name": "base", "sources": [] }]
+ }
+ },
+ "set without sources": {
+ "message": "sources array",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "description": "Missing sources." } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "set with unknown property": {
+ "message": "unsupported property",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [], "unknown": true } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "set description must be text": {
+ "message": "description",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "description": 42, "sources": [] } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "set extensions must be object": {
+ "message": "$extensions",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "$extensions": ["invalid"], "sources": [] } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "source must be object": {
+ "message": "must be an object",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [42] } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "modifier contexts cannot be empty": {
+ "message": "non-empty contexts",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": {} } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "single-context modifier should be a set": {
+ "message": "at least two contexts",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": { "light": [] } } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "modifier context source must be array": {
+ "message": "array of token sources",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": { "light": { "unexpected": true }, "dark": [] } } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "modifier default must select a context": {
+ "message": "must match a context key",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": { "light": [], "dark": [] }, "default": "other" } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "modifier with unknown property": {
+ "message": "unsupported property",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": { "light": [], "dark": [] }, "unknown": true } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "case-insensitive contexts must not be ambiguous": {
+ "message": "differ only by case",
+ "document": {
+ "version": "2025.10",
+ "modifiers": { "theme": { "contexts": { "light": [], "LIGHT": [] } } },
+ "resolutionOrder": [{ "$ref": "#/modifiers/theme" }]
+ }
+ },
+ "resolution order names must be unique": {
+ "message": "name \"base\" is duplicated",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [] } },
+ "resolutionOrder": [
+ { "$ref": "#/sets/base" },
+ { "type": "set", "name": "base", "sources": [] }
+ ]
+ }
+ },
+ "referenced resolution order names must be unique": {
+ "message": "name \"base\" is duplicated",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [] } },
+ "resolutionOrder": [
+ { "$ref": "#/sets/base" },
+ { "$ref": "#/sets/base" }
+ ]
+ }
+ },
+ "generic resolution order reference needs a type": {
+ "message": "does not identify a set or modifier",
+ "document": {
+ "version": "2025.10",
+ "$defs": { "item": { "name": "generic", "sources": [] } },
+ "resolutionOrder": [{ "$ref": "#/$defs/item" }]
+ }
+ },
+ "generic resolution order reference needs a name": {
+ "message": "must have a name",
+ "document": {
+ "version": "2025.10",
+ "$defs": { "item": { "type": "set", "sources": [] } },
+ "resolutionOrder": [{ "$ref": "#/$defs/item" }]
+ }
+ },
+ "same-document fragment must be a JSON Pointer": {
+ "message": "must be a JSON Pointer",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [] } },
+ "resolutionOrder": [{ "$ref": "#sets/base" }]
+ }
+ },
+ "set cannot reference modifier": {
+ "message": "must not reference modifiers",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [{ "$ref": "#/modifiers/theme" }] } },
+ "modifiers": { "theme": { "contexts": { "light": [], "dark": [] } } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ },
+ "nothing can reference resolution order": {
+ "message": "must not point into resolutionOrder",
+ "document": {
+ "version": "2025.10",
+ "sets": { "base": { "sources": [{ "$ref": "#/resolutionOrder/0" }] } },
+ "resolutionOrder": [{ "$ref": "#/sets/base" }]
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json b/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json
new file mode 100644
index 00000000000..b31ef9fa15c
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/dtcg/resolver-valid.resolver.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json",
+ "version": "2025.10",
+ "sets": {
+ "base": {
+ "sources": [
+ {
+ "$ref": "./format-valid.tokens.json"
+ }
+ ]
+ }
+ },
+ "modifiers": {
+ "theme": {
+ "contexts": {
+ "light": [
+ {
+ "$ref": "./format-valid.tokens.json"
+ }
+ ],
+ "dark": [
+ {
+ "$ref": "./format-valid.tokens.json"
+ }
+ ]
+ },
+ "default": "light"
+ }
+ },
+ "resolutionOrder": [
+ {
+ "$ref": "#/sets/base"
+ },
+ {
+ "$ref": "#/modifiers/theme"
+ }
+ ]
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json b/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json
new file mode 100644
index 00000000000..5ab632c97b1
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/brand-sky.tokens.json
@@ -0,0 +1,27 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "brand": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.3255,
+ 0.5176,
+ 0.9294
+ ]
+ }
+ },
+ "brand-strong": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.2275,
+ 0.3882,
+ 0.7686
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json b/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json
new file mode 100644
index 00000000000..70a9b967681
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/brand-symfony.tokens.json
@@ -0,0 +1,27 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "brand": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.4157,
+ 0.1765,
+ 0.7294
+ ]
+ }
+ },
+ "brand-strong": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.3059,
+ 0.1216,
+ 0.549
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json b/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json
new file mode 100644
index 00000000000..86ba95ca9d0
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/foundation.tokens.json
@@ -0,0 +1,616 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "n-50": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.949,
+ 0.949,
+ 0.949
+ ]
+ }
+ },
+ "n-100": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.851,
+ 0.851,
+ 0.851
+ ]
+ }
+ },
+ "n-200": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.749,
+ 0.749,
+ 0.749
+ ]
+ }
+ },
+ "n-300": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.651,
+ 0.651,
+ 0.651
+ ]
+ }
+ },
+ "n-400": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.549,
+ 0.549,
+ 0.549
+ ]
+ }
+ },
+ "n-500": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.451,
+ 0.451,
+ 0.451
+ ]
+ }
+ },
+ "n-600": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.349,
+ 0.349,
+ 0.349
+ ]
+ }
+ },
+ "n-700": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.251,
+ 0.251,
+ 0.251
+ ]
+ }
+ },
+ "n-800": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.149,
+ 0.149,
+ 0.149
+ ]
+ }
+ },
+ "n-900": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.051,
+ 0.051,
+ 0.051
+ ]
+ }
+ }
+ },
+ "signal": {
+ "$type": "color",
+ "red": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.9843,
+ 0.251,
+ 0.251
+ ]
+ }
+ },
+ "yellow": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.9843,
+ 0.7765,
+ 0.251
+ ]
+ }
+ },
+ "green": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.5176,
+ 0.8706,
+ 0.1725
+ ]
+ }
+ }
+ },
+ "action": {
+ "primary": {
+ "$value": "{color.palette.brand}"
+ },
+ "primary-hover": {
+ "$value": "{color.palette.brand-strong}"
+ },
+ "on-primary": {
+ "$value": "{color.palette.n-50}"
+ }
+ },
+ "content": {
+ "default": {
+ "$value": "{color.palette.text}"
+ },
+ "muted": {
+ "$value": "{color.palette.text-muted}"
+ },
+ "faint": {
+ "$value": "{color.palette.text-faint}"
+ },
+ "code": {
+ "$value": "{color.palette.code}"
+ }
+ },
+ "surface": {
+ "canvas": {
+ "$value": "{color.palette.surface}"
+ },
+ "subtle": {
+ "$value": "{color.palette.surface-subtle}"
+ },
+ "raised": {
+ "$value": "{color.palette.surface-raised}"
+ }
+ },
+ "border": {
+ "default": {
+ "$value": "{color.palette.line}"
+ },
+ "subtle": {
+ "$value": "{color.palette.line-subtle}"
+ }
+ },
+ "focus": {
+ "ring": {
+ "$value": "{color.palette.brand}"
+ }
+ },
+ "selection": {
+ "background": {
+ "$value": "{color.palette.brand}"
+ },
+ "foreground": {
+ "$value": "{color.palette.n-50}"
+ }
+ }
+ },
+ "font": {
+ "family": {
+ "$type": "fontFamily",
+ "title": {
+ "$value": [
+ "Ubuntu Sans",
+ "Ubuntu",
+ "system-ui",
+ "sans-serif"
+ ]
+ },
+ "ui": {
+ "$value": [
+ "system-ui",
+ "sans-serif"
+ ]
+ },
+ "mono": {
+ "$value": [
+ "ui-monospace",
+ "SF Mono",
+ "SFMono-Regular",
+ "Menlo",
+ "Consolas",
+ "monospace"
+ ]
+ }
+ },
+ "weight": {
+ "$type": "fontWeight",
+ "regular": {
+ "$value": 400
+ },
+ "medium": {
+ "$value": 500
+ },
+ "bold": {
+ "$value": 700
+ }
+ },
+ "size": {
+ "$type": "dimension",
+ "small": {
+ "$value": {
+ "value": 0.875,
+ "unit": "rem"
+ }
+ },
+ "body": {
+ "$value": {
+ "value": 1,
+ "unit": "rem"
+ }
+ },
+ "h5": {
+ "$value": {
+ "value": 1.25,
+ "unit": "rem"
+ }
+ },
+ "h4": {
+ "$value": {
+ "value": 1.5,
+ "unit": "rem"
+ }
+ },
+ "h3": {
+ "$value": {
+ "value": 1.75,
+ "unit": "rem"
+ }
+ },
+ "h2": {
+ "$value": {
+ "value": 2,
+ "unit": "rem"
+ }
+ },
+ "h1": {
+ "$value": {
+ "value": 3.25,
+ "unit": "rem"
+ }
+ }
+ }
+ },
+ "number": {
+ "line-height": {
+ "$type": "number",
+ "heading": {
+ "$value": 1.2
+ },
+ "body": {
+ "$value": 1.5
+ }
+ }
+ },
+ "dimension": {
+ "$type": "dimension",
+ "spacing": {
+ "xs": {
+ "$value": {
+ "value": 0.5,
+ "unit": "rem"
+ }
+ },
+ "sm": {
+ "$value": {
+ "value": 0.75,
+ "unit": "rem"
+ }
+ },
+ "md": {
+ "$value": {
+ "value": 1,
+ "unit": "rem"
+ }
+ },
+ "lg": {
+ "$value": {
+ "value": 1.5,
+ "unit": "rem"
+ }
+ },
+ "xl": {
+ "$value": {
+ "value": 2,
+ "unit": "rem"
+ }
+ }
+ },
+ "radius": {
+ "control": {
+ "$value": {
+ "value": 0.4,
+ "unit": "rem"
+ }
+ },
+ "card": {
+ "$value": {
+ "value": 0.75,
+ "unit": "rem"
+ }
+ }
+ },
+ "border": {
+ "width": {
+ "$value": {
+ "value": 1,
+ "unit": "px"
+ }
+ }
+ }
+ },
+ "typography": {
+ "$type": "typography",
+ "body": {
+ "$value": {
+ "fontFamily": "{font.family.ui}",
+ "fontSize": "{font.size.body}",
+ "fontWeight": "{font.weight.regular}",
+ "letterSpacing": {
+ "value": 0,
+ "unit": "px"
+ },
+ "lineHeight": "{number.line-height.body}"
+ }
+ },
+ "heading": {
+ "$value": {
+ "fontFamily": "{font.family.title}",
+ "fontSize": "{font.size.h2}",
+ "fontWeight": "{font.weight.medium}",
+ "letterSpacing": {
+ "value": -0.01,
+ "unit": "rem"
+ },
+ "lineHeight": "{number.line-height.heading}"
+ }
+ },
+ "display": {
+ "$value": {
+ "fontFamily": "{font.family.title}",
+ "fontSize": "{font.size.h1}",
+ "fontWeight": "{font.weight.bold}",
+ "letterSpacing": {
+ "value": -0.02,
+ "unit": "rem"
+ },
+ "lineHeight": "{number.line-height.heading}"
+ }
+ },
+ "code": {
+ "$value": {
+ "fontFamily": "{font.family.mono}",
+ "fontSize": "{font.size.small}",
+ "fontWeight": "{font.weight.regular}",
+ "letterSpacing": {
+ "value": 0,
+ "unit": "px"
+ },
+ "lineHeight": "{number.line-height.body}"
+ }
+ }
+ },
+ "elevation": {
+ "$type": "shadow",
+ "raised": {
+ "$value": {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.051,
+ 0.051,
+ 0.051
+ ],
+ "alpha": 0.08
+ },
+ "offsetX": {
+ "value": 0,
+ "unit": "px"
+ },
+ "offsetY": {
+ "value": 1,
+ "unit": "px"
+ },
+ "blur": {
+ "value": 3,
+ "unit": "px"
+ },
+ "spread": {
+ "value": 0,
+ "unit": "px"
+ }
+ }
+ },
+ "overlay": {
+ "$value": [
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.051,
+ 0.051,
+ 0.051
+ ],
+ "alpha": 0.1
+ },
+ "offsetX": {
+ "value": 0,
+ "unit": "px"
+ },
+ "offsetY": {
+ "value": 4,
+ "unit": "px"
+ },
+ "blur": {
+ "value": 6,
+ "unit": "px"
+ },
+ "spread": {
+ "value": -1,
+ "unit": "px"
+ }
+ },
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.051,
+ 0.051,
+ 0.051
+ ],
+ "alpha": 0.06
+ },
+ "offsetX": {
+ "value": 0,
+ "unit": "px"
+ },
+ "offsetY": {
+ "value": 10,
+ "unit": "px"
+ },
+ "blur": {
+ "value": 15,
+ "unit": "px"
+ },
+ "spread": {
+ "value": -3,
+ "unit": "px"
+ }
+ }
+ ]
+ }
+ },
+ "stroke": {
+ "$type": "strokeStyle",
+ "solid": {
+ "$value": "solid"
+ },
+ "dashed": {
+ "$value": "dashed"
+ }
+ },
+ "border": {
+ "$type": "border",
+ "default": {
+ "$value": {
+ "color": "{color.border.default}",
+ "width": "{dimension.border.width}",
+ "style": "{stroke.solid}"
+ }
+ },
+ "subtle": {
+ "$value": {
+ "color": "{color.border.subtle}",
+ "width": "{dimension.border.width}",
+ "style": "{stroke.solid}"
+ }
+ }
+ },
+ "gradient": {
+ "$type": "gradient",
+ "rainbow": {
+ "$value": [
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.8392,
+ 0.3451,
+ 0.1922
+ ]
+ },
+ "position": 0
+ },
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.8235,
+ 0.8392,
+ 0.1922
+ ]
+ },
+ "position": 0.3652
+ },
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.1922,
+ 0.8392,
+ 0.451
+ ]
+ },
+ "position": 0.7183
+ },
+ {
+ "color": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.2275,
+ 0.6392,
+ 1.0
+ ]
+ },
+ "position": 1
+ }
+ ]
+ }
+ },
+ "duration": {
+ "$type": "duration",
+ "quick": {
+ "$value": {
+ "value": 150,
+ "unit": "ms"
+ }
+ },
+ "base": {
+ "$value": {
+ "value": 250,
+ "unit": "ms"
+ }
+ }
+ },
+ "easing": {
+ "$type": "cubicBezier",
+ "standard": {
+ "$value": [
+ 0.2,
+ 0,
+ 0,
+ 1
+ ]
+ },
+ "decelerate": {
+ "$value": [
+ 0,
+ 0,
+ 0.2,
+ 1
+ ]
+ }
+ },
+ "motion": {
+ "$type": "transition",
+ "control": {
+ "$value": {
+ "duration": "{duration.quick}",
+ "delay": {
+ "value": 0,
+ "unit": "ms"
+ },
+ "timingFunction": "{easing.standard}"
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json b/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json
new file mode 100644
index 00000000000..c84b9f4d767
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/scheme-dark.tokens.json
@@ -0,0 +1,98 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "surface": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.1294,
+ 0.1451,
+ 0.1608
+ ]
+ }
+ },
+ "surface-subtle": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.1686,
+ 0.1882,
+ 0.2078
+ ]
+ }
+ },
+ "surface-raised": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.2039,
+ 0.2275,
+ 0.251
+ ]
+ }
+ },
+ "text": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.8706,
+ 0.8863,
+ 0.902
+ ]
+ }
+ },
+ "text-muted": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.6784,
+ 0.7098,
+ 0.7412
+ ]
+ }
+ },
+ "text-faint": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.4235,
+ 0.4588,
+ 0.4902
+ ]
+ }
+ },
+ "line": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "alpha": 0.35
+ }
+ },
+ "line-subtle": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.2039,
+ 0.2275,
+ 0.251
+ ]
+ }
+ },
+ "code": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 1.0,
+ 0.4745,
+ 0.7765
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json b/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json
new file mode 100644
index 00000000000..c339e1cc063
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/scheme-light.tokens.json
@@ -0,0 +1,97 @@
+{
+ "color": {
+ "palette": {
+ "$type": "color",
+ "surface": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 1.0,
+ 1.0,
+ 1.0
+ ]
+ }
+ },
+ "surface-subtle": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.9725,
+ 0.9765,
+ 0.9804
+ ]
+ }
+ },
+ "surface-raised": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.9137,
+ 0.9255,
+ 0.9373
+ ]
+ }
+ },
+ "text": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.1294,
+ 0.1451,
+ 0.1608
+ ]
+ }
+ },
+ "text-muted": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.4235,
+ 0.4588,
+ 0.4902
+ ]
+ }
+ },
+ "text-faint": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.6784,
+ 0.7098,
+ 0.7412
+ ]
+ }
+ },
+ "line": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.8549,
+ 0.8706,
+ 0.8824
+ ]
+ }
+ },
+ "line-subtle": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.9137,
+ 0.9255,
+ 0.9373
+ ]
+ }
+ },
+ "code": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [
+ 0.8118,
+ 0.0,
+ 0.4039
+ ]
+ }
+ }
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json b/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json
new file mode 100644
index 00000000000..dda52cdcc29
--- /dev/null
+++ b/src/DesignTokens/tests/Fixtures/theme/theme.resolver.json
@@ -0,0 +1,56 @@
+{
+ "$schema": "https://www.designtokens.org/schemas/2025.10/resolver.json",
+ "version": "2025.10",
+ "sets": {
+ "foundation": {
+ "sources": [
+ {
+ "$ref": "foundation.tokens.json"
+ }
+ ]
+ }
+ },
+ "modifiers": {
+ "brand": {
+ "contexts": {
+ "symfony": [
+ {
+ "$ref": "brand-symfony.tokens.json"
+ }
+ ],
+ "sky": [
+ {
+ "$ref": "brand-sky.tokens.json"
+ }
+ ]
+ },
+ "default": "symfony"
+ },
+ "scheme": {
+ "contexts": {
+ "light": [
+ {
+ "$ref": "scheme-light.tokens.json"
+ }
+ ],
+ "dark": [
+ {
+ "$ref": "scheme-dark.tokens.json"
+ }
+ ]
+ },
+ "default": "light"
+ }
+ },
+ "resolutionOrder": [
+ {
+ "$ref": "#/sets/foundation"
+ },
+ {
+ "$ref": "#/modifiers/brand"
+ },
+ {
+ "$ref": "#/modifiers/scheme"
+ }
+ ]
+}
diff --git a/src/DesignTokens/tests/Integration/BundleIntegrationTest.php b/src/DesignTokens/tests/Integration/BundleIntegrationTest.php
new file mode 100644
index 00000000000..80430fcec78
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/BundleIntegrationTest.php
@@ -0,0 +1,365 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Integration;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\FrameworkBundle\Console\Application;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter;
+use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer;
+use Symfony\UX\DesignTokens\Command\ExportCommand;
+use Symfony\UX\DesignTokens\Command\ImportCommand;
+use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand;
+use Symfony\UX\DesignTokens\TokenRegistry;
+use Symfony\UX\DesignTokens\TokenRegistryInterface;
+use Symfony\UX\DesignTokens\Twig\DesignTokenExtension;
+use Symfony\UX\DesignTokens\UXDesignTokensBundle;
+use Symfony\UX\DesignTokens\Validation\DtcgValidator;
+use Symfony\UX\DesignTokens\Validation\Normalizer;
+use Twig\Environment;
+
+#[CoversClass(UXDesignTokensBundle::class)]
+#[CoversClass(TokenRegistry::class)]
+#[CoversClass(DesignTokenExtension::class)]
+#[CoversClass(ExportCommand::class)]
+#[CoversClass(ImportCommand::class)]
+#[CoversClass(LintDesignTokensCommand::class)]
+#[CoversClass(ThemeImporter::class)]
+#[CoversClass(DtcgValidator::class)]
+#[CoversClass(Normalizer::class)]
+#[CoversClass(DesignTokensCacheWarmer::class)]
+final class BundleIntegrationTest extends TestCase
+{
+ /** @var list */
+ private array $kernels = [];
+
+ protected function tearDown(): void
+ {
+ $filesystem = new Filesystem();
+ foreach ($this->kernels as $kernel) {
+ $cacheDir = $kernel->getCacheDir();
+ $kernel->shutdown();
+ $filesystem->remove($cacheDir);
+ }
+ $this->kernels = [];
+ }
+
+ public function testConfiguredFilesLoadThroughTheRegistryService(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [$this->fixture('app.tokens.json')],
+ ]);
+
+ $registry = $kernel->getContainer()->get(TokenRegistryInterface::class);
+
+ self::assertInstanceOf(TokenRegistryInterface::class, $registry);
+ self::assertSame('color(srgb 0.2 0.4 0.8)', (string) $registry->get('color.brand'));
+ }
+
+ public function testEveryBundleServiceIsInstantiable(): void
+ {
+ $container = $this->boot([
+ 'paths' => [$this->fixture('app.tokens.json')],
+ ])->getContainer();
+
+ $ids = array_filter($container->getServiceIds(), static fn (string $id): bool => str_starts_with($id, '.ux_design_tokens.'));
+
+ self::assertNotEmpty($ids);
+ foreach ($ids as $id) {
+ self::assertIsObject($container->get($id), $id);
+ }
+ }
+
+ public function testResolverSourcesStayInsideTheAllowedRoots(): void
+ {
+ $filesystem = new Filesystem();
+ $outside = sys_get_temp_dir().'/dt-outside-'.bin2hex(random_bytes(4)).'.tokens.json';
+ $filesystem->dumpFile($outside, '{"x":{"$type":"number","$value":1}}');
+ $directory = $this->fixture('').'escape-'.bin2hex(random_bytes(4));
+ $filesystem->dumpFile($directory.'/theme.resolver.json', json_encode([
+ 'version' => '2025.10',
+ 'sets' => ['s' => ['sources' => [['$ref' => $outside]]]],
+ 'resolutionOrder' => [['$ref' => '#/sets/s']],
+ ], \JSON_THROW_ON_ERROR));
+
+ try {
+ $registry = $this->boot(['resolver' => ['path' => $directory.'/theme.resolver.json']])->getContainer()->get(TokenRegistryInterface::class);
+ \assert($registry instanceof TokenRegistryInterface);
+
+ $this->expectExceptionMessage('Refusing to read design token document');
+ $registry->all();
+ } finally {
+ $filesystem->remove([$outside, $directory]);
+ }
+ }
+
+ public function testConfiguredFileReferencesResolveRelativeToTheirSource(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [$this->fixture('reference.tokens.json')],
+ ]);
+
+ $registry = $kernel->getContainer()->get(TokenRegistryInterface::class);
+
+ self::assertSame('color(srgb 0.1 0.3 0.7)', (string) $registry->get('semantic'));
+ }
+
+ public function testResolverInputsSelectTheConfiguredContext(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => $this->fixture('theme.resolver.json'), 'inputs' => ['theme' => 'dark']],
+ ]);
+
+ $registry = $kernel->getContainer()->get(TokenRegistryInterface::class);
+
+ self::assertSame('dark', (string) $registry->get('theme'));
+ }
+
+ public function testTheRegistryRejectsAnInvalidDocument(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [\dirname(__DIR__).'/Fixtures/dtcg/format-invalid-legacy-values.tokens.json'],
+ ]);
+
+ $registry = $kernel->getContainer()->get(TokenRegistryInterface::class);
+
+ $this->expectException(\InvalidArgumentException::class);
+ $registry->all();
+ }
+
+ public function testTwigTokenOutputIsAutoEscaped(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [$this->fixture('app.tokens.json')],
+ ]);
+ $twig = $kernel->getContainer()->get('test.twig');
+ self::assertInstanceOf(Environment::class, $twig);
+
+ $output = $twig->createTemplate('{{ ux_token("unsafe") }}')->render();
+
+ self::assertSame('"<strong>token</strong>"', $output);
+ }
+
+ public function testExportCommandIsDiscoveredAndUsesConfiguredTokens(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [$this->fixture('app.tokens.json')],
+ ]);
+ $command = new Application($kernel)->find('ux:design-tokens:export');
+
+ $tester = new CommandTester($command);
+ $status = $tester->execute(['format' => 'css']);
+
+ self::assertSame(0, $status);
+ self::assertStringContainsString('--dt-color-brand', $tester->getDisplay());
+ }
+
+ #[DataProvider('shippedFormats')]
+ public function testEveryShippedFormatExportsThroughTheContainer(string $format): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'],
+ ]);
+ $tester = new CommandTester(new Application($kernel)->find('ux:design-tokens:export'));
+
+ self::assertSame(0, $tester->execute(['format' => $format]), $tester->getDisplay());
+ self::assertNotSame('', trim($tester->getDisplay()));
+ }
+
+ /** @return iterable */
+ public static function shippedFormats(): iterable
+ {
+ foreach (['dtcg', 'css', 'javascript', 'design.md', 'tailwind'] as $format) {
+ yield $format => [$format];
+ }
+ }
+
+ public function testADebugKernelServesAStylesheetRebuiltAfterAnEdit(): void
+ {
+ $directory = sys_get_temp_dir().'/ux-design-tokens-edit-'.bin2hex(random_bytes(4));
+ new Filesystem()->mirror(\dirname(__DIR__).'/Fixtures/color-scheme', $directory);
+ $config = ['resolver' => ['path' => $directory.'/theme.resolver.json']];
+ $cacheDir = sys_get_temp_dir().'/ux-design-tokens-kernel-'.bin2hex(random_bytes(6));
+
+ try {
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $this->renderCss($config, $cacheDir));
+
+ $light = $directory.'/light.tokens.json';
+ new Filesystem()->dumpFile($light, str_replace('"primary": { "$value": "{color.palette.primary-light}" }', '"primary": { "$value": { "colorSpace": "srgb", "components": [0.9, 0.1, 0.1] } }', (string) file_get_contents($light)));
+ new Filesystem()->touch($light, time() + 10);
+
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.9 0.1 0.1);', $this->renderCss($config, $cacheDir));
+ } finally {
+ new Filesystem()->remove($directory);
+ }
+ }
+
+ public function testThePreloadScriptHoldsNoStylesheet(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'],
+ ]);
+ $tester = new CommandTester(new Application($kernel)->find('cache:warmup'));
+
+ self::assertSame(0, $tester->execute([]));
+ self::assertFileExists($kernel->getBuildDir().'/ux_design_tokens/tokens.css');
+
+ $preload = glob($kernel->getBuildDir().'/*.preload.php') ?: [];
+ self::assertNotSame([], $preload);
+ foreach ($preload as $file) {
+ self::assertStringNotContainsString('.css', (string) file_get_contents($file));
+ }
+ }
+
+ public function testTheDarkColorSchemeWorksThroughTheCommandAndTwig(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'],
+ ]);
+
+ $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export'));
+ self::assertSame(0, $command->execute(['format' => 'css']));
+ self::assertDarkScheme($command->getDisplay());
+
+ $twig = $kernel->getContainer()->get('test.twig');
+ self::assertInstanceOf(Environment::class, $twig);
+ self::assertDarkScheme($twig->createTemplate('{{ ux_token_css() }}')->render());
+
+ $contextualHtml = $twig->createTemplate('{{ ux_token_css({package: "map"}) }}')->render();
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.1 0.7 0.5);', $contextualHtml);
+
+ $contextualToken = $twig->createTemplate('{{ ux_token("font.size.body", {surface: "docs"}) }}')->render();
+ self::assertSame('1.125rem', $contextualToken);
+ }
+
+ public function testTailwindImporterServiceAndCommandAreDiscovered(): void
+ {
+ $kernel = $this->boot([]);
+ $importer = $kernel->getContainer()->get('.ux_design_tokens.importer.tailwind');
+ self::assertInstanceOf(ThemeImporter::class, $importer);
+ self::assertSame('color', $importer->import('@theme { --color-brand: #336699; }')['color']['brand']['$type']);
+
+ $command = new Application($kernel)->find('ux:design-tokens:import');
+ self::assertSame('ux:design-tokens:import', $command->getName());
+ }
+
+ public function testLintServicesAndCommandsAreDiscovered(): void
+ {
+ $kernel = $this->boot([
+ 'paths' => [$this->fixture('app.tokens.json')],
+ ]);
+ $container = $kernel->getContainer();
+ self::assertInstanceOf(DtcgValidator::class, $container->get('.ux_design_tokens.validator'));
+
+ $application = new Application($kernel);
+ $lint = new CommandTester($application->find('lint:design-tokens'));
+ self::assertSame(0, $lint->execute([]));
+ self::assertStringContainsString('document is valid', $lint->getDisplay());
+
+ self::assertStringContainsString('The configured resolution holds', $lint->getDisplay());
+
+ $debug = new CommandTester($application->find('debug:design-tokens'));
+ self::assertSame(0, $debug->execute(['path' => 'color.brand']));
+ self::assertStringContainsString('color.brand', $debug->getDisplay());
+ }
+
+ public function testTheColorSchemeModifierIsConfigurable(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/named-theme.resolver.json'],
+ 'color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'],
+ ]);
+
+ $twig = $kernel->getContainer()->get('test.twig');
+ self::assertInstanceOf(Environment::class, $twig);
+ self::assertDarkScheme($twig->createTemplate('{{ ux_token_css() }}')->render());
+
+ $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export'));
+ self::assertSame(0, $command->execute(['format' => 'css']));
+ self::assertDarkScheme($command->getDisplay());
+ }
+
+ public function testCssPrefixIsApplicationOwned(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'],
+ 'css_prefix' => 'my',
+ ]);
+
+ $command = new CommandTester(new Application($kernel)->find('ux:design-tokens:export'));
+ self::assertSame(0, $command->execute(['format' => 'css']));
+ self::assertStringContainsString('--my-color-action-primary:', $command->getDisplay());
+
+ $twig = $kernel->getContainer()->get('test.twig');
+ self::assertInstanceOf(Environment::class, $twig);
+ $html = $twig->createTemplate('{{ ux_token_css() }}')->render();
+ self::assertStringContainsString('--my-color-action-primary:', $html);
+ }
+
+ public function testTheWarmedStylesheetIsServedFromTheBuildDirectory(): void
+ {
+ $kernel = $this->boot([
+ 'resolver' => ['path' => \dirname(__DIR__).'/Fixtures/color-scheme/theme.resolver.json'],
+ ]);
+
+ $warmer = $kernel->getContainer()->get('test.cache_warmer');
+ self::assertInstanceOf(DesignTokensCacheWarmer::class, $warmer);
+ $warmer->warmUp($kernel->getCacheDir(), $kernel->getBuildDir());
+
+ $stylesheet = $kernel->getBuildDir().'/ux_design_tokens/tokens.css';
+ self::assertFileExists($stylesheet);
+ self::assertDarkScheme((string) file_get_contents($stylesheet));
+
+ $twig = $kernel->getContainer()->get('test.twig');
+ self::assertInstanceOf(Environment::class, $twig);
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $twig->createTemplate('{{ ux_token_css() }}')->render());
+ }
+
+ private function boot(array $config): IntegrationTestKernel
+ {
+ $cacheDir = sys_get_temp_dir().'/ux-design-tokens-kernel-'.bin2hex(random_bytes(6));
+ $kernel = new IntegrationTestKernel($config, $cacheDir);
+ $this->kernels[] = $kernel;
+ $kernel->boot();
+
+ return $kernel;
+ }
+
+ /** @param array $config */
+ private function renderCss(array $config, string $cacheDir): string
+ {
+ $kernel = new IntegrationTestKernel($config, $cacheDir, true);
+ $this->kernels[] = $kernel;
+ $kernel->boot();
+ $twig = $kernel->getContainer()->get('test.twig');
+ \assert($twig instanceof Environment);
+
+ return $twig->createTemplate('{{ ux_token_css() }}')->render();
+ }
+
+ private static function assertDarkScheme(string $css): void
+ {
+ [$root, $dark] = explode('@media (prefers-color-scheme: dark)', $css, 2) + [1 => ''];
+
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $root);
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $dark);
+ self::assertStringNotContainsString('--dt-dimension-spacing-md', $dark);
+ }
+
+ private function fixture(string $name): string
+ {
+ return __DIR__.'/Fixtures/'.$name;
+ }
+}
diff --git a/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json
new file mode 100644
index 00000000000..df7e8710877
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/Fixtures/app.tokens.json
@@ -0,0 +1,15 @@
+{
+ "color": {
+ "$type": "color",
+ "brand": {
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [0.2, 0.4, 0.8]
+ }
+ }
+ },
+ "unsafe": {
+ "$type": "fontFamily",
+ "$value": "token"
+ }
+}
diff --git a/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json
new file mode 100644
index 00000000000..202db14bbdc
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/Fixtures/primitive.tokens.json
@@ -0,0 +1,9 @@
+{
+ "base": {
+ "$type": "color",
+ "$value": {
+ "colorSpace": "srgb",
+ "components": [0.1, 0.3, 0.7]
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json b/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json
new file mode 100644
index 00000000000..cd1439bd760
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/Fixtures/reference.tokens.json
@@ -0,0 +1,6 @@
+{
+ "semantic": {
+ "$type": "color",
+ "$ref": "./primitive.tokens.json#/base/$value"
+ }
+}
diff --git a/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json b/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json
new file mode 100644
index 00000000000..d98279d5c2e
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/Fixtures/theme.resolver.json
@@ -0,0 +1,31 @@
+{
+ "version": "2025.10",
+ "modifiers": {
+ "theme": {
+ "contexts": {
+ "light": [
+ {
+ "theme": {
+ "$type": "fontFamily",
+ "$value": "light"
+ }
+ }
+ ],
+ "dark": [
+ {
+ "theme": {
+ "$type": "fontFamily",
+ "$value": "dark"
+ }
+ }
+ ]
+ },
+ "default": "light"
+ }
+ },
+ "resolutionOrder": [
+ {
+ "$ref": "#/modifiers/theme"
+ }
+ ]
+}
diff --git a/src/DesignTokens/tests/Integration/IntegrationTestKernel.php b/src/DesignTokens/tests/Integration/IntegrationTestKernel.php
new file mode 100644
index 00000000000..59cda69e83d
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/IntegrationTestKernel.php
@@ -0,0 +1,93 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Integration;
+
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\Compiler\PassConfig;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\HttpKernel\Kernel;
+use Symfony\UX\DesignTokens\UXDesignTokensBundle;
+
+/** @internal */
+final class IntegrationTestKernel extends Kernel
+{
+ use MicroKernelTrait;
+
+ /** @param array $bundleConfig */
+ public function __construct(
+ private readonly array $bundleConfig,
+ private readonly string $testCacheDir,
+ bool $debug = false,
+ ) {
+ parent::__construct('test', $debug);
+ }
+
+ public function registerBundles(): iterable
+ {
+ yield new FrameworkBundle();
+ yield new TwigBundle();
+ yield new UXDesignTokensBundle();
+ }
+
+ public function getProjectDir(): string
+ {
+ return \dirname(__DIR__, 2);
+ }
+
+ public function getCacheDir(): string
+ {
+ return $this->testCacheDir;
+ }
+
+ public function getLogDir(): string
+ {
+ return $this->testCacheDir.'/log';
+ }
+
+ protected function build(ContainerBuilder $container): void
+ {
+ $container->addCompilerPass(new class implements CompilerPassInterface {
+ public function process(ContainerBuilder $container): void
+ {
+ foreach ($container->getDefinitions() as $id => $definition) {
+ if (str_starts_with($id, '.ux_design_tokens.')) {
+ $definition->setPublic(true);
+ }
+ }
+ foreach ($container->getAliases() as $id => $alias) {
+ if (str_starts_with($id, 'Symfony\\UX\\DesignTokens\\')) {
+ $alias->setPublic(true);
+ }
+ }
+ }
+ }, PassConfig::TYPE_BEFORE_REMOVING);
+ }
+
+ protected function configureContainer(ContainerConfigurator $container): void
+ {
+ $container->extension('framework', [
+ 'secret' => 'ux-design-tokens-test',
+ 'test' => true,
+ ]);
+ $container->extension('ux_design_tokens', $this->bundleConfig);
+
+ $services = $container->services();
+ $services->alias('test.cache_warmer', '.ux_design_tokens.cache_warmer')
+ ->public()
+ ;
+ $services->alias('test.twig', 'twig')->public();
+ }
+}
diff --git a/src/DesignTokens/tests/Integration/LayeredThemeTest.php b/src/DesignTokens/tests/Integration/LayeredThemeTest.php
new file mode 100644
index 00000000000..484293305ea
--- /dev/null
+++ b/src/DesignTokens/tests/Integration/LayeredThemeTest.php
@@ -0,0 +1,77 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Integration;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+use Symfony\UX\DesignTokens\TokenRegistry;
+
+final class LayeredThemeTest extends TestCase
+{
+ private const RESOLVER = __DIR__.'/../Fixtures/theme/theme.resolver.json';
+
+ public function testTheStylesheetWritesOnlyTheDarkDifferences(): void
+ {
+ $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER));
+ $light = $registry->flatten(['scheme' => 'light']);
+ $dark = $registry->flatten(['scheme' => 'dark']);
+
+ self::assertCount(78, $light);
+ self::assertCount(20, array_filter($light, static fn ($token, string $path): bool => (string) $token !== (string) $dark[$path], \ARRAY_FILTER_USE_BOTH));
+
+ $css = new CssGenerator('dt')->generate($registry->all(['scheme' => 'light']), [GeneratorInterface::DARK_TOKENS => $registry->all(['scheme' => 'dark'])]);
+ [$root, $darkBlock] = explode(':root[data-theme="dark"] {', $css, 2);
+
+ self::assertStringContainsString('--dt-dimension-spacing-md: 1rem;', $root);
+
+ // 18 colors, plus the shorthand and the color of the 2 borders.
+ self::assertSame(22, substr_count($darkBlock, '--dt-'));
+ self::assertStringContainsString('--dt-color-palette-surface: color(srgb 0.1294 0.1451 0.1608);', $darkBlock);
+ self::assertStringContainsString('--dt-border-default-color:', $darkBlock);
+ self::assertStringNotContainsString('--dt-dimension-spacing-md', $darkBlock);
+ }
+
+ public function testTheRegistryListsTheModifiersOfTheTheme(): void
+ {
+ $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER));
+
+ self::assertSame([
+ 'brand' => ['contexts' => ['symfony', 'sky'], 'default' => 'symfony'],
+ 'scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light'],
+ ], $registry->getModifiers());
+ }
+
+ public function testATraceNamesTheBrandFileBehindAToken(): void
+ {
+ $resolution = new ConfiguredTokenResolver(resolverPath: self::RESOLVER)->trace(['brand' => 'sky', 'scheme' => 'dark']);
+
+ self::assertSame('color(srgb 0.3255 0.5176 0.9294)', (string) $resolution->getTokens()['color']['action']['primary']);
+ self::assertSame('system-ui, sans-serif', (string) $resolution->getTokens()['font']['family']['ui']);
+ self::assertSame('brand-sky.tokens.json', basename($resolution->getSource('color.palette.brand')?->uri ?? ''));
+ }
+
+ public function testAnotherSelectionExportsAsJavaScript(): void
+ {
+ $registry = new TokenRegistry(new ConfiguredTokenResolver(resolverPath: self::RESOLVER));
+ $module = new JavaScriptGenerator()->generate($registry->all(['brand' => 'sky', 'scheme' => 'dark']));
+
+ self::assertStringContainsString('export const tokens = Object.freeze(JSON.parse(', $module);
+ self::assertStringContainsString('\\"color.action.primary\\":\\"color(srgb 0.3255 0.5176 0.9294)\\"', $module);
+ self::assertStringContainsString('\\"color.surface.canvas\\":\\"color(srgb 0.1294 0.1451 0.1608)\\"', $module);
+ self::assertStringContainsString('\\"font.size.h1\\":\\"3.25rem\\"', $module);
+ self::assertStringContainsString('\\"dimension.spacing.lg\\":\\"1.5rem\\"', $module);
+ self::assertStringContainsString('\\"motion.control\\":\\"150ms cubic-bezier(0.2, 0, 0, 1) 0ms\\"', $module);
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php b/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php
new file mode 100644
index 00000000000..6d713bfff1e
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Bridge/GoogleDesignMd/DesignMdGeneratorTest.php
@@ -0,0 +1,216 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\GoogleDesignMd;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Yaml\Yaml;
+use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator;
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues;
+
+#[CoversClass(DesignMdGenerator::class)]
+final class DesignMdGeneratorTest extends TestCase
+{
+ private DesignMdGenerator $generator;
+ private TokenTreeBuilder $resolver;
+
+ protected function setUp(): void
+ {
+ $this->generator = new DesignMdGenerator();
+ $this->resolver = new TokenTreeBuilder();
+ }
+
+ public function testGenerateContainsTitleAndSections(): void
+ {
+ $md = $this->generate([
+ 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]],
+ 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]],
+ 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]],
+ 'opacity' => ['disabled' => ['$type' => 'number', '$value' => 0.5]],
+ 'elevation' => ['sm' => ['$type' => 'shadow', '$value' => [
+ 'offsetX' => ['value' => 0, 'unit' => 'px'],
+ 'offsetY' => ['value' => 1, 'unit' => 'px'],
+ 'blur' => ['value' => 2, 'unit' => 'px'],
+ 'spread' => ['value' => 0, 'unit' => 'px'],
+ 'color' => TokenValues::color(),
+ ]]],
+ ]);
+
+ self::assertStringContainsString('version: alpha', $md);
+ self::assertStringContainsString('name: \'Design System\'', $md);
+ self::assertStringContainsString('# Design System', $md);
+ self::assertStringNotContainsString('tokens across', $md);
+ self::assertStringContainsString('## Colors', $md);
+ self::assertStringContainsString("primary: 'rgb(20% 40% 80%)'", $md);
+ self::assertStringContainsString('16px', $md);
+ self::assertStringContainsString('## Elevation & Depth', $md);
+ self::assertStringContainsString('`elevation.sm`', $md);
+ }
+
+ public function testGenerateRendersStructuredCompositeValues(): void
+ {
+ $md = $this->generate([
+ 'heading' => ['$type' => 'typography', '$value' => TokenValues::typography()],
+ 'border' => ['$type' => 'border', '$value' => TokenValues::border()],
+ 'transition' => ['$type' => 'transition', '$value' => TokenValues::transition()],
+ ]);
+
+ self::assertStringContainsString('## Typography', $md);
+ self::assertStringContainsString('Inter, sans-serif', $md);
+ self::assertStringContainsString('32px', $md);
+ self::assertStringContainsString('## Additional Tokens', $md);
+ self::assertStringContainsString('| `border` | `border` |', $md);
+ self::assertStringContainsString('color(srgb 0.2 0.4 0.8)', $md);
+ self::assertStringContainsString('| `transition` | `transition` |', $md);
+ self::assertStringContainsString('250ms', $md);
+ }
+
+ public function testGenerateUsesStableSectionOrderAndCustomTitle(): void
+ {
+ $generator = new DesignMdGenerator();
+ $md = $generator->generate($this->resolver->resolve([
+ 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]],
+ 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]],
+ 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]],
+ ]), [GeneratorInterface::TITLE => 'Acme']);
+
+ self::assertStringContainsString('# Acme', $md);
+ self::assertLessThan(mb_strpos($md, '## Layout'), mb_strpos($md, '## Colors'));
+ self::assertLessThan(mb_strpos($md, '## Additional Tokens'), mb_strpos($md, '## Layout'));
+ }
+
+ public function testGenerateEmptyTokensProducesHeader(): void
+ {
+ $md = $this->generator->generate([]);
+
+ self::assertStringContainsString('# Design System', $md);
+ self::assertStringContainsString('## Overview', $md);
+ }
+
+ public function testGenerateHasGoogleDesignMdFrontMatter(): void
+ {
+ $md = $this->generate([
+ 'color' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]],
+ 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]],
+ 'radius' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]],
+ 'components' => ['button-primary' => [
+ 'backgroundColor' => ['$type' => 'color', '$value' => TokenValues::color()],
+ 'padding' => ['$type' => 'dimension', '$value' => ['value' => 12, 'unit' => 'px']],
+ ]],
+ ]);
+
+ self::assertSame(1, preg_match('/\A---\n(.*?)\n---\n/s', $md, $matches));
+
+ /** @var array $frontMatter */
+ $frontMatter = Yaml::parse($matches[1]);
+ self::assertSame('alpha', $frontMatter['version']);
+ self::assertSame('Design System', $frontMatter['name']);
+ self::assertSame('rgb(20% 40% 80%)', $frontMatter['colors']['primary']);
+ self::assertSame('16px', $frontMatter['spacing']['md']);
+ self::assertSame('8px', $frontMatter['rounded']['md']);
+ self::assertSame('rgb(20% 40% 80%)', $frontMatter['components']['button-primary']['backgroundColor']);
+ self::assertSame('12px', $frontMatter['components']['button-primary']['padding']);
+ }
+
+ public function testNamesKeepTheirWholePath(): void
+ {
+ $md = $this->generate(['color' => [
+ '$type' => 'color',
+ 'palette' => ['code' => ['$value' => TokenValues::color()]],
+ 'content' => ['code' => ['$value' => TokenValues::color()]],
+ ]]);
+
+ self::assertStringContainsString("palette-code: 'rgb(20% 40% 80%)'", $md);
+ self::assertStringContainsString("content-code: 'rgb(20% 40% 80%)'", $md);
+ }
+
+ public function testTwoPathsGivingTheSameNameAreRefused(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Design tokens "color.a.b-c" and "color.a-b.c" both export to the DESIGN.md name "a-b-c".');
+
+ $this->generate(['color' => [
+ '$type' => 'color',
+ 'a' => ['b-c' => ['$value' => TokenValues::color()]],
+ 'a-b' => ['c' => ['$value' => TokenValues::color()]],
+ ]]);
+ }
+
+ /**
+ * @param array $value
+ */
+ #[DataProvider('colors')]
+ public function testColorsUseAFormDesignMdAccepts(array $value, string $expected): void
+ {
+ $md = $this->generate(['color' => ['$type' => 'color', 'x' => ['$value' => $value]]]);
+
+ self::assertStringContainsString("x: '{$expected}'", $md);
+ }
+
+ /** @return iterable, string}> */
+ public static function colors(): iterable
+ {
+ yield 'srgb' => [['colorSpace' => 'srgb', 'components' => [1, 0, 0]], 'rgb(100% 0% 0%)'];
+ yield 'srgb with alpha' => [['colorSpace' => 'srgb', 'components' => [1, 0, 0], 'alpha' => 0.5], 'rgb(100% 0% 0% / 0.5)'];
+ yield 'oklch' => [['colorSpace' => 'oklch', 'components' => [0.62, 0.18, 255]], 'oklch(62% 0.18 255)'];
+ yield 'hsl' => [['colorSpace' => 'hsl', 'components' => [120, 50, 50]], 'hsl(120 50% 50%)'];
+ yield 'display-p3 with hex fallback' => [['colorSpace' => 'display-p3', 'components' => [1, 0, 0], 'hex' => '#ff0000'], '#ff0000'];
+ }
+
+ public function testASpaceDesignMdCannotExpressNeedsAHexFallback(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Design token "color.x" uses the rec2020 color space, which DESIGN.md cannot express. Add a "hex" fallback.');
+
+ $this->generate(['color' => ['$type' => 'color', 'x' => ['$value' => ['colorSpace' => 'rec2020', 'components' => [1, 0, 0]]]]]);
+ }
+
+ public function testTheBodyDoesNotRepeatTheFrontMatter(): void
+ {
+ $md = $this->generate([
+ 'color' => ['$type' => 'color', 'x' => ['$value' => TokenValues::color()]],
+ 'spacing' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']]],
+ 'components' => ['button' => ['background' => ['$type' => 'color', '$value' => TokenValues::color()]]],
+ ]);
+ [, , $body] = explode("---\n", $md, 3);
+
+ self::assertStringNotContainsString('| Token | Value |', $body);
+ self::assertStringNotContainsString('| Property | Value |', $body);
+ self::assertStringNotContainsString('`components.button.background`', $body);
+ self::assertStringContainsString('## Components', $body);
+ }
+
+ public function testSectionsFollowTheSpecificationOrder(): void
+ {
+ $md = $this->generate([
+ 'radius' => ['md' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']]],
+ 'elevation' => ['sm' => ['$type' => 'shadow', '$value' => [
+ 'offsetX' => ['value' => 0, 'unit' => 'px'],
+ 'offsetY' => ['value' => 1, 'unit' => 'px'],
+ 'blur' => ['value' => 2, 'unit' => 'px'],
+ 'spread' => ['value' => 0, 'unit' => 'px'],
+ 'color' => TokenValues::color(),
+ ]]],
+ ]);
+
+ self::assertLessThan(mb_strpos($md, '## Shapes'), mb_strpos($md, '## Elevation & Depth'));
+ }
+
+ private function generate(array $raw): string
+ {
+ return $this->generator->generate($this->resolver->resolve($raw));
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php
new file mode 100644
index 00000000000..96464eb52e7
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeGeneratorTest.php
@@ -0,0 +1,161 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\Tailwind;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues;
+
+#[CoversClass(ThemeGenerator::class)]
+final class ThemeGeneratorTest extends TestCase
+{
+ private ThemeGenerator $generator;
+ private TokenTreeBuilder $resolver;
+
+ protected function setUp(): void
+ {
+ $this->generator = new ThemeGenerator();
+ $this->resolver = new TokenTreeBuilder();
+ }
+
+ public function testGeneratesOfficialTailwindNamespaces(): void
+ {
+ $css = $this->generate([
+ 'color' => ['brand' => ['primary' => ['$type' => 'color', '$value' => TokenValues::color()]]],
+ 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']]],
+ 'weight' => ['strong' => ['$type' => 'fontWeight', '$value' => 700]],
+ 'spacing' => ['md' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)]],
+ 'radius' => ['card' => ['$type' => 'dimension', '$value' => TokenValues::dimension(12)]],
+ 'breakpoints' => ['wide' => ['$type' => 'dimension', '$value' => ['value' => 80, 'unit' => 'rem']]],
+ 'tracking' => ['tight' => ['$type' => 'number', '$value' => -0.02]],
+ 'easing' => ['snappy' => ['$type' => 'cubicBezier', '$value' => [0.2, 0, 0, 1]]],
+ 'elevation' => ['card' => ['$type' => 'shadow', '$value' => TokenValues::shadow()]],
+ 'inset-shadow' => ['control' => ['$type' => 'shadow', '$value' => TokenValues::shadow(true)]],
+ ]);
+
+ self::assertStringStartsWith("@theme static {\n", $css);
+ self::assertStringContainsString('--color-brand-primary: color(srgb 0.2 0.4 0.8);', $css);
+ self::assertStringContainsString('--font-body: Inter, sans-serif;', $css);
+ self::assertStringContainsString('--font-weight-strong: 700;', $css);
+ self::assertStringContainsString('--spacing-md: 16px;', $css);
+ self::assertStringContainsString('--radius-card: 12px;', $css);
+ self::assertStringContainsString('--breakpoint-wide: 80rem;', $css);
+ self::assertStringContainsString('--tracking-tight: -0.02;', $css);
+ self::assertStringContainsString('--ease-snappy: cubic-bezier(0.2, 0, 0, 1);', $css);
+ self::assertStringContainsString('--shadow-card:', $css);
+ self::assertStringContainsString('--inset-shadow-control:', $css);
+ self::assertStringEndsWith("}\n", $css);
+ }
+
+ public function testExpandsTypographyIntoTailwindThemeVariables(): void
+ {
+ $css = $this->generate(['typography' => ['heading' => ['$type' => 'typography', '$value' => [
+ 'fontFamily' => ['Inter', 'sans-serif'],
+ 'fontSize' => ['value' => 2, 'unit' => 'rem'],
+ 'fontWeight' => 700,
+ 'letterSpacing' => ['value' => -0.02, 'unit' => 'rem'],
+ 'lineHeight' => 1.2,
+ ]]]]);
+
+ self::assertStringContainsString('--text-heading: 2rem;', $css);
+ self::assertStringContainsString('--text-heading--line-height: 1.2;', $css);
+ self::assertStringContainsString('--text-heading--letter-spacing: -0.02rem;', $css);
+ self::assertStringContainsString('--text-heading--font-weight: 700;', $css);
+ self::assertStringNotContainsString('--leading-heading', $css);
+ }
+
+ public function testUsesSpacingForAnUnclassifiedDimensionAndOmitsUnsupportedTypes(): void
+ {
+ $css = $this->generate([
+ 'size' => ['icon' => ['$type' => 'dimension', '$value' => TokenValues::dimension(24)]],
+ 'motion' => ['fast' => ['$type' => 'duration', '$value' => ['value' => 100, 'unit' => 'ms']]],
+ 'opacity' => ['muted' => ['$type' => 'number', '$value' => 0.5]],
+ ]);
+
+ self::assertStringContainsString('--spacing-icon: 24px;', $css);
+ self::assertStringNotContainsString('motion', $css);
+ self::assertStringNotContainsString('opacity', $css);
+ self::assertSame("@theme static {\n}\n", $this->generator->generate([]));
+ }
+
+ public function testWritesTheSpacingScaleAndTextSizesUnderTheirOwnNames(): void
+ {
+ $css = $this->generate([
+ 'spacing' => ['$root' => ['$type' => 'dimension', '$value' => ['value' => 0.25, 'unit' => 'rem']]],
+ 'text' => ['sm' => ['$type' => 'dimension', '$value' => ['value' => 0.875, 'unit' => 'rem']]],
+ ]);
+
+ self::assertStringContainsString(' --spacing: 0.25rem;', $css);
+ self::assertStringContainsString(' --text-sm: 0.875rem;', $css);
+ }
+
+ public function testRejectsNormalizedNameCollisionsAndUnsafeCssValues(): void
+ {
+ try {
+ $this->generate([
+ 'color' => [
+ 'Brand primary' => ['$type' => 'color', '$value' => TokenValues::color()],
+ 'brand-primary' => ['$type' => 'color', '$value' => ['colorSpace' => 'srgb', 'components' => [1, 0, 0]]],
+ ],
+ ]);
+ self::fail('Expected a Tailwind variable collision.');
+ } catch (\InvalidArgumentException $error) {
+ self::assertStringContainsString('same Tailwind theme variable', $error->getMessage());
+ }
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('unsafe Tailwind theme value');
+ $this->generate(['font' => ['bad' => ['$type' => 'fontFamily', '$value' => 'Inter; color: red']]]);
+ }
+
+ public function testNamespacesComeFromAnySegmentOfThePath(): void
+ {
+ $css = $this->generate([
+ 'dimension' => ['$type' => 'dimension', 'radius' => ['control' => ['$value' => TokenValues::dimension(6)]], 'space' => ['md' => ['$value' => TokenValues::dimension(16)]]],
+ 'font' => [
+ 'size' => ['$type' => 'dimension', 'body' => ['$value' => ['value' => 1, 'unit' => 'rem']]],
+ 'weight' => ['$type' => 'fontWeight', 'regular' => ['$value' => 400]],
+ 'body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']],
+ ],
+ 'number' => ['$type' => 'number', 'tab-size' => ['code' => ['$value' => 4]], 'zoom' => ['compact' => ['$value' => 0.9]]],
+ ]);
+
+ self::assertStringContainsString('--radius-control: 6px;', $css);
+ self::assertStringContainsString('--spacing-md: 16px;', $css);
+ self::assertStringContainsString('--text-body: 1rem;', $css);
+ self::assertStringContainsString('--font-weight-regular: 400;', $css);
+ self::assertStringContainsString('--font-body: Inter, sans-serif;', $css);
+ self::assertStringContainsString('--tab-size-code: 4;', $css);
+ self::assertStringContainsString('--zoom-compact: 0.9;', $css);
+ }
+
+ public function testATypographyNextToAFontFamilyOfTheSameNameDoesNotCollide(): void
+ {
+ $css = $this->generate([
+ 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter']]],
+ 'typography' => ['body' => ['$type' => 'typography', '$value' => [
+ 'fontFamily' => ['Inter'], 'fontSize' => ['value' => 1, 'unit' => 'rem'], 'fontWeight' => 400,
+ 'letterSpacing' => ['value' => 0, 'unit' => 'px'], 'lineHeight' => 1.5,
+ ]]],
+ ]);
+
+ self::assertStringContainsString('--font-body: Inter;', $css);
+ self::assertStringContainsString('--text-body--line-height: 1.5;', $css);
+ }
+
+ private function generate(array $tokens): string
+ {
+ return $this->generator->generate($this->resolver->resolve($tokens));
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php
new file mode 100644
index 00000000000..92a5f2c1038
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Bridge/Tailwind/ThemeImporterTest.php
@@ -0,0 +1,292 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Bridge\Tailwind;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LogLevel;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\RecordingLogger;
+
+#[CoversClass(ThemeImporter::class)]
+final class ThemeImporterTest extends TestCase
+{
+ private ThemeImporter $importer;
+ private RecordingLogger $logger;
+
+ protected function setUp(): void
+ {
+ $this->importer = new ThemeImporter();
+ $this->logger = new RecordingLogger();
+ $this->importer->setLogger($this->logger);
+ }
+
+ public function testImportsSupportedNamespacesAndKeepsFlatNames(): void
+ {
+ $tokens = $this->importer->import(<<<'CSS'
+ @import "tailwindcss";
+ @theme static inline {
+ --*: initial;
+ --color-*: initial;
+ --color-brand-primary: oklch(72% 0.11 221.19 / 80%);
+ --font-body: "Inter var", sans-serif;
+ --font-weight-strong: 700;
+ --text-title: 2rem;
+ --tracking-tight: -0.02rem;
+ --leading-copy: 1.5;
+ --breakpoint-wide: 80rem;
+ --container-card: 40rem;
+ --spacing: 4px;
+ --radius-card: 12px;
+ --blur-soft: 8px;
+ --perspective-near: 500px;
+ --zoom-compact: 0.9;
+ --aspect-photo: 1.5;
+ --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
+ }
+ CSS);
+
+ self::assertSame('color', $tokens['color']['brand-primary']['$type']);
+ self::assertSame([
+ 'colorSpace' => 'oklch',
+ 'components' => [0.72, 0.11, 221.19],
+ 'alpha' => 0.8,
+ ], $tokens['color']['brand-primary']['$value']);
+ self::assertSame(['Inter var', 'sans-serif'], $tokens['font']['body']['$value']);
+ self::assertSame(700, $tokens['font-weight']['strong']['$value']);
+ self::assertSame(['value' => 2, 'unit' => 'rem'], $tokens['text']['title']['$value']);
+ self::assertSame(['value' => -0.02, 'unit' => 'rem'], $tokens['tracking']['tight']['$value']);
+ self::assertSame(1.5, $tokens['leading']['copy']['$value']);
+ self::assertSame(['value' => 4, 'unit' => 'px'], $tokens['spacing']['$root']['$value']);
+ self::assertSame([0.2, 0, 0, 1], $tokens['ease']['snappy']['$value']);
+
+ new TokenTreeBuilder()->resolve($tokens);
+ }
+
+ public function testImportsColorsShadowsAliasesAndMultipleThemeBlocks(): void
+ {
+ $tokens = $this->importer->import(<<<'CSS'
+ /* @theme { --color-fake: red; } */
+ @theme {
+ --color-base: #369c;
+ --color-rgb: rgb(51 102 204 / 50%);
+ --color-p3: color(display-p3 20% 0.4 0.8);
+ --shadow-card: 0 2px 4px #336699, 0 1px 2px 0 rgb(0 0 0 / 10%);
+ --inset-shadow-control: inset 0 0 2px color(srgb 0.2 0.4 0.8);
+ @keyframes ignored { from { opacity: 0; } to { opacity: 1; } }
+ }
+ @theme inline {
+ --color-brand: var(--color-base);
+ --color-base: #336699;
+ }
+ CSS);
+
+ self::assertSame('#336699', $tokens['color']['base']['$value']['hex']);
+ self::assertSame('{color.base}', $tokens['color']['brand']['$value']);
+ self::assertSame([0.2, 0.4, 0.8], $tokens['color']['rgb']['$value']['components']);
+ self::assertSame([0.2, 0.4, 0.8], $tokens['color']['p3']['$value']['components']);
+ self::assertCount(2, $tokens['shadow']['card']['$value']);
+ self::assertTrue($tokens['inset-shadow']['control']['$value']['inset']);
+
+ $resolved = new TokenTreeBuilder()->resolve($tokens);
+ self::assertSame($resolved['color']['base']->getValue(), $resolved['color']['brand']->getValue());
+ }
+
+ public function testImportsColorFormsFontWeightsAndEscapedStrings(): void
+ {
+ $tokens = $this->importer->import(<<<'CSS'
+ @theme {
+ /* declarations may contain comments */
+ --color-short: #abc;
+ --color-alpha: #1234;
+ --color-long-alpha: #11223344;
+ --color-percent-rgb: rgb(20% 40% 80%);
+ --color-hsl: hsl(120 50% 25%);
+ --color-none: oklab(none 0.1 -0.1);
+ --font-escaped: "A\" B", sans-serif;
+ --font-weight-semi: semibold;
+ --font-weight-heavy: heavy;
+ --tracking-zero: 0;
+ --spacing-zero: 0;
+ @custom-media --ignored;
+ }
+ CSS);
+
+ self::assertSame('#aabbcc', $tokens['color']['short']['$value']['hex']);
+ self::assertArrayHasKey('alpha', $tokens['color']['alpha']['$value']);
+ self::assertArrayHasKey('alpha', $tokens['color']['long-alpha']['$value']);
+ self::assertSame([0.2, 0.4, 0.8], $tokens['color']['percent-rgb']['$value']['components']);
+ self::assertSame([120, 50, 25], $tokens['color']['hsl']['$value']['components']);
+ self::assertSame('none', $tokens['color']['none']['$value']['components'][0]);
+ self::assertSame(['A" B', 'sans-serif'], $tokens['font']['escaped']['$value']);
+ self::assertSame('semi-bold', $tokens['font-weight']['semi']['$value']);
+ self::assertSame('heavy', $tokens['font-weight']['heavy']['$value']);
+ self::assertSame(['value' => 0, 'unit' => 'px'], $tokens['tracking']['zero']['$value']);
+ self::assertSame(['value' => 0, 'unit' => 'px'], $tokens['spacing']['zero']['$value']);
+
+ new TokenTreeBuilder()->resolve($tokens);
+ }
+
+ public function testRoundTripsTheTailwindExporterSubset(): void
+ {
+ $source = [
+ 'color' => ['brand' => ['$type' => 'color', '$value' => ['colorSpace' => 'srgb', 'components' => [0.2, 0.4, 0.8]]]],
+ 'font' => ['body' => ['$type' => 'fontFamily', '$value' => ['Inter', 'sans-serif']]],
+ 'weight' => ['strong' => ['$type' => 'fontWeight', '$value' => 700]],
+ 'spacing' => [
+ '$root' => ['$type' => 'dimension', '$value' => ['value' => 0.25, 'unit' => 'rem']],
+ 'base' => ['$type' => 'dimension', '$value' => ['value' => 8, 'unit' => 'px']],
+ 'md' => ['$type' => 'dimension', '$value' => ['value' => 16, 'unit' => 'px']],
+ ],
+ 'text' => ['sm' => ['$type' => 'dimension', '$value' => ['value' => 0.875, 'unit' => 'rem']]],
+ 'tracking' => ['tight' => ['$type' => 'number', '$value' => -0.02]],
+ 'easing' => ['snappy' => ['$type' => 'cubicBezier', '$value' => [0.2, 0, 0, 1]]],
+ 'elevation' => ['card' => ['$type' => 'shadow', '$value' => [
+ 'color' => ['colorSpace' => 'srgb', 'components' => [0.1, 0.2, 0.3]],
+ 'offsetX' => ['value' => 0, 'unit' => 'px'],
+ 'offsetY' => ['value' => 2, 'unit' => 'px'],
+ 'blur' => ['value' => 4, 'unit' => 'px'],
+ 'spread' => ['value' => 0, 'unit' => 'px'],
+ 'inset' => false,
+ ]]],
+ ];
+ $resolver = new TokenTreeBuilder();
+ $css = new ThemeGenerator()->generate($resolver->resolve($source));
+ $roundTrip = $resolver->resolve($this->importer->import($css));
+
+ self::assertSame($source['color']['brand']['$value'], $roundTrip['color']['brand']->getValue());
+ self::assertSame($source['font']['body']['$value'], $roundTrip['font']['body']->getValue());
+ self::assertSame(700, $roundTrip['font-weight']['strong']->getValue());
+ self::assertSame(['value' => 16, 'unit' => 'px'], $roundTrip['spacing']['md']->getValue());
+ self::assertSame(['value' => 0.25, 'unit' => 'rem'], $roundTrip['spacing']['$root']->getValue());
+ self::assertSame(['value' => 8, 'unit' => 'px'], $roundTrip['spacing']['base']->getValue());
+ self::assertSame(['value' => 0.875, 'unit' => 'rem'], $roundTrip['text']['sm']->getValue());
+ self::assertSame(-0.02, $roundTrip['tracking']['tight']->getValue());
+ self::assertSame([0.2, 0, 0, 1], $roundTrip['ease']['snappy']->getValue());
+ self::assertSame($source['elevation']['card']['$value'], $roundTrip['shadow']['card']->getValue());
+ }
+
+ /** @return iterable */
+ public static function invalidCss(): iterable
+ {
+ yield 'no theme' => [':root { --color-brand: red; }', 'No top-level'];
+ yield 'nested theme' => ['@media (width > 10px) { @theme { --color-brand: #fff; } }', 'No top-level'];
+ yield 'unterminated leading comment' => ['/* comment', 'Unterminated CSS comment'];
+ yield 'bad options' => ['@theme prefix(tw) { --color-brand: #fff; }', 'Unsupported @theme options'];
+ yield 'theme without block' => ['@theme;', 'Malformed Tailwind @theme block'];
+ yield 'unterminated block' => ['@theme { --color-brand: #fff;', 'Unterminated'];
+ yield 'unterminated block comment' => ['@theme { /* comment', 'Unterminated CSS comment'];
+ yield 'unterminated string' => ['@theme { --font-body: "Inter; }', 'Unterminated CSS string'];
+ yield 'unexpected rule' => ['@theme { color: red; }', 'Unexpected content'];
+ yield 'missing colon' => ['@theme { --color-brand }', 'Malformed Tailwind theme declaration'];
+ yield 'invalid variable name' => ['@theme { --color.bad: #fff; }', 'Invalid Tailwind theme variable'];
+ yield 'brace in value' => ['@theme { --font-body: Inter{}; }', 'Malformed Tailwind theme declaration'];
+ yield 'unterminated inner at-rule' => ['@theme { @custom-media --narrow }', 'Unterminated at-rule'];
+ }
+
+ #[DataProvider('skippedVariables')]
+ public function testLogsAVariableItCannotImport(string $css, string $reason, string $level = LogLevel::WARNING): void
+ {
+ $tokens = $this->importer->import($css);
+
+ self::assertSame([], $tokens);
+ self::assertCount(1, $this->logger->records);
+ self::assertSame($level, $this->logger->records[0]['level']);
+ self::assertStringContainsString($reason, $this->logger->records[0]['message']);
+ }
+
+ /** @return iterable */
+ public static function skippedVariables(): iterable
+ {
+ yield 'unknown namespace' => ['@theme { --animate-spin: spin 1s; }', 'has no DTCG type', LogLevel::NOTICE];
+ yield 'non-reset wildcard' => ['@theme { --color-*: #fff; }', 'Unsupported Tailwind theme variable', LogLevel::NOTICE];
+ yield 'modifier' => ['@theme { --text-xs--line-height: 1; }', 'modifies another variable', LogLevel::NOTICE];
+ yield 'unknown alias' => ['@theme { --color-brand: var(--external); }', 'references unknown'];
+ yield 'expression' => ['@theme { --spacing-fluid: calc(1rem + 1vw); }', 'cannot be represented losslessly'];
+ yield 'unsupported unit' => ['@theme { --spacing-fluid: 1vw; }', 'allows px and rem'];
+ yield 'bad number' => ['@theme { --zoom-fluid: infinite; }', 'finite number'];
+ yield 'named color' => ['@theme { --color-brand: red; }', 'structured CSS color'];
+ yield 'bad hex length' => ['@theme { --color-brand: #12345; }', 'invalid hexadecimal'];
+ yield 'empty color' => ['@theme { --color-brand: oklch(); }', 'has no value'];
+ yield 'short rgb' => ['@theme { --color-brand: rgb(1 2); }', 'three color components'];
+ yield 'unknown color space' => ['@theme { --color-brand: device-cmyk(0 0 0); }', 'unsupported color space'];
+ yield 'short structured color' => ['@theme { --color-brand: oklch(0.5 0.2); }', 'three color components'];
+ yield 'too many alpha separators' => ['@theme { --color-brand: oklch(50% 0.2 20 / 1 / 1); }', 'one alpha separator'];
+ yield 'malformed color function' => ['@theme { --color-brand: color(srgb); }', 'malformed color()'];
+ yield 'font function' => ['@theme { --font-body: env(font); }', 'unsupported font family'];
+ yield 'empty font family' => ['@theme { --font-body: Inter, ; }', 'empty font family'];
+ yield 'bad font weight' => ['@theme { --font-weight-heavy: 1200; }', 'not a DTCG font weight'];
+ yield 'bad bezier' => ['@theme { --ease-snap: ease-in; }', 'cubic-bezier'];
+ yield 'short bezier' => ['@theme { --ease-snap: cubic-bezier(0, 1); }', 'four cubic Bezier'];
+ yield 'bad shadow' => ['@theme { --shadow-card: 0 2px red; }', 'px or rem'];
+ yield 'shadow without color' => ['@theme { --shadow-card: 0 2px 4px; }', 'not a lossless CSS box shadow'];
+ yield 'shadow with two colors' => ['@theme { --shadow-card: 0 2px #fff #000; }', 'multiple shadow colors'];
+ yield 'shadow with too many dimensions' => ['@theme { --shadow-card: 0 1px 2px 3px 4px #000; }', 'not a lossless CSS box shadow'];
+ }
+
+ #[DataProvider('invalidCss')]
+ public function testRejectsAFileThatIsNotATailwindTheme(string $css, string $message): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage($message);
+ $this->importer->import($css);
+ }
+
+ public function testAcceptsEveryThemeOption(): void
+ {
+ foreach (['default', 'reference', 'static', 'inline', 'default inline reference'] as $options) {
+ $tokens = $this->importer->import("@theme {$options} { --color-a: #fff; }");
+
+ self::assertArrayHasKey('a', $tokens['color'], $options);
+ }
+ }
+
+ public function testTheLastDeclarationMayOmitItsSemicolon(): void
+ {
+ self::assertArrayHasKey('b', $this->importer->import('@theme { --color-a: #fff; --color-b: #000 }')['color']);
+ }
+
+ public function testInsetShadowsKeepTheirOwnGroup(): void
+ {
+ $tokens = $this->importer->import('@theme { --shadow-xs: 0 1px 2px #0000000d; --inset-shadow-xs: inset 0 1px 1px #0000000d; }');
+
+ self::assertArrayHasKey('xs', $tokens['shadow']);
+ self::assertArrayHasKey('xs', $tokens['inset-shadow']);
+ }
+
+ public function testImportsTabSizes(): void
+ {
+ self::assertSame(4, $this->importer->import('@theme { --tab-size-code: 4; }')['tab-size']['code']['$value']);
+ }
+
+ public function testSkippedVariablesAreLoggedByName(): void
+ {
+ $tokens = $this->importer->import('@theme default { --color-a: #fff; --animate-spin: spin 1s linear infinite; --text-xs--line-height: calc(1 / 0.75); --font-sans--font-feature-settings: normal; --text-shadow-xs: 0 1px 0 #000; --tracking-tight: -0.025em; --default-font-family: --theme(--font-sans, initial); }');
+
+ self::assertSame(['a'], array_keys($tokens['color']));
+ self::assertSame(
+ ['--animate-spin', '--text-xs--line-height', '--font-sans--font-feature-settings', '--text-shadow-xs', '--default-font-family', '--tracking-tight'],
+ array_map(static fn (array $record): mixed => $record['context']['variable'] ?? null, $this->logger->records),
+ );
+ }
+
+ public function testImportsWithoutALogger(): void
+ {
+ $tokens = new ThemeImporter()->import('@theme { --color-a: #fff; --font-sans--font-feature-settings: normal; }');
+
+ self::assertSame(['color'], array_keys($tokens));
+ self::assertSame(['a'], array_keys($tokens['color']));
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php b/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php
new file mode 100644
index 00000000000..bef119ef533
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/CacheWarmer/DesignTokensCacheWarmerTest.php
@@ -0,0 +1,62 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\CacheWarmer;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+
+#[CoversClass(DesignTokensCacheWarmer::class)]
+final class DesignTokensCacheWarmerTest extends TestCase
+{
+ private string $buildDir;
+
+ protected function setUp(): void
+ {
+ $this->buildDir = sys_get_temp_dir().'/ux-design-tokens-warmer-'.bin2hex(random_bytes(6));
+ }
+
+ protected function tearDown(): void
+ {
+ new Filesystem()->remove($this->buildDir);
+ }
+
+ public function testWarmupIsOptional(): void
+ {
+ self::assertTrue($this->warmer()->isOptional());
+ }
+
+ public function testWritesTheStylesheetAndReturnsNoFileToPreload(): void
+ {
+ self::assertSame([], $this->warmer()->warmUp($this->buildDir, $this->buildDir));
+
+ self::assertStringContainsString('--dt-color-action-primary:', (string) file_get_contents($this->directory().'/tokens.css'));
+ }
+
+ private function directory(): string
+ {
+ return StylesheetCache::directory($this->buildDir);
+ }
+
+ private function warmer(): DesignTokensCacheWarmer
+ {
+ return new DesignTokensCacheWarmer(new StylesheetCache(
+ new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'),
+ new CssGenerator('dt'),
+ $this->buildDir,
+ ));
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php b/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php
new file mode 100644
index 00000000000..5e2f923f4ce
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/CacheWarmer/StylesheetCacheTest.php
@@ -0,0 +1,136 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\CacheWarmer;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+
+#[CoversClass(StylesheetCache::class)]
+final class StylesheetCacheTest extends TestCase
+{
+ private string $directory;
+
+ protected function setUp(): void
+ {
+ $this->directory = sys_get_temp_dir().'/dt-stylesheets-'.bin2hex(random_bytes(4));
+ $filesystem = new Filesystem();
+ $filesystem->dumpFile($this->directory.'/a.tokens.json', '{"c":{"$type":"number","$value":1},"d":{"$ref":"b.json#/d"}}');
+ $filesystem->dumpFile($this->directory.'/b.json', '{"d":{"$type":"number","$value":10}}');
+ }
+
+ protected function tearDown(): void
+ {
+ new Filesystem()->remove($this->directory);
+ }
+
+ public function testWritesAMissingStylesheet(): void
+ {
+ $path = $this->cache(debug: false)->path();
+
+ self::assertFileExists($path);
+ self::assertStringContainsString('--dt-c: 1;', (string) file_get_contents($path));
+ }
+
+ public function testRebuildsInDebugWhenADocumentChanges(): void
+ {
+ $cache = $this->cache(debug: true);
+ $cache->path();
+ $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}');
+
+ self::assertStringContainsString('--dt-c: 2;', (string) file_get_contents($cache->path()));
+ }
+
+ public function testRebuildsInDebugWhenAFileReachedByAReferenceChanges(): void
+ {
+ $cache = $this->cache(debug: true);
+ $cache->path();
+ $this->rewrite('b.json', '{"d":{"$type":"number","$value":20}}');
+
+ self::assertStringContainsString('--dt-d: 20;', (string) file_get_contents($cache->path()));
+ }
+
+ public function testKeepsTheStylesheetOutsideDebug(): void
+ {
+ $cache = $this->cache(debug: false);
+ $cache->path();
+ $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}');
+
+ self::assertStringContainsString('--dt-c: 1;', (string) file_get_contents($cache->path()));
+ }
+
+ public function testRebuildsInDebugWhenTheConfigurationChanges(): void
+ {
+ $this->cache(debug: true, fingerprint: 'a')->path();
+
+ $path = $this->cache(debug: true, prefix: 'app', fingerprint: 'b')->path();
+
+ self::assertStringContainsString('--app-c: 1;', (string) file_get_contents($path));
+ }
+
+ public function testReadsNothingWithoutWritingAMissingStylesheet(): void
+ {
+ self::assertNull($this->cache(debug: true)->read());
+ self::assertDirectoryDoesNotExist($this->directory.'/build');
+ }
+
+ public function testReadsAStylesheetThatIsFresh(): void
+ {
+ $cache = $this->cache(debug: true);
+ $cache->path();
+
+ self::assertStringContainsString('--dt-c: 1;', (string) $cache->read());
+ }
+
+ public function testReadsNothingOnceAStylesheetIsStale(): void
+ {
+ $cache = $this->cache(debug: true);
+ $cache->path();
+ $this->rewrite('a.tokens.json', '{"c":{"$type":"number","$value":2}}');
+
+ self::assertNull($cache->read());
+ }
+
+ public function testWritesTheDarkSchemeOfTheDefaultSelection(): void
+ {
+ $css = (string) file_get_contents(new StylesheetCache(
+ new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'),
+ new CssGenerator('dt'),
+ $this->directory.'/build',
+ )->path());
+
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.2 0.4 0.8);', $css);
+ self::assertStringContainsString(':root[data-theme="dark"]', $css);
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $css);
+ }
+
+ private function rewrite(string $file, string $json): void
+ {
+ $filesystem = new Filesystem();
+ $filesystem->dumpFile($this->directory.'/'.$file, $json);
+ $filesystem->touch($this->directory.'/'.$file, time() + 10);
+ }
+
+ private function cache(bool $debug, string $prefix = 'dt', string $fingerprint = ''): StylesheetCache
+ {
+ return new StylesheetCache(
+ new ConfiguredTokenResolver(paths: [$this->directory.'/a.tokens.json']),
+ new CssGenerator($prefix),
+ $this->directory.'/build',
+ debug: $debug,
+ fingerprint: $fingerprint,
+ );
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php b/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php
new file mode 100644
index 00000000000..ecbcc7618ff
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Command/CommandNamingTest.php
@@ -0,0 +1,63 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Command;
+
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\UX\DesignTokens\Command\DebugTokensCommand;
+use Symfony\UX\DesignTokens\Command\ExportCommand;
+use Symfony\UX\DesignTokens\Command\ImportCommand;
+use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand;
+
+final class CommandNamingTest extends TestCase
+{
+ /** @return iterable, string}> */
+ public static function commands(): iterable
+ {
+ yield 'debug' => [DebugTokensCommand::class, 'debug:design-tokens'];
+ yield 'export' => [ExportCommand::class, 'ux:design-tokens:export'];
+ yield 'import' => [ImportCommand::class, 'ux:design-tokens:import'];
+ yield 'lint' => [LintDesignTokensCommand::class, 'lint:design-tokens'];
+ }
+
+ /** @param class-string $class */
+ #[DataProvider('commands')]
+ public function testEveryCommandHasOneNameAndNoAlias(string $class, string $name): void
+ {
+ $attributes = new \ReflectionClass($class)->getAttributes(AsCommand::class);
+ self::assertCount(1, $attributes, \sprintf('"%s" should carry one #[AsCommand].', $class));
+
+ $declared = $attributes[0]->getArguments();
+
+ self::assertSame($name, $declared['name'] ?? null);
+ self::assertArrayNotHasKey('aliases', $declared);
+ }
+
+ public function testEveryCommandInThePackageIsCovered(): void
+ {
+ $declared = array_map(
+ static fn (string $file): string => basename($file, '.php'),
+ glob(\dirname(__DIR__, 3).'/src/Command/*Command.php') ?: [],
+ );
+ $covered = array_map(
+ static fn (array $case): string => new \ReflectionClass($case[0])->getShortName(),
+ iterator_to_array(self::commands(), false),
+ );
+
+ sort($declared);
+ sort($covered);
+
+ self::assertSame($declared, $covered, 'A new command must be added to the data provider.');
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php b/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php
new file mode 100644
index 00000000000..7917fb4e99d
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Command/DebugTokensCommandTest.php
@@ -0,0 +1,134 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Command;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\UX\DesignTokens\Command\DebugTokensCommand;
+use Symfony\UX\DesignTokens\Resolver\ArrayDocumentLoader;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader;
+use Symfony\UX\DesignTokens\Tests\Fixtures\Registries;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory;
+use Symfony\UX\DesignTokens\TokenRegistry;
+
+#[CoversClass(DebugTokensCommand::class)]
+final class DebugTokensCommandTest extends TestCase
+{
+ private ConfiguredTokenResolver $resolver;
+ private TokenRegistry $registry;
+
+ protected function setUp(): void
+ {
+ $this->resolver = new ConfiguredTokenResolver(new ArrayDocumentLoader(['memory.tokens.json' => json_decode('{"color":{"brand":{"$type":"color","$description":"Brand color","$value":{"colorSpace":"srgb","components":[0.2,0.4,0.8]}}},"spacing":{"sm":{"$type":"dimension","$value":{"value":4,"unit":"px"}}}}', true, 512, \JSON_THROW_ON_ERROR)]), ['memory.tokens.json']);
+ $this->registry = new TokenRegistry($this->resolver);
+ }
+
+ public function testDisplaysResolvedTokensAsTable(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand($this->registry));
+ $tester->execute([]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('color.brand', $tester->getDisplay());
+ self::assertStringContainsString('Brand color', $tester->getDisplay());
+ self::assertStringContainsString('spacing.sm', $tester->getDisplay());
+ }
+
+ public function testFiltersByTokenOrGroupAndReportsJson(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand($this->registry));
+ $tester->execute(['path' => 'color', '--format' => 'json']);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ $result = json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR);
+ self::assertSame('color', $result['tokens']['color.brand']['type']);
+ self::assertSame([0.2, 0.4, 0.8], $result['tokens']['color.brand']['value']['components']);
+ self::assertArrayNotHasKey('spacing.sm', $result['tokens']);
+
+ $tester->execute(['path' => 'spacing.sm']);
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('spacing.sm', $tester->getDisplay());
+ }
+
+ public function testReportsMissingPathAndEmptyRegistry(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand($this->registry));
+ $tester->execute(['path' => 'missing']);
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('No design tokens found', $tester->getDisplay());
+
+ $tester = new CommandTester(new DebugTokensCommand(new TokenRegistry()));
+ $tester->execute([]);
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('No design tokens are configured', $tester->getDisplay());
+ }
+
+ public function testReportsRegistryFailureAndInvalidInput(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand(Registries::fromFiles(paths: ['/missing.tokens.json'])));
+ $tester->execute([]);
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('not found', $tester->getDisplay());
+
+ $tester = new CommandTester(new DebugTokensCommand($this->registry));
+ $tester->execute(['--format' => 'xml']);
+ self::assertSame(Command::INVALID, $tester->getStatusCode());
+ self::assertStringContainsString('--format', $tester->getDisplay());
+
+ $tester->execute(['path' => ['invalid']]);
+ self::assertSame(Command::INVALID, $tester->getStatusCode());
+ self::assertStringContainsString('must be a string', $tester->getDisplay());
+ }
+
+ public function testSourcesNamesTheFileThatWonAndTheOnesItReplaced(): void
+ {
+ $directory = new TemporaryDirectory();
+ $base = $directory->write('base.tokens.json', '{"color":{"accent":{"$type":"color","$value":{"colorSpace":"srgb","components":[0,0,1]}}}}');
+ $brand = $directory->write('brand.tokens.json', '{"color":{"accent":{"$type":"color","$value":{"colorSpace":"srgb","components":[1,0,0]}}}}');
+
+ try {
+ $resolver = new ConfiguredTokenResolver(new JsonDocumentLoader(), [$base, $brand]);
+ $tester = new CommandTester(new DebugTokensCommand(new TokenRegistry($resolver), $resolver));
+ $tester->execute(['--sources' => true, '--format' => 'json']);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ $accent = json_decode($tester->getDisplay(), true)['tokens']['color.accent'];
+
+ self::assertSame($brand, $accent['source']);
+ self::assertSame([$base], $accent['overrides']);
+ } finally {
+ $directory->remove();
+ }
+ }
+
+ public function testSourcesAddsTwoColumnsToTheTable(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand($this->registry, $this->resolver));
+ $tester->execute(['--sources' => true]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('Source', $tester->getDisplay());
+ self::assertStringContainsString('Replaced', $tester->getDisplay());
+ }
+
+ public function testProvenanceIsOffByDefault(): void
+ {
+ $tester = new CommandTester(new DebugTokensCommand($this->registry));
+ $tester->execute([]);
+
+ self::assertStringNotContainsString('Source', $tester->getDisplay());
+ self::assertStringNotContainsString('Replaced', $tester->getDisplay());
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php b/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php
new file mode 100644
index 00000000000..53b5c65c988
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Command/ExportCommandTest.php
@@ -0,0 +1,334 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Command;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandCompletionTester;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\DependencyInjection\ServiceLocator;
+use Symfony\UX\DesignTokens\Bridge\GoogleDesignMd\DesignMdGenerator;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeGenerator;
+use Symfony\UX\DesignTokens\Command\ExportCommand;
+use Symfony\UX\DesignTokens\Generator\ColorScheme;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Generator\DtcgGenerator;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Generator\JavaScriptGenerator;
+use Symfony\UX\DesignTokens\Tests\Fixtures\Registries;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues;
+use Symfony\UX\DesignTokens\TokenRegistry;
+
+#[CoversClass(ExportCommand::class)]
+final class ExportCommandTest extends TestCase
+{
+ private TokenRegistry $tokenRegistry;
+ private CommandTester $tester;
+ private TemporaryDirectory $directory;
+
+ protected function setUp(): void
+ {
+ $this->tokenRegistry = Registries::fromJson(json_encode([
+ 'color' => [
+ 'brand' => [
+ '$type' => 'color',
+ '$value' => TokenValues::color(),
+ ],
+ ],
+ 'spacing' => [
+ '$type' => 'dimension',
+ 'md' => ['$value' => TokenValues::dimension(16)],
+ ],
+ ], \JSON_THROW_ON_ERROR));
+
+ $this->tester = new CommandTester(self::command($this->tokenRegistry));
+ $this->directory = new TemporaryDirectory();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->directory->remove();
+ }
+
+ /**
+ * @param array $input
+ * @param list $expected
+ */
+ #[DataProvider('stdoutExports')]
+ public function testExportsToStdout(array $input, array $expected): void
+ {
+ $this->tester->execute($input);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ foreach ($expected as $fragment) {
+ self::assertStringContainsString($fragment, $this->tester->getDisplay());
+ }
+ }
+
+ /** @return iterable, list}> */
+ public static function stdoutExports(): iterable
+ {
+ yield 'css' => [['format' => 'css'], [':root {', '--dt-color-brand']];
+ yield 'css with an explicit application prefix' => [['format' => 'css', '--css-prefix' => 'my'], ['--my-color-brand']];
+ yield 'tailwind theme' => [['format' => 'tailwind'], ['@theme static {', '--color-brand:', '--spacing-md: 16px;']];
+ yield 'javascript' => [['format' => 'javascript'], [
+ 'export const tokens = Object.freeze(JSON.parse(',
+ '\\"color.brand\\":\\"color(srgb 0.2 0.4 0.8)\\"',
+ 'export function token(path)',
+ ]];
+ yield 'design.md' => [['format' => 'design.md'], ['version: alpha', '# Design System']];
+ yield 'design.md with a custom title' => [['format' => 'design.md', '--title' => 'Acme Corp'], ['Acme Corp']];
+ }
+
+ /** @param array $input */
+ #[DataProvider('rejectedExports')]
+ public function testRejectsAnExportItCannotRun(array $input, int $status, string $message): void
+ {
+ $this->tester->execute($input);
+
+ self::assertSame($status, $this->tester->getStatusCode());
+ self::assertStringContainsString($message, $this->tester->getDisplay());
+ }
+
+ /** @return iterable, int, string}> */
+ public static function rejectedExports(): iterable
+ {
+ yield 'invalid css prefix' => [['format' => 'css', '--css-prefix' => '--my'], Command::FAILURE, 'CSS prefix'];
+ yield 'unknown format' => [['format' => 'xml'], Command::INVALID, 'Unknown format'];
+ yield 'malformed input' => [['format' => 'dtcg', '--input' => ['scheme']], Command::FAILURE, 'name=value'];
+ yield 'permutations without a resolver document' => [['format' => 'dtcg', 'output' => '/tmp/unused.json', '--all-permutations' => true], Command::FAILURE, 'Resolver document'];
+ yield 'permutations without an output path' => [['format' => 'dtcg', '--all-permutations' => true], Command::FAILURE, 'output path'];
+ }
+
+ public function testExportCssWritesTheDarkColorScheme(): void
+ {
+ $tester = new CommandTester(self::command(Registries::colorScheme()));
+ $tester->execute(['format' => 'css', '--css-prefix' => 'my']);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ [$root, $dark] = explode('@media (prefers-color-scheme: dark)', $tester->getDisplay(), 2);
+ self::assertStringContainsString('--my-color-action-primary: color(srgb 0.2 0.4 0.8);', $root);
+ self::assertStringContainsString('--my-color-action-primary: color(srgb 0.5 0.6 0.9);', $dark);
+ self::assertStringContainsString(':root[data-theme="dark"]', $dark);
+ self::assertStringNotContainsString('--my-dimension-spacing-md', $dark);
+ }
+
+ public function testExportCssOfASelectedSchemeHasNoDarkBlock(): void
+ {
+ $tester = new CommandTester(self::command(Registries::colorScheme()));
+ $tester->execute(['format' => 'css', '--input' => ['scheme=dark']]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('--dt-color-action-primary: color(srgb 0.5 0.6 0.9);', $tester->getDisplay());
+ self::assertStringNotContainsString('prefers-color-scheme', $tester->getDisplay());
+ }
+
+ public function testExportCssUsesTheConfiguredColorScheme(): void
+ {
+ $generators = new ServiceLocator(['css' => static fn (): GeneratorInterface => new CssGenerator()]);
+ $configured = new CommandTester(new ExportCommand(Registries::colorScheme('named-theme.resolver.json'), $generators, colorScheme: new ColorScheme('mode', 'day', 'night')));
+ $default = new CommandTester(new ExportCommand(Registries::colorScheme('named-theme.resolver.json'), $generators));
+
+ $configured->execute(['format' => 'css']);
+ $default->execute(['format' => 'css']);
+
+ self::assertStringContainsString('prefers-color-scheme', $configured->getDisplay());
+ self::assertStringNotContainsString('prefers-color-scheme', $default->getDisplay());
+ }
+
+ public function testExportDtcgToStdout(): void
+ {
+ $this->tester->execute(['format' => 'dtcg']);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+
+ $data = json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR);
+ self::assertSame('color', $data['color']['brand']['$type']);
+ self::assertSame(TokenValues::color(), $data['color']['brand']['$value']);
+ self::assertSame('dimension', $data['spacing']['md']['$type']);
+ }
+
+ public function testExportToFile(): void
+ {
+ $outputPath = $this->directory->path('tokens.css');
+
+ $this->tester->execute(['format' => 'css', 'output' => $outputPath]);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ self::assertFileExists($outputPath);
+ self::assertStringContainsString(':root {', file_get_contents($outputPath));
+ }
+
+ public function testExportCreatesTheOutputDirectory(): void
+ {
+ $outputPath = $this->directory->path('export/nested/tokens.css');
+
+ $this->tester->execute(['format' => 'css', 'output' => $outputPath]);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ self::assertFileExists($outputPath);
+ }
+
+ public function testExportReportsAnUnwritableOutputPath(): void
+ {
+ $blocker = $this->directory->write('blocker', '');
+
+ $this->tester->execute(['format' => 'css', 'output' => $blocker.'/tokens.css']);
+
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('Could not write export', $this->tester->getDisplay());
+ }
+
+ public function testExportSelectsAResolverContextWithInput(): void
+ {
+ $tester = $this->resolverTester();
+
+ $tester->execute(['format' => 'dtcg', '--input' => ['scheme=dark']]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('"$value": 2', $tester->getDisplay());
+ }
+
+ public function testExportWritesOneFilePerPermutation(): void
+ {
+ $tester = $this->resolverTester();
+
+ $tester->execute([
+ 'format' => 'dtcg',
+ 'output' => $this->directory->path('permutations/theme.tokens.json'),
+ '--all-permutations' => true,
+ ]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertFileExists($light = $this->directory->path('permutations/theme.scheme-light.tokens.json'));
+ self::assertFileExists($dark = $this->directory->path('permutations/theme.scheme-dark.tokens.json'));
+ self::assertStringContainsString('"$value": 1', (string) file_get_contents($light));
+ self::assertStringContainsString('"$value": 2', (string) file_get_contents($dark));
+ }
+
+ public function testExportRefusesTwoPermutationsSharingAFileName(): void
+ {
+ $path = $this->directory->write('export.resolver.json', [
+ 'version' => '2025.10',
+ 'modifiers' => ['size' => ['contexts' => [
+ 'a b' => [['n' => ['$type' => 'number', '$value' => 1]]],
+ 'a-b' => [['n' => ['$type' => 'number', '$value' => 2]]],
+ ], 'default' => 'a b']],
+ 'resolutionOrder' => [['$ref' => '#/modifiers/size']],
+ ]);
+ $directory = $this->directory->path('permutations');
+
+ $tester = new CommandTester(self::command(Registries::fromFiles(resolverPath: $path)));
+ $tester->execute(['format' => 'dtcg', 'output' => $directory.'/theme.tokens.json', '--all-permutations' => true]);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('same file', $tester->getDisplay());
+ self::assertDirectoryDoesNotExist($directory);
+ }
+
+ public function testAnyGeneratorReceivesTheExportContext(): void
+ {
+ $generator = new class implements GeneratorInterface {
+ /** @var array */
+ public array $context = [];
+
+ public function generate(array $resolvedTokens, array $context = []): string
+ {
+ $this->context = $context;
+
+ return 'custom';
+ }
+ };
+
+ $tester = new CommandTester(new ExportCommand(Registries::fromFiles(resolverPath: $this->writeSchemeResolver()), new ServiceLocator(['custom' => static fn (): GeneratorInterface => $generator])));
+ $tester->execute(['format' => 'custom', '--title' => 'Acme', '--css-prefix' => 'app']);
+
+ self::assertSame('Acme', $generator->context[GeneratorInterface::TITLE]);
+ self::assertSame('app', $generator->context[GeneratorInterface::CSS_PREFIX]);
+ self::assertSame(2, $generator->context[GeneratorInterface::DARK_TOKENS]['theme']->getValue());
+ }
+
+ public function testAFormatKeyedByAServiceIdIsFound(): void
+ {
+ $generator = new class implements GeneratorInterface {
+ public function generate(array $resolvedTokens, array $context = []): string
+ {
+ return 'custom';
+ }
+ };
+ $tester = new CommandTester(new ExportCommand($this->tokenRegistry, new ServiceLocator(['App\\Export\\Scss' => static fn (): GeneratorInterface => $generator])));
+
+ self::assertSame(Command::SUCCESS, $tester->execute(['format' => 'App\\Export\\Scss']));
+ self::assertSame('custom', $tester->getDisplay());
+ }
+
+ public function testAnApplicationCssGeneratorIsUsedAsIs(): void
+ {
+ $generator = new class implements GeneratorInterface {
+ public function generate(array $resolvedTokens, array $context = []): string
+ {
+ return 'custom css';
+ }
+ };
+ $tester = new CommandTester(new ExportCommand($this->tokenRegistry, new ServiceLocator(['css' => static fn (): GeneratorInterface => $generator])));
+
+ $tester->execute(['format' => 'css']);
+
+ self::assertSame('custom css', $tester->getDisplay());
+ }
+
+ public function testCompletesFormats(): void
+ {
+ $tester = new CommandCompletionTester(self::command($this->tokenRegistry));
+
+ self::assertContains('css', $tester->complete(['']));
+ self::assertContains('dtcg', $tester->complete(['']));
+ }
+
+ private function resolverTester(): CommandTester
+ {
+ return new CommandTester(self::command(Registries::fromFiles(resolverPath: $this->writeSchemeResolver())));
+ }
+
+ private function writeSchemeResolver(): string
+ {
+ return $this->directory->write('theme.resolver.json', [
+ 'version' => '2025.10',
+ 'modifiers' => [
+ 'scheme' => [
+ 'contexts' => [
+ 'light' => [['theme' => ['$type' => 'number', '$value' => 1]]],
+ 'dark' => [['theme' => ['$type' => 'number', '$value' => 2]]],
+ ],
+ 'default' => 'light',
+ ],
+ ],
+ 'resolutionOrder' => [['$ref' => '#/modifiers/scheme']],
+ ]);
+ }
+
+ private static function command(TokenRegistry $registry): ExportCommand
+ {
+ $generators = new ServiceLocator([
+ 'dtcg' => static fn (): GeneratorInterface => new DtcgGenerator(),
+ 'css' => static fn (): GeneratorInterface => new CssGenerator(),
+ 'javascript' => static fn (): GeneratorInterface => new JavaScriptGenerator(),
+ 'design.md' => static fn (): GeneratorInterface => new DesignMdGenerator(),
+ 'tailwind' => static fn (): GeneratorInterface => new ThemeGenerator(),
+ ]);
+
+ return new ExportCommand($registry, $generators);
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php b/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php
new file mode 100644
index 00000000000..19e65664655
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Command/ImportCommandTest.php
@@ -0,0 +1,165 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Command;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Tester\CommandCompletionTester;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\DependencyInjection\ServiceLocator;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter;
+use Symfony\UX\DesignTokens\Command\ImportCommand;
+use Symfony\UX\DesignTokens\Importer\ImporterInterface;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory;
+
+#[CoversClass(ImportCommand::class)]
+final class ImportCommandTest extends TestCase
+{
+ private TemporaryDirectory $directory;
+ private string $input;
+ private CommandTester $tester;
+
+ protected function setUp(): void
+ {
+ $this->directory = new TemporaryDirectory();
+ $this->input = $this->directory->write('theme.css', '@theme { --color-brand: #336699; --spacing-md: 1rem; }');
+ $this->tester = new CommandTester(self::command());
+ }
+
+ protected function tearDown(): void
+ {
+ $this->directory->remove();
+ }
+
+ public function testImportsToStdout(): void
+ {
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input]);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ $tokens = json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR);
+ self::assertSame('color', $tokens['color']['brand']['$type']);
+ self::assertSame('dimension', $tokens['spacing']['md']['$type']);
+ }
+
+ public function testImportsToAFile(): void
+ {
+ $output = $this->directory->path('tokens.json');
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output]);
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ self::assertFileExists($output);
+ self::assertStringContainsString('Imported tailwind tokens', $this->tester->getDisplay());
+ }
+
+ public function testReportsUnknownFormatInvalidInputAndWriteFailure(): void
+ {
+ $this->tester->execute(['format' => 'json', 'input' => $this->input]);
+ self::assertSame(Command::INVALID, $this->tester->getStatusCode());
+ self::assertStringContainsString('Unknown import format "json". Available: tailwind', $this->tester->getDisplay());
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input.'.missing']);
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('not found', $this->tester->getDisplay());
+
+ $blocker = $this->directory->write('blocker', '');
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $blocker.'/tokens.json']);
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('Could not write imported tokens', $this->tester->getDisplay());
+ }
+
+ public function testReportsAnUnreadableInput(): void
+ {
+ new Filesystem()->chmod($this->input, 0o000);
+
+ try {
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input]);
+
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('Could not read', $this->tester->getDisplay());
+ } finally {
+ new Filesystem()->chmod($this->input, 0o600);
+ }
+ }
+
+ public function testAnyImporterJoinsTheCommandUnderItsFormat(): void
+ {
+ $importer = new class implements ImporterInterface {
+ public function import(string $contents, array $context = []): array
+ {
+ return ['imported' => ['$type' => 'number', '$value' => \strlen($contents)]];
+ }
+ };
+ $tester = new CommandTester(new ImportCommand(new ServiceLocator(['css' => static fn (): ImporterInterface => $importer]), new TokenTreeBuilder()));
+
+ self::assertSame(Command::SUCCESS, $tester->execute(['format' => 'css', 'input' => $this->input]));
+ self::assertStringContainsString('"imported"', $tester->getDisplay());
+ }
+
+ public function testCompletesFormats(): void
+ {
+ self::assertSame(['tailwind'], new CommandCompletionTester(self::command())->complete(['']));
+ }
+
+ public function testRejectsNonStringArgumentsWhenInvokedProgrammatically(): void
+ {
+ $this->tester->execute(['format' => [], 'input' => $this->input]);
+ self::assertSame(Command::INVALID, $this->tester->getStatusCode());
+ self::assertStringContainsString('format must be a string', $this->tester->getDisplay());
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => []]);
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('input path must be a string', $this->tester->getDisplay());
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => []]);
+ self::assertSame(Command::INVALID, $this->tester->getStatusCode());
+ self::assertStringContainsString('output path must be a string', $this->tester->getDisplay());
+ }
+
+ public function testLogsSkippedVariablesToStderrByVerbosity(): void
+ {
+ $this->directory->write('theme.css', '@theme { --color-brand: #336699; --animate-spin: spin 1s linear infinite; --spacing-fluid: 1vw; }');
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input], ['capture_stderr_separately' => true]);
+
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ self::assertArrayHasKey('color', json_decode($this->tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR));
+ self::assertStringContainsString('--spacing-fluid', $this->tester->getErrorOutput());
+ self::assertStringNotContainsString('--animate-spin', $this->tester->getErrorOutput());
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input], ['capture_stderr_separately' => true, 'verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
+
+ self::assertStringContainsString('--animate-spin', $this->tester->getErrorOutput());
+ }
+
+ public function testDoesNotReplaceAnExistingFileWithoutForce(): void
+ {
+ $output = $this->directory->write('tokens.json', 'kept');
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output]);
+ self::assertSame(Command::FAILURE, $this->tester->getStatusCode());
+ self::assertStringContainsString('--force', $this->tester->getDisplay());
+ self::assertSame('kept', file_get_contents($output));
+
+ $this->tester->execute(['format' => 'tailwind', 'input' => $this->input, 'output' => $output, '--force' => true]);
+ self::assertSame(Command::SUCCESS, $this->tester->getStatusCode());
+ self::assertStringContainsString('"brand"', (string) file_get_contents($output));
+ }
+
+ private static function command(): ImportCommand
+ {
+ return new ImportCommand(new ServiceLocator(['tailwind' => static fn (): ImporterInterface => new ThemeImporter()]), new TokenTreeBuilder());
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php b/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php
new file mode 100644
index 00000000000..24fcf15adb0
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Command/LintDesignTokensCommandTest.php
@@ -0,0 +1,345 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Command;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Tester\CommandTester;
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\UX\DesignTokens\Command\LintDesignTokensCommand;
+use Symfony\UX\DesignTokens\Resolver\JsonDocumentLoader;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\Registries;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory;
+use Symfony\UX\DesignTokens\TokenRegistry;
+use Symfony\UX\DesignTokens\Validation\DtcgValidator;
+use Symfony\UX\DesignTokens\Validation\Normalizer;
+
+#[CoversClass(LintDesignTokensCommand::class)]
+final class LintDesignTokensCommandTest extends TestCase
+{
+ private TemporaryDirectory $directory;
+ private string $valid;
+
+ protected function setUp(): void
+ {
+ $this->directory = new TemporaryDirectory();
+ $this->valid = $this->directory->write('valid.tokens.json', self::tokens());
+ $this->directory->write('nested/invalid.tokens.json', '{"bad":{"$type":"dimension","$value":"12px"}}');
+ $this->directory->write('ignored.json', '{}');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->directory->remove();
+ }
+
+ public function testLintsConfiguredFilesByDefault(): void
+ {
+ $tester = $this->tester([$this->valid]);
+ $tester->execute([]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertStringContainsString('The design token document is valid', $tester->getDisplay());
+ }
+
+ public function testLintsDirectoriesAndReportsJson(): void
+ {
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$this->directory->path()], '--format' => 'json']);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ $result = json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR);
+ self::assertFalse($result['valid']);
+ self::assertCount(2, $result['files']);
+ self::assertStringContainsString('structured dimension', $result['files'][0]['error'] ?? $result['files'][1]['error']);
+ }
+
+ public function testReportsGithubAnnotationsAndEscapesValues(): void
+ {
+ $path = $this->directory->write('bad,file.tokens.json', '{');
+
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$path], '--format' => 'github']);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('::error file=', $tester->getDisplay());
+ self::assertStringContainsString('bad%2Cfile.tokens.json', $tester->getDisplay());
+ self::assertStringContainsString('::Invalid JSON in "', $tester->getDisplay());
+ self::assertStringContainsString('": Syntax error', $tester->getDisplay());
+ }
+
+ public function testSupportsStdinAndReportsReadFailure(): void
+ {
+ $tester = $this->stdinTester(self::tokens());
+ $tester->execute(['filename' => ['-']]);
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+
+ $tester = $this->stdinTester('');
+ $tester->execute(['filename' => ['-']]);
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ self::assertStringContainsString('Could not read', $tester->getDisplay());
+ }
+
+ public function testValidatesTheConfiguredResolver(): void
+ {
+ $tester = $this->tester(resolver: \dirname(__DIR__, 2).'/Integration/Fixtures/theme.resolver.json');
+
+ self::assertSame(Command::SUCCESS, $tester->execute([]));
+ }
+
+ /**
+ * @param array $input a "filename" list names entries of the temporary directory
+ */
+ #[DataProvider('rejectedRunProvider')]
+ public function testRejectsARunItCannotLint(array $input, int $status, string $message): void
+ {
+ $this->directory->mkdir('empty');
+ if (\is_array($input['filename'] ?? null)) {
+ $input['filename'] = array_map($this->directory->path(...), $input['filename']);
+ }
+
+ $tester = $this->tester();
+
+ self::assertSame($status, $tester->execute($input));
+ self::assertStringContainsString($message, $tester->getDisplay());
+ }
+
+ /** @return iterable, int, string}> */
+ public static function rejectedRunProvider(): iterable
+ {
+ yield 'no file given or configured' => [[], Command::INVALID, 'No design token files'];
+ yield 'unknown format' => [['filename' => ['valid.tokens.json'], '--format' => 'xml'], Command::INVALID, '--format'];
+ yield 'filename that is not a list' => [['filename' => 'not-a-list'], Command::INVALID, 'must be a list'];
+ yield 'missing path' => [['filename' => ['missing']], Command::FAILURE, 'path not found'];
+ yield 'empty directory' => [['filename' => ['empty']], Command::FAILURE, 'No .tokens.json'];
+ yield 'file without the .tokens.json suffix' => [['filename' => ['ignored.json']], Command::FAILURE, 'Expected a .tokens.json'];
+ }
+
+ public function testFixRewritesADocumentInItsNormalizedForm(): void
+ {
+ $compact = $this->directory->write('compact.tokens.json', self::tokens());
+
+ $tester = $this->tester();
+ self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$compact], '--fix' => true]));
+
+ $rewritten = (string) file_get_contents($compact);
+ self::assertNotSame(self::tokens(), $rewritten);
+ self::assertStringContainsString("\n", $rewritten);
+ self::assertStringContainsString('Normalized '.$compact, $tester->getDisplay());
+ self::assertSame(json_decode(self::tokens(), true), json_decode($rewritten, true));
+
+ $second = $this->tester();
+ $second->execute(['filename' => [$compact], '--fix' => true]);
+ self::assertStringNotContainsString('Normalized', $second->getDisplay());
+ self::assertSame($rewritten, file_get_contents($compact));
+ }
+
+ public function testWithoutFixANonNormalizedDocumentIsStillValid(): void
+ {
+ $tester = $this->tester();
+
+ self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$this->valid]]));
+ self::assertStringNotContainsString('Normalized', $tester->getDisplay());
+ self::assertSame(self::tokens(), file_get_contents($this->valid));
+ }
+
+ public function testFixOnStdinWritesTheNormalizedDocumentToOutput(): void
+ {
+ $tester = $this->stdinTester(self::tokens());
+
+ self::assertSame(Command::SUCCESS, $tester->execute(['filename' => ['-'], '--fix' => true]));
+ self::assertStringContainsString('"color"', $tester->getDisplay());
+ self::assertStringNotContainsString('Normalized -', $tester->getDisplay());
+ }
+
+ public function testLintingTheConfiguredFilesAlsoChecksTheApplicationResolution(): void
+ {
+ $registry = Registries::fromFiles(paths: [$this->valid]);
+ $tester = $this->tester([$this->valid], registry: $registry);
+
+ self::assertSame(Command::SUCCESS, $tester->execute([]));
+ self::assertStringContainsString('configured resolution holds', $tester->getDisplay());
+
+ $json = $this->tester([$this->valid], registry: Registries::fromFiles(paths: [$this->valid]));
+ $json->execute(['--format' => 'json']);
+ self::assertSame(
+ ['valid' => true, 'tokens' => 1, 'error' => null],
+ json_decode($json->getDisplay(), true)['resolution'],
+ );
+ }
+
+ /** @param array $input */
+ #[DataProvider('brokenResolutionOutputProvider')]
+ public function testAResolutionThatFailsIsReportedEvenWhenEveryDocumentIsValid(array $input, string $expected): void
+ {
+ $tester = $this->danglingTester();
+
+ self::assertSame(Command::FAILURE, $tester->execute($input));
+ self::assertStringContainsString($expected, $tester->getDisplay());
+ }
+
+ /** @return iterable, string}> */
+ public static function brokenResolutionOutputProvider(): iterable
+ {
+ yield 'text' => [[], 'The configured design tokens do not resolve'];
+ yield 'GitHub annotation' => [['--format' => 'github'], '::error::'];
+ }
+
+ /** @param list $configured */
+ private function tester(array $configured = [], ?string $resolver = null, ?TokenRegistry $registry = null): CommandTester
+ {
+ $validator = new DtcgValidator(new TokenTreeBuilder(new JsonDocumentLoader()));
+
+ return new CommandTester(new LintDesignTokensCommand($validator, new Normalizer($validator), $registry, $configured, $resolver));
+ }
+
+ private function stdinTester(string $stdin): CommandTester
+ {
+ $tester = $this->tester();
+ $tester->setInputs([$stdin]);
+
+ return $tester;
+ }
+
+ private function danglingTester(): CommandTester
+ {
+ $dangling = $this->directory->write('dangling.tokens.json', '{"color":{"alias":{"$type":"color","$value":"{color.missing}"}}}');
+
+ return $this->tester([$dangling], registry: Registries::fromFiles(paths: [$dangling]));
+ }
+
+ private static function tokens(): string
+ {
+ return '{"color":{"brand":{"$type":"color","$value":{"colorSpace":"srgb","components":[0.2,0.4,0.8]}}}}';
+ }
+
+ public function testADocumentALintedResolverCompletesIsLintedAndFixed(): void
+ {
+ $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brand}"}}}');
+
+ $tester = $this->tester();
+
+ self::assertSame(Command::SUCCESS, $tester->execute(['filename' => [$theme], '--fix' => true]), $tester->getDisplay());
+ self::assertStringContainsString('Normalized '.$theme.'/foundation.tokens.json', $tester->getDisplay());
+ self::assertStringContainsString("\n", (string) file_get_contents($theme.'/foundation.tokens.json'));
+ }
+
+ public function testTheResolverReportsAReferenceNoSourceDefines(): void
+ {
+ $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brnad}"}}}');
+
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$theme], '--format' => 'json']);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ $files = array_column(json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)['files'], null, 'file');
+ self::assertTrue($files[$theme.'/foundation.tokens.json']['valid']);
+ self::assertStringContainsString('{color.brnad}', (string) $files[$theme.'/theme.resolver.json']['error']);
+ }
+
+ public function testAnInvalidValueALaterSourceOverridesIsStillReported(): void
+ {
+ $theme = $this->writeLayeredTheme('{"color":{"brand":{"$type":"dimension","$value":"12px"}}}');
+
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$theme], '--format' => 'json']);
+
+ $files = array_column(json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR)['files'], null, 'file');
+ self::assertStringContainsString('structured dimension', (string) $files[$theme.'/foundation.tokens.json']['error']);
+ }
+
+ public function testADocumentLintedWithoutItsResolverSaysWhatToLint(): void
+ {
+ $theme = $this->writeLayeredTheme('{"color":{"text":{"$type":"color","$value":"{color.brand}"}}}');
+
+ $tester = $this->tester();
+
+ self::assertSame(Command::FAILURE, $tester->execute(['filename' => [$theme.'/foundation.tokens.json']]));
+ self::assertStringContainsString('lint it with that Resolver', $tester->getDisplay());
+ }
+
+ private function writeLayeredTheme(string $foundation): string
+ {
+ $this->directory->write('theme/foundation.tokens.json', $foundation);
+ $this->directory->write('theme/brand.tokens.json', '{"color":{"brand":{"$type":"color","$value":{"colorSpace":"srgb","components":[0,0,1]}}}}');
+ $this->directory->write('theme/theme.resolver.json', '{"version":"2025.10","sets":{"foundation":{"sources":[{"$ref":"foundation.tokens.json"}]},"brand":{"sources":[{"$ref":"brand.tokens.json"}]}},"resolutionOrder":[{"$ref":"#/sets/foundation"},{"$ref":"#/sets/brand"}]}');
+
+ return $this->directory->path().'/theme';
+ }
+
+ public function testFixReportsADocumentItCannotRewrite(): void
+ {
+ $file = $this->directory->write('locked/theme.tokens.json', self::tokens());
+ new Filesystem()->chmod($locked = \dirname($file), 0o500);
+
+ try {
+ $tester = $this->tester();
+
+ self::assertSame(Command::FAILURE, $tester->execute(['filename' => [$file], '--fix' => true]));
+ self::assertStringContainsString('Could not write the normalized document', $tester->getDisplay());
+ self::assertSame(self::tokens(), file_get_contents($file));
+ } finally {
+ new Filesystem()->chmod($locked, 0o700);
+ }
+ }
+
+ public function testABrokenResolutionIsReportedInJson(): void
+ {
+ $tester = $this->danglingTester();
+
+ self::assertSame(Command::FAILURE, $tester->execute(['--format' => 'json']));
+ $payload = json_decode($tester->getDisplay(), true);
+ self::assertFalse($payload['valid']);
+ self::assertFalse($payload['resolution']['valid']);
+ self::assertNotNull($payload['resolution']['error']);
+ }
+
+ public function testAnUnreadableDocumentIsReported(): void
+ {
+ $unreadable = $this->directory->write('unreadable.tokens.json', self::tokens());
+ new Filesystem()->chmod($unreadable, 0o000);
+
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$unreadable]]);
+
+ self::assertSame(Command::FAILURE, $tester->getStatusCode());
+ }
+
+ public function testFixingStdinWritesOnlyTheDocumentToStdout(): void
+ {
+ $tester = $this->stdinTester(self::tokens());
+ $tester->execute(['filename' => ['-'], '--fix' => true, '--format' => 'json'], ['capture_stderr_separately' => true]);
+
+ self::assertSame(Command::SUCCESS, $tester->getStatusCode());
+ self::assertArrayHasKey('color', json_decode($tester->getDisplay(), true, 512, \JSON_THROW_ON_ERROR));
+ self::assertTrue(json_decode($tester->getErrorOutput(), true, 512, \JSON_THROW_ON_ERROR)['valid']);
+ }
+
+ public function testDefaultsToGithubAnnotationsOnGithubActions(): void
+ {
+ $previous = getenv('GITHUB_ACTIONS');
+ putenv('GITHUB_ACTIONS=true');
+ $broken = $this->directory->write('broken.tokens.json', '{');
+
+ try {
+ $tester = $this->tester();
+ $tester->execute(['filename' => [$broken]]);
+
+ self::assertStringContainsString('::error file=', $tester->getDisplay());
+ } finally {
+ false === $previous ? putenv('GITHUB_ACTIONS') : putenv('GITHUB_ACTIONS='.$previous);
+ }
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php b/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php
new file mode 100644
index 00000000000..5ef75a5be4a
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Conformance/FormatColorConformanceTest.php
@@ -0,0 +1,96 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Conformance;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Token\ColorToken;
+use Symfony\UX\DesignTokens\Token\GradientToken;
+use Symfony\UX\DesignTokens\Validation\ColorRangeInspector;
+use Symfony\UX\DesignTokens\Validation\TokenValueValidator;
+
+#[CoversClass(TokenValueValidator::class)]
+#[CoversClass(GradientToken::class)]
+final class FormatColorConformanceTest extends TestCase
+{
+ #[DataProvider('validValueProvider')]
+ public function testAcceptsEveryPinnedPositiveFormatAndColorCase(string $type, mixed $value): void
+ {
+ new TokenValueValidator()->validate($type, $value, '/fixture');
+ $this->addToAssertionCount(1);
+ }
+
+ private const array OUT_OF_RANGE = [
+ 'color/srgb-below',
+ 'color/srgb-above',
+ 'color/hsl-percent-above',
+ 'color/lab-lightness-above',
+ 'color/oklab-lightness-above',
+ 'color/lch-negative-chroma',
+ ];
+
+ #[DataProvider('invalidValueProvider')]
+ public function testRejectsEveryPinnedNegativeFormatAndColorCase(string $type, mixed $value, string $case): void
+ {
+ if (\in_array($case, self::OUT_OF_RANGE, true)) {
+ new TokenValueValidator()->validate($type, $value, '/fixture');
+ self::assertNotSame([], new ColorRangeInspector()->inspect([
+ 'fixture' => new ColorToken($value),
+ ]), \sprintf('"%s" should be reported as out of range.', $case));
+
+ return;
+ }
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('/fixture');
+
+ new TokenValueValidator()->validate($type, $value, '/fixture');
+ }
+
+ public function testAppliesNormativeGradientClamping(): void
+ {
+ $token = new GradientToken([
+ ['color' => ['colorSpace' => 'srgb', 'components' => [0, 0, 0]], 'position' => -99],
+ ['color' => ['colorSpace' => 'srgb', 'components' => [1, 1, 1]], 'position' => 42],
+ ]);
+
+ self::assertSame(0, $token->getValue()[0]['position']);
+ self::assertSame(1, $token->getValue()[1]['position']);
+ }
+
+ /** @return iterable */
+ public static function validValueProvider(): iterable
+ {
+ yield from self::fixture('format-color-valid-values.json');
+ }
+
+ /** @return iterable */
+ public static function invalidValueProvider(): iterable
+ {
+ foreach (self::fixture('format-color-invalid-values.json') as $case => [$type, $value]) {
+ yield $case => [$type, $value, $case];
+ }
+ }
+
+ /** @return array */
+ private static function fixture(string $filename): array
+ {
+ $contents = file_get_contents(\dirname(__DIR__, 2).'/Fixtures/dtcg/'.$filename);
+ self::assertNotFalse($contents);
+
+ /** @var array $cases */
+ $cases = json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
+
+ return $cases;
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php b/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php
new file mode 100644
index 00000000000..552fe1b3584
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/DependencyInjection/ConfigurationTest.php
@@ -0,0 +1,94 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\DependencyInjection;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
+use Symfony\Component\Config\Definition\Processor;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
+use Symfony\UX\DesignTokens\UXDesignTokensBundle;
+
+#[CoversClass(UXDesignTokensBundle::class)]
+final class ConfigurationTest extends TestCase
+{
+ private function getExtension(): ExtensionInterface
+ {
+ $bundle = new UXDesignTokensBundle();
+ $extension = $bundle->getContainerExtension();
+ self::assertNotNull($extension);
+
+ return $extension;
+ }
+
+ private function processConfig(array $config = []): array
+ {
+ $extension = $this->getExtension();
+ $configuration = $extension->getConfiguration([], new ContainerBuilder());
+ self::assertNotNull($configuration);
+
+ return new Processor()->processConfiguration($configuration, [$config]);
+ }
+
+ public function testDefaultConfigurationHasExpectedKeys(): void
+ {
+ $config = $this->processConfig();
+
+ self::assertSame([], $config['paths']);
+ self::assertSame(['path' => null, 'inputs' => []], $config['resolver']);
+ self::assertSame(['modifier' => 'scheme', 'light' => 'light', 'dark' => 'dark'], $config['color_scheme']);
+ self::assertSame('dt', $config['css_prefix']);
+ }
+
+ public function testConfigurationAcceptsCustomValues(): void
+ {
+ $config = $this->processConfig([
+ 'paths' => ['/tokens/base.json'],
+ 'resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => 'dark']],
+ 'color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'],
+ ]);
+
+ self::assertSame(['/tokens/base.json'], $config['paths']);
+ self::assertSame(['path' => '/theme.resolver.json', 'inputs' => ['theme' => 'dark']], $config['resolver']);
+ self::assertSame(['modifier' => 'mode', 'light' => 'day', 'dark' => 'night'], $config['color_scheme']);
+ }
+
+ public function testResolverInputNamesKeepTheirHyphens(): void
+ {
+ $config = $this->processConfig(['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['color-scheme' => 'dark']]]);
+
+ self::assertSame(['color-scheme' => 'dark'], $config['resolver']['inputs']);
+ }
+
+ /** @param array $config */
+ #[DataProvider('invalidConfigurations')]
+ public function testConfigurationRejectsAnInvalidValue(array $config, ?string $message): void
+ {
+ $this->expectException(InvalidConfigurationException::class);
+ if (null !== $message) {
+ $this->expectExceptionMessage($message);
+ }
+
+ $this->processConfig($config);
+ }
+
+ /** @return iterable, ?string}> */
+ public static function invalidConfigurations(): iterable
+ {
+ yield 'nested resolver input value' => [['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => ['dark']]]], 'Resolver input values must be strings or numbers.'];
+ yield 'null resolver input value' => [['resolver' => ['path' => '/theme.resolver.json', 'inputs' => ['theme' => null]]], 'Resolver input values must be strings or numbers.'];
+ yield 'resolver inputs without a resolver document' => [['resolver' => ['inputs' => ['theme' => 'dark']]], '"resolver.inputs" requires "resolver.path"'];
+ yield 'empty path' => [['paths' => ['']], null];
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php b/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php
new file mode 100644
index 00000000000..e9671107d2c
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/DependencyInjection/DesignTokensExtensionTest.php
@@ -0,0 +1,360 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\DependencyInjection;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
+use Symfony\Bundle\TwigBundle\TwigBundle;
+use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent;
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\Config\Resource\DirectoryResource;
+use Symfony\Component\Config\Resource\FileExistenceResource;
+use Symfony\Component\Config\Resource\FileResource;
+use Symfony\Component\DependencyInjection\ChildDefinition;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
+use Symfony\Component\DependencyInjection\ParameterBag\EnvPlaceholderParameterBag;
+use Symfony\Component\Filesystem\Path;
+use Symfony\UX\DesignTokens\Bridge\Tailwind\ThemeImporter;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Importer\ImporterInterface;
+use Symfony\UX\DesignTokens\Resolver\DocumentLoaderInterface;
+use Symfony\UX\DesignTokens\Resolver\TokenResolverInterface;
+use Symfony\UX\DesignTokens\TokenRegistry;
+use Symfony\UX\DesignTokens\TokenRegistryInterface;
+use Symfony\UX\DesignTokens\UXDesignTokensBundle;
+use Symfony\UX\DesignTokens\Validation\DtcgValidator;
+
+#[CoversClass(UXDesignTokensBundle::class)]
+final class DesignTokensExtensionTest extends TestCase
+{
+ public function testPathsResolveContainerParameters(): void
+ {
+ $container = $this->createContainer();
+ $container->setParameter('app.tokens_dir', '/srv/tokens');
+ $this->load(['paths' => ['%app.tokens_dir%/a.tokens.json', 'relative/b.tokens.json']], $container);
+
+ self::assertSame(['/srv/tokens/a.tokens.json', Path::join(sys_get_temp_dir(), 'relative/b.tokens.json')], $container->getParameter('.ux_design_tokens.paths'));
+ }
+
+ /** @param array $expected */
+ #[DataProvider('parameters')]
+ public function testExposesTheConfigurationAsParameters(array $config, array $expected): void
+ {
+ $container = $this->load($config);
+
+ foreach ($expected as $name => $value) {
+ self::assertSame($value, $container->getParameter($name));
+ }
+ }
+
+ /** @return iterable, array}> */
+ public static function parameters(): iterable
+ {
+ yield 'default paths' => [[], ['.ux_design_tokens.paths' => []]];
+ yield 'default resolver' => [[], ['.ux_design_tokens.resolver_path' => null, '.ux_design_tokens.resolver_inputs' => []]];
+ yield 'custom paths' => [['paths' => ['/tokens/base.json']], ['.ux_design_tokens.paths' => ['/tokens/base.json']]];
+ yield 'custom resolver' => [
+ ['resolver' => ['path' => 'theme.resolver.json', 'inputs' => ['theme' => 'dark']]],
+ ['.ux_design_tokens.resolver_path' => sys_get_temp_dir().'/theme.resolver.json', '.ux_design_tokens.resolver_inputs' => ['theme' => 'dark']],
+ ];
+ yield 'default color scheme' => [[], ['.ux_design_tokens.color_scheme.modifier' => 'scheme', '.ux_design_tokens.color_scheme.light' => 'light', '.ux_design_tokens.color_scheme.dark' => 'dark']];
+ yield 'custom color scheme' => [
+ ['color_scheme' => ['modifier' => 'mode', 'light' => 'day', 'dark' => 'night']],
+ ['.ux_design_tokens.color_scheme.modifier' => 'mode', '.ux_design_tokens.color_scheme.light' => 'day', '.ux_design_tokens.color_scheme.dark' => 'night'],
+ ];
+ yield 'paths built from an environment variable' => [
+ ['paths' => ['%env(TOKENS_FILE)%'], 'resolver' => ['path' => '%env(resolve:RESOLVER_FILE)%']],
+ ['.ux_design_tokens.paths' => ['%env(TOKENS_FILE)%'], '.ux_design_tokens.resolver_path' => '%env(resolve:RESOLVER_FILE)%'],
+ ];
+ }
+
+ public function testDeclaresItsOwnCachePoolWithFrameworkBundle(): void
+ {
+ $container = $this->createContainer();
+ $container->setParameter('kernel.bundles', ['FrameworkBundle' => FrameworkBundle::class]);
+ $this->load([], $container);
+
+ $pool = $container->getDefinition('.ux_design_tokens.cache');
+ self::assertInstanceOf(ChildDefinition::class, $pool);
+ self::assertSame('cache.system', $pool->getParent());
+ self::assertTrue($pool->hasTag('cache.pool'));
+ }
+
+ public function testResolvesWithoutACachePoolWithoutFrameworkBundle(): void
+ {
+ $container = $this->load();
+
+ self::assertFalse($container->has('.ux_design_tokens.cache'));
+ self::assertSame(ContainerInterface::NULL_ON_INVALID_REFERENCE, $container->getDefinition('.ux_design_tokens.resolver.configured')->getArgument(3)->getInvalidBehavior());
+ }
+
+ public function testTheTwigHelpersNeedTwigBundle(): void
+ {
+ $container = $this->createContainer();
+ $container->setParameter('kernel.bundles', []);
+ $this->load([], $container);
+
+ self::assertFalse($container->has('.ux_design_tokens.twig_runtime'));
+ }
+
+ public function testExposesTheConsumableServicesUnderTheirClassName(): void
+ {
+ $container = $this->load();
+
+ foreach ([TokenRegistryInterface::class, TokenResolverInterface::class, DocumentLoaderInterface::class] as $id) {
+ self::assertTrue($container->hasAlias($id), \sprintf('"%s" should be aliased.', $id));
+ }
+ foreach ([TokenRegistry::class, DtcgValidator::class, ThemeImporter::class] as $id) {
+ self::assertFalse($container->hasAlias($id), \sprintf('"%s" is internal and should not be aliased.', $id));
+ }
+ }
+
+ /** @param list> $attributes */
+ #[DataProvider('builtInTaggedServiceProvider')]
+ public function testRegistersABuiltInTaggedService(string $id, string $tag, array $attributes): void
+ {
+ $container = $this->load();
+
+ self::assertTrue($container->has($id));
+ self::assertSame($attributes, $container->getDefinition($id)->getTag($tag));
+ }
+
+ /** @return iterable>}> */
+ public static function builtInTaggedServiceProvider(): iterable
+ {
+ yield 'Twig extension' => ['.ux_design_tokens.twig_extension', 'twig.extension', [[]]];
+ yield 'Twig runtime' => ['.ux_design_tokens.twig_runtime', 'twig.runtime', [[]]];
+ yield 'AssetMapper compile listener' => ['.ux_design_tokens.asset_compile_listener', 'kernel.event_listener', [['event' => PreAssetsCompileEvent::class, 'method' => '__invoke']]];
+ yield 'Tailwind importer' => ['.ux_design_tokens.importer.tailwind', 'ux_design_tokens.importer', [['format' => 'tailwind']]];
+ }
+
+ /** @param class-string $class */
+ #[DataProvider('autoconfiguredImplementationProvider')]
+ public function testTagsAnyImplementationOfAnExtensionPoint(string $class, string $tag): void
+ {
+ $container = $this->load();
+
+ $container->register('app.extension_point', $class)
+ ->setAutoconfigured(true)
+ ->setPublic(true);
+ new UXDesignTokensBundle()->build($container);
+ $container->compile();
+
+ self::assertTrue($container->getDefinition('app.extension_point')->hasTag($tag));
+ }
+
+ /** @return iterable */
+ public static function autoconfiguredImplementationProvider(): iterable
+ {
+ yield 'generator for the export command' => [ScssGeneratorStub::class, 'ux_design_tokens.generator'];
+ yield 'importer for the import command' => [CssImporterStub::class, 'ux_design_tokens.importer'];
+ }
+
+ public function testKeepsInternalServicesOutOfReach(): void
+ {
+ $container = $this->load();
+
+ foreach ($container->getDefinitions() as $id => $definition) {
+ if (str_contains($id, 'DesignTokens')) {
+ self::assertStringStartsWith('.ux_design_tokens.', $id);
+ }
+ }
+ }
+
+ public function testRegistersOnlyConfiguredFilesAsContainerResources(): void
+ {
+ $tokenPath = \dirname(__DIR__, 2).'/Fixtures/dtcg/format-valid.tokens.json';
+ $resolverPath = \dirname(__DIR__, 2).'/Fixtures/dtcg/resolver-valid.resolver.json';
+
+ $container = $this->load(['paths' => [$tokenPath], 'resolver' => ['path' => $resolverPath]]);
+
+ $fileResources = array_map(
+ static fn (FileResource $resource): string => $resource->getResource(),
+ self::resources($container, FileResource::class),
+ );
+ self::assertContains($tokenPath, $fileResources);
+ self::assertContains($resolverPath, $fileResources);
+
+ self::assertSame([], self::resources($container, DirectoryResource::class));
+ }
+
+ public function testFingerprintsTheConfigurationTheStylesheetsDependOn(): void
+ {
+ $fingerprint = fn (array $config): string => $this->load($config)->getDefinition('.ux_design_tokens.stylesheet_cache')->getArgument(6);
+
+ $default = $fingerprint(['paths' => ['/tokens/a.tokens.json']]);
+
+ self::assertSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json']]));
+ self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json'], 'css_prefix' => 'app']));
+ self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json'], 'color_scheme' => ['modifier' => 'mode']]));
+ self::assertNotSame($default, $fingerprint(['paths' => ['/tokens/a.tokens.json', '/tokens/b.tokens.json']]));
+ }
+
+ public function testMissingConfiguredFileIsTrackedForCacheInvalidation(): void
+ {
+ $path = sys_get_temp_dir().'/missing-design-tokens-'.bin2hex(random_bytes(6)).'.tokens.json';
+ $container = $this->load(['paths' => [$path]]);
+
+ $resources = array_values(array_filter(
+ self::resources($container, FileExistenceResource::class),
+ static fn (FileExistenceResource $resource): bool => $resource->getResource() === $path,
+ ));
+ self::assertCount(1, $resources);
+ self::assertTrue($resources[0]->isFresh(time()));
+ }
+
+ public function testPathsBuiltFromAnEnvironmentVariableAreNotTrackedAsContainerResources(): void
+ {
+ $container = $this->load(['paths' => ['%env(TOKENS_FILE)%']]);
+
+ foreach ($container->getResources() as $resource) {
+ self::assertStringNotContainsString('env(', (string) $resource);
+ }
+ }
+
+ public function testAPathCarryingAResolvedEnvPlaceholderIsPassedThroughUntouched(): void
+ {
+ $bag = new EnvPlaceholderParameterBag();
+ $placeholder = $bag->get('env(TOKENS_FILE)');
+ self::assertIsString($placeholder);
+
+ $container = new ContainerBuilder($bag);
+ $container->setParameter('kernel.project_dir', sys_get_temp_dir());
+ $container->setParameter('kernel.environment', 'test');
+ $container->setParameter('kernel.build_dir', sys_get_temp_dir());
+ $container->setParameter('kernel.debug', true);
+
+ $this->load(['paths' => [$placeholder]], $container);
+
+ self::assertSame([$placeholder], $container->getParameter('.ux_design_tokens.paths'));
+ }
+
+ public function testNothingOutsideConfigTwigTouchesTwig(): void
+ {
+ $root = \dirname(__DIR__, 3);
+ $offenders = [];
+ foreach (['config/services.php', 'config/asset_mapper.php'] as $file) {
+ if (str_contains((string) file_get_contents($root.'/'.$file), 'Twig')) {
+ $offenders[] = $file;
+ }
+ }
+
+ foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root.'/src')) as $php) {
+ if (!$php->isFile() || 'php' !== $php->getExtension()) {
+ continue;
+ }
+ $path = str_replace($root.'/', '', $php->getPathname());
+ if (str_starts_with($path, 'src/Twig/')) {
+ continue;
+ }
+ if (preg_match('/^use Twig\\\\/m', (string) file_get_contents($php->getPathname()))) {
+ $offenders[] = $path;
+ }
+ }
+
+ self::assertSame([], $offenders);
+ }
+
+ public function testTheRenderedStylesheetsAreMappedForAssetMapper(): void
+ {
+ $container = $this->createContainer();
+ $bundle = new UXDesignTokensBundle();
+
+ $instanceof = [];
+ $bundle->prependExtension(new ContainerConfigurator($container, new PhpFileLoader($container, new FileLocator()), $instanceof, __FILE__, __FILE__), $container);
+
+ $directory = sys_get_temp_dir().'/'.StylesheetCache::DIRECTORY;
+ self::assertSame(
+ [$directory => UXDesignTokensBundle::ASSET_NAMESPACE],
+ $container->getExtensionConfig('framework')[0]['asset_mapper']['paths'],
+ );
+ self::assertDirectoryExists($directory);
+ }
+
+ public function testAllowedRootsCoverTheProjectAndEveryConfiguredSourceDirectory(): void
+ {
+ $container = $this->load(['paths' => [\dirname(__DIR__, 2).'/Fixtures/base.tokens.json']]);
+
+ $roots = $container->getParameter('.ux_design_tokens.allowed_roots');
+ self::assertIsArray($roots);
+ self::assertContains(realpath(\dirname(__DIR__, 2).'/Fixtures'), $roots);
+ self::assertContains(realpath(sys_get_temp_dir()), $roots);
+ }
+
+ public function testTheResolverIsNotSharedBetweenItsConsumers(): void
+ {
+ $container = $this->load();
+
+ self::assertFalse($container->getDefinition('.ux_design_tokens.token_tree_builder')->isShared());
+ }
+
+ /** @param array $config */
+ private function load(array $config = [], ?ContainerBuilder $container = null): ContainerBuilder
+ {
+ $container ??= $this->createContainer();
+ $extension = new UXDesignTokensBundle()->getContainerExtension();
+ self::assertNotNull($extension);
+ $extension->load([] === $config ? [] : [$config], $container);
+
+ return $container;
+ }
+
+ private function createContainer(): ContainerBuilder
+ {
+ $container = new ContainerBuilder();
+ $container->setParameter('kernel.project_dir', sys_get_temp_dir());
+ $container->setParameter('kernel.environment', 'test');
+ $container->setParameter('kernel.build_dir', sys_get_temp_dir());
+ $container->setParameter('kernel.debug', true);
+ $container->setParameter('kernel.bundles', ['TwigBundle' => TwigBundle::class]);
+ $container->setParameter('kernel.bundles_metadata', [
+ 'FrameworkBundle' => ['path' => \dirname(__DIR__, 3).'/vendor/symfony/framework-bundle'],
+ 'AssetMapperBundle' => ['path' => \dirname(__DIR__, 3).'/vendor/symfony/asset-mapper'],
+ ]);
+
+ return $container;
+ }
+
+ /**
+ * @template T of object
+ *
+ * @param class-string $class
+ *
+ * @return list
+ */
+ private static function resources(ContainerBuilder $container, string $class): array
+ {
+ return array_values(array_filter($container->getResources(), static fn (object $resource): bool => $resource instanceof $class));
+ }
+}
+
+final class ScssGeneratorStub implements GeneratorInterface
+{
+ public function generate(array $resolvedTokens, array $context = []): string
+ {
+ return '';
+ }
+}
+
+final class CssImporterStub implements ImporterInterface
+{
+ public function import(string $contents, array $context = []): array
+ {
+ return [];
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php b/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php
new file mode 100644
index 00000000000..9329bc220b3
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/EventListener/WarmDesignTokensOnAssetCompileListenerTest.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\EventListener;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent;
+use Symfony\Component\Console\Output\BufferedOutput;
+use Symfony\UX\DesignTokens\CacheWarmer\DesignTokensCacheWarmer;
+use Symfony\UX\DesignTokens\CacheWarmer\StylesheetCache;
+use Symfony\UX\DesignTokens\EventListener\WarmDesignTokensOnAssetCompileListener;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Resolver\ConfiguredTokenResolver;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TemporaryDirectory;
+
+#[CoversClass(WarmDesignTokensOnAssetCompileListener::class)]
+final class WarmDesignTokensOnAssetCompileListenerTest extends TestCase
+{
+ private TemporaryDirectory $directory;
+ private string $buildDir;
+
+ protected function setUp(): void
+ {
+ $this->directory = new TemporaryDirectory();
+ $this->buildDir = $this->directory->path();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->directory->remove();
+ }
+
+ public function testRendersTheStylesheetBeforeTheAssetsAreCompiled(): void
+ {
+ $warmer = new DesignTokensCacheWarmer(new StylesheetCache(
+ new ConfiguredTokenResolver(resolverPath: \dirname(__DIR__, 2).'/Fixtures/color-scheme/theme.resolver.json'),
+ new CssGenerator('dt'),
+ $this->buildDir,
+ ));
+ $output = new BufferedOutput();
+
+ new WarmDesignTokensOnAssetCompileListener($warmer, $this->buildDir)(new PreAssetsCompileEvent($output));
+
+ self::assertFileExists(StylesheetCache::directory($this->buildDir).'/tokens.css');
+ self::assertStringContainsString('Rendering the design token stylesheet...', $output->fetch());
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php b/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php
new file mode 100644
index 00000000000..f80a820c992
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Exception/ExceptionTest.php
@@ -0,0 +1,99 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Exception;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Exception\ExceptionInterface;
+use Symfony\UX\DesignTokens\Exception\InvalidArgumentException;
+use Symfony\UX\DesignTokens\Exception\LogicException;
+use Symfony\UX\DesignTokens\Exception\ResolverException;
+use Symfony\UX\DesignTokens\Exception\RuntimeException;
+use Symfony\UX\DesignTokens\Exception\TokenNotFoundException;
+use Symfony\UX\DesignTokens\Exception\UnexpectedValueException;
+use Symfony\UX\DesignTokens\Exception\UnresolvedReferenceException;
+
+#[CoversClass(InvalidArgumentException::class)]
+#[CoversClass(LogicException::class)]
+#[CoversClass(ResolverException::class)]
+#[CoversClass(RuntimeException::class)]
+#[CoversClass(TokenNotFoundException::class)]
+#[CoversClass(UnexpectedValueException::class)]
+#[CoversClass(UnresolvedReferenceException::class)]
+final class ExceptionTest extends TestCase
+{
+ /** @return iterable, class-string<\Throwable>}> */
+ public static function exceptions(): iterable
+ {
+ yield 'invalid argument' => [InvalidArgumentException::class, \InvalidArgumentException::class];
+ yield 'logic' => [LogicException::class, \LogicException::class];
+ yield 'runtime' => [RuntimeException::class, \RuntimeException::class];
+ yield 'unexpected value' => [UnexpectedValueException::class, \UnexpectedValueException::class];
+ yield 'resolver' => [ResolverException::class, \InvalidArgumentException::class];
+ yield 'token not found' => [TokenNotFoundException::class, \InvalidArgumentException::class];
+ yield 'unresolved reference' => [UnresolvedReferenceException::class, \RuntimeException::class];
+ }
+
+ /**
+ * @param class-string<\Throwable> $class
+ * @param class-string<\Throwable> $spl
+ */
+ #[DataProvider('exceptions')]
+ public function testEveryExceptionIsCatchableThroughTheMarkerInterface(string $class, string $spl): void
+ {
+ self::assertTrue(is_a($class, ExceptionInterface::class, true), \sprintf('"%s" should implement the package marker interface.', $class));
+ self::assertTrue(is_a($class, $spl, true), \sprintf('"%s" should stay a "%s".', $class, $spl));
+ }
+
+ public function testEveryExceptionInTheNamespaceIsCovered(): void
+ {
+ $declared = array_map(
+ static fn (string $file): string => basename($file, '.php'),
+ glob(\dirname(__DIR__, 3).'/src/Exception/*.php') ?: [],
+ );
+ $tested = array_map(
+ static fn (array $case): string => new \ReflectionClass($case[0])->getShortName(),
+ iterator_to_array(self::exceptions(), false),
+ );
+
+ sort($declared);
+ $tested[] = 'ExceptionInterface';
+ sort($tested);
+
+ self::assertSame($declared, $tested, 'A new exception class must be added to the data provider.');
+ }
+
+ public function testTokenNotFoundExceptionCarriesThePathThatDidNotResolve(): void
+ {
+ $exception = new TokenNotFoundException('color.action.primary');
+
+ self::assertSame('color.action.primary', $exception->getPath());
+ self::assertSame('Design token not found: "color.action.primary".', $exception->getMessage());
+ }
+
+ public function testTokenNotFoundExceptionAcceptsAMoreSpecificMessage(): void
+ {
+ $exception = new TokenNotFoundException('color', '"color" is a token group, not a token.');
+
+ self::assertSame('color', $exception->getPath());
+ self::assertSame('"color" is a token group, not a token.', $exception->getMessage());
+ }
+
+ public function testResolverExceptionKeepsEveryErrorItWasGiven(): void
+ {
+ $exception = new ResolverException(['first problem', 'second problem']);
+
+ self::assertSame(['first problem', 'second problem'], $exception->getErrors());
+ self::assertSame("first problem\nsecond problem", $exception->getMessage());
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php b/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php
new file mode 100644
index 00000000000..eefb96fbac2
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Generator/ColorSchemeTest.php
@@ -0,0 +1,54 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Generator;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Generator\ColorScheme;
+
+#[CoversClass(ColorScheme::class)]
+final class ColorSchemeTest extends TestCase
+{
+ /**
+ * @param array, default: string|null}> $modifiers
+ * @param array $inputs
+ * @param array{array, array}|null $expected
+ */
+ #[DataProvider('pairs')]
+ public function testPairsTheLightAndDarkContexts(array $modifiers, array $inputs, ?array $expected): void
+ {
+ self::assertSame($expected, new ColorScheme()->contexts($modifiers, $inputs));
+ }
+
+ /** @return iterable, default: string|null}>, array, array{array, array}|null}> */
+ public static function pairs(): iterable
+ {
+ $scheme = ['scheme' => ['contexts' => ['light', 'dark'], 'default' => 'light']];
+
+ yield 'both contexts declared' => [$scheme, [], [['scheme' => 'light'], ['scheme' => 'dark']]];
+ yield 'other inputs kept' => [$scheme + ['brand' => ['contexts' => ['a', 'b'], 'default' => 'a']], ['brand' => 'b'], [['brand' => 'b', 'scheme' => 'light'], ['brand' => 'b', 'scheme' => 'dark']]];
+ yield 'names compared without case' => [['Scheme' => ['contexts' => ['Light', 'DARK'], 'default' => null]], [], [['scheme' => 'light'], ['scheme' => 'dark']]];
+ yield 'no Resolver document' => [[], [], null];
+ yield 'no such modifier' => [['brand' => ['contexts' => ['a', 'b'], 'default' => 'a']], [], null];
+ yield 'a context missing' => [['scheme' => ['contexts' => ['light', 'dim'], 'default' => 'light']], [], null];
+ yield 'the scheme already selected' => [$scheme, ['Scheme' => 'dark'], null];
+ }
+
+ public function testUsesTheConfiguredNames(): void
+ {
+ self::assertSame(
+ [['mode' => 'day'], ['mode' => 'night']],
+ new ColorScheme('mode', 'day', 'night')->contexts(['mode' => ['contexts' => ['day', 'night'], 'default' => 'day']], []),
+ );
+ }
+}
diff --git a/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php b/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php
new file mode 100644
index 00000000000..721fad58f7b
--- /dev/null
+++ b/src/DesignTokens/tests/Unit/Generator/CssGeneratorTest.php
@@ -0,0 +1,188 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\DesignTokens\Tests\Unit\Generator;
+
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\DesignTokens\Generator\CssGenerator;
+use Symfony\UX\DesignTokens\Generator\GeneratorInterface;
+use Symfony\UX\DesignTokens\Resolver\TokenTreeBuilder;
+use Symfony\UX\DesignTokens\Tests\Fixtures\TokenValues;
+
+#[CoversClass(CssGenerator::class)]
+final class CssGeneratorTest extends TestCase
+{
+ /**
+ * @param array $document
+ * @param list $contains
+ * @param list $leavesOut
+ */
+ #[DataProvider('generatedCssProvider')]
+ public function testGeneratesTheStylesheetOfADocument(array $document, array $contains, array $leavesOut = []): void
+ {
+ $css = new CssGenerator()->generate(new TokenTreeBuilder()->resolve($document));
+
+ foreach ($contains as $fragment) {
+ self::assertStringContainsString($fragment, $css);
+ }
+ foreach ($leavesOut as $fragment) {
+ self::assertStringNotContainsString($fragment, $css);
+ }
+ }
+
+ /** @return iterable, 1: list, 2?: list}> */
+ public static function generatedCssProvider(): iterable
+ {
+ $brand = ['$type' => 'color', '$value' => TokenValues::color()];
+
+ yield 'an empty tree still has a :root block' => [[], [':root {']];
+ yield 'one token in the :root block' => [
+ ['ratio' => ['$type' => 'number', '$value' => 1.5]],
+ [':root {', '--ratio: 1.5;'],
+ ];
+ yield 'several tokens' => [
+ ['a' => ['$type' => 'number', '$value' => 1], 'b' => ['$type' => 'number', '$value' => 2]],
+ ['--a: 1;', '--b: 2;'],
+ ];
+ yield '@property for typed tokens' => [
+ [
+ 'color' => $brand,
+ 'size' => ['$type' => 'dimension', '$value' => TokenValues::dimension(16)],
+ 'duration' => ['$type' => 'duration', '$value' => TokenValues::dimension(300, 'ms')],
+ 'ratio' => ['$type' => 'number', '$value' => 1.5],
+ ],
+ ["syntax: ''", "syntax: ''", "syntax: '