diff --git a/.changeset/petite-bats-lick.md b/.changeset/petite-bats-lick.md new file mode 100644 index 00000000..41ec9456 --- /dev/null +++ b/.changeset/petite-bats-lick.md @@ -0,0 +1,6 @@ +--- +"@mincho-js/css": minor +--- + +**theme** +- Add `theme()` base usage and reference variables diff --git a/configs/tsconfig-custom/tsconfig.base.json b/configs/tsconfig-custom/tsconfig.base.json index 0cadf0af..3bf129e4 100644 --- a/configs/tsconfig-custom/tsconfig.base.json +++ b/configs/tsconfig-custom/tsconfig.base.json @@ -100,7 +100,7 @@ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ diff --git a/packages/css/src/css/index.ts b/packages/css/src/css/index.ts index d5a6fdd6..653cc873 100644 --- a/packages/css/src/css/index.ts +++ b/packages/css/src/css/index.ts @@ -13,7 +13,7 @@ import type { import { setFileScope } from "@vanilla-extract/css/fileScope"; import { style as vStyle, globalStyle as gStyle } from "@vanilla-extract/css"; import type { GlobalStyleRule } from "@vanilla-extract/css"; -import { className, getDebugName } from "../utils.js"; +import { identifierName, getDebugName } from "../utils.js"; import type { CSSRuleWith } from "./types.js"; // == Global CSS =============================================================== @@ -600,7 +600,7 @@ if (import.meta.vitest) { const result = css({ color: "red" }, debugId); assert.isString(result); - expect(result).toMatch(className(debugId)); + expect(result).toMatch(identifierName(debugId)); }); it("composition", () => { @@ -608,7 +608,7 @@ if (import.meta.vitest) { const result = css([base, { color: "red" }], debugId); assert.isString(result); - expect(result).toMatch(className(debugId, "base")); + expect(result).toMatch(identifierName(debugId, "base")); }); }); @@ -640,8 +640,8 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary", "secondary"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); - expect(result.secondary).toMatch(className(`${debugId}_secondary`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); + expect(result.secondary).toMatch(identifierName(`${debugId}_secondary`)); }); it("Mapping Variants", () => { @@ -657,8 +657,8 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary", "secondary"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); - expect(result.secondary).toMatch(className(`${debugId}_secondary`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); + expect(result.secondary).toMatch(identifierName(`${debugId}_secondary`)); }); it("Mapping Variants with composition", () => { @@ -678,9 +678,11 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary", "secondary"]); - expect(result.primary).toMatch(className(`${debugId}_primary`, "base")); + expect(result.primary).toMatch( + identifierName(`${debugId}_primary`, "base") + ); expect(result.secondary).toMatch( - className(`${debugId}_secondary`, "base") + identifierName(`${debugId}_secondary`, "base") ); }); @@ -701,7 +703,7 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); }); it("Complex composition with multiple base styles", () => { @@ -717,10 +719,10 @@ if (import.meta.vitest) { assert.hasAllKeys(result, ["primary", "secondary"]); expect(result.primary).toMatch( - className(`${debugId}_primary`, "base1", "base2") + identifierName(`${debugId}_primary`, "base1", "base2") ); expect(result.secondary).toMatch( - className(`${debugId}_secondary`, "base1") + identifierName(`${debugId}_secondary`, "base1") ); }); @@ -760,9 +762,9 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary", "secondary", "accent"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); - expect(result.secondary).toMatch(className(`${debugId}_secondary`)); - expect(result.accent).toMatch(className(`${debugId}_accent`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); + expect(result.secondary).toMatch(identifierName(`${debugId}_secondary`)); + expect(result.accent).toMatch(identifierName(`${debugId}_accent`)); }); it("Mapping with complex transformation", async () => { @@ -828,9 +830,9 @@ if (import.meta.vitest) { // Test existing class name generation assert.hasAllKeys(result, ["primary", "secondary", "accent"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); - expect(result.secondary).toMatch(className(`${debugId}_secondary`)); - expect(result.accent).toMatch(className(`${debugId}_accent`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); + expect(result.secondary).toMatch(identifierName(`${debugId}_secondary`)); + expect(result.accent).toMatch(identifierName(`${debugId}_accent`)); // Verify that each result is a valid CSS class name string expect(typeof result.primary).toBe("string"); @@ -894,7 +896,7 @@ if (import.meta.vitest) { const result = withRedBackground({ color: "blue" }, debugId); assert.isString(result); - expect(result).toMatch(className(debugId)); + expect(result).toMatch(identifierName(debugId)); }); it("css.with().raw()", () => { @@ -926,8 +928,8 @@ if (import.meta.vitest) { ); assert.hasAllKeys(result, ["primary", "secondary"]); - expect(result.primary).toMatch(className(`${debugId}_primary`)); - expect(result.secondary).toMatch(className(`${debugId}_secondary`)); + expect(result.primary).toMatch(identifierName(`${debugId}_primary`)); + expect(result.secondary).toMatch(identifierName(`${debugId}_secondary`)); }); it("css.with() with like mixin", () => { diff --git a/packages/css/src/rules/index.ts b/packages/css/src/rules/index.ts index fc141273..13ebed11 100644 --- a/packages/css/src/rules/index.ts +++ b/packages/css/src/rules/index.ts @@ -10,7 +10,7 @@ import type { import { css } from "../css/index.js"; import type { CSSRuleWith } from "../css/types.js"; -import { className, getDebugName, getVarName } from "../utils.js"; +import { identifierName, getDebugName, getVarName } from "../utils.js"; import { createRuntimeFn } from "./createRuntimeFn.js"; import type { ComplexPropDefinitions, @@ -500,8 +500,8 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); + expect(result.classNames.base).toMatch(identifierName(debugId)); expect(result.classNames.variants).toEqual({}); expect(result.variants()).toEqual([]); }); @@ -514,8 +514,8 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); + expect(result.classNames.base).toMatch(identifierName(debugId)); expect(result.classNames.variants).toEqual({}); expect(result.variants()).toEqual([]); }); @@ -546,8 +546,8 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); + expect(result.classNames.base).toMatch(identifierName(debugId)); assert.hasAllKeys(result.classNames.variants, [ "color", "size", @@ -558,10 +558,10 @@ if (import.meta.vitest) { // Each variant className check assert.hasAllKeys(result.classNames.variants.color, ["brand", "accent"]); expect(result.classNames.variants.color.brand).toMatch( - className(`${debugId}_color_brand`) + identifierName(`${debugId}_color_brand`) ); expect(result.classNames.variants.color.accent).toMatch( - className(`${debugId}_color_accent`) + identifierName(`${debugId}_color_accent`) ); assert.hasAllKeys(result.classNames.variants.size, [ @@ -570,74 +570,90 @@ if (import.meta.vitest) { "large" ]); expect(result.classNames.variants.size.small).toMatch( - className(`${debugId}_size_small`) + identifierName(`${debugId}_size_small`) ); expect(result.classNames.variants.size.medium).toMatch( - className(`${debugId}_size_medium`) + identifierName(`${debugId}_size_medium`) ); expect(result.classNames.variants.size.large).toMatch( - className(`${debugId}_size_large`) + identifierName(`${debugId}_size_large`) ); assert.hasAllKeys(result.classNames.variants.outlined, ["true", "false"]); expect(result.classNames.variants.outlined.true).toMatch( - className(`${debugId}_outlined_true`) + identifierName(`${debugId}_outlined_true`) ); expect(result.classNames.variants.outlined.false).toMatch( - className(`${debugId}_outlined_false`) + identifierName(`${debugId}_outlined_false`) ); // Compose variant className check - expect(result()).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); expect(result({ color: "brand" })).toMatch( - className(debugId, `${debugId}_color_brand`) + identifierName(debugId, `${debugId}_color_brand`) ); expect(result({ size: "small" })).toMatch( - className(debugId, `${debugId}_size_small`) + identifierName(debugId, `${debugId}_size_small`) ); expect(result({ outlined: true })).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); expect(result(["outlined"])).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); expect(result({ color: "brand", size: "small" })).toMatch( - className(debugId, `${debugId}_color_brand`, `${debugId}_size_small`) + identifierName( + debugId, + `${debugId}_color_brand`, + `${debugId}_size_small` + ) ); expect(result({ size: "small", color: "brand" })).toMatch( - className(debugId, `${debugId}_size_small`, `${debugId}_color_brand`) + identifierName( + debugId, + `${debugId}_size_small`, + `${debugId}_color_brand` + ) ); expect(result(["outlined", { color: "brand" }])).toMatch( - className(debugId, `${debugId}_outlined_true`, `${debugId}_color_brand`) + identifierName( + debugId, + `${debugId}_outlined_true`, + `${debugId}_color_brand` + ) ); expect(result([{ color: "brand" }, "outlined"])).toMatch( - className(debugId, `${debugId}_color_brand`, `${debugId}_outlined_true`) + identifierName( + debugId, + `${debugId}_color_brand`, + `${debugId}_outlined_true` + ) ); expect(result([{ color: "brand" }, { color: "accent" }])).toMatch( - className(debugId, `${debugId}_color_accent`) + identifierName(debugId, `${debugId}_color_accent`) ); expect(result(["outlined", { outlined: false }])).toMatch( - className(debugId, `${debugId}_outlined_false`) + identifierName(debugId, `${debugId}_outlined_false`) ); expect(result(["outlined", { outlined: false }, "outlined"])).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); // Without debugId const resultWithoutDebugId = rules(variants); expect(resultWithoutDebugId({ color: "brand" })).toMatch( - className(undefined, `color_brand`) + identifierName(undefined, `color_brand`) ); expect(resultWithoutDebugId({ size: "small" })).toMatch( - className(undefined, `size_small`) + identifierName(undefined, `size_small`) ); expect(resultWithoutDebugId({ outlined: true })).toMatch( - className(undefined, `outlined_true`) + identifierName(undefined, `outlined_true`) ); expect(resultWithoutDebugId(["outlined"])).toMatch( - className(undefined, `outlined_true`) + identifierName(undefined, `outlined_true`) ); }); @@ -658,46 +674,46 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); + expect(result.classNames.base).toMatch(identifierName(debugId)); assert.hasAllKeys(result.classNames.variants, ["disabled", "rounded"]); expect(result.variants()).toEqual(["disabled", "rounded"]); // Each variant className check assert.hasAllKeys(result.classNames.variants.disabled, ["true"]); expect(result.classNames.variants.disabled.true).toMatch( - className(`${debugId}_disabled_true`) + identifierName(`${debugId}_disabled_true`) ); assert.hasAllKeys(result.classNames.variants.rounded, ["true"]); expect(result.classNames.variants.rounded.true).toMatch( - className(`${debugId}_rounded_true`) + identifierName(`${debugId}_rounded_true`) ); // Compose variant className check - expect(result()).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); expect(result({ disabled: true })).toMatch( - className(debugId, `${debugId}_disabled_true`) + identifierName(debugId, `${debugId}_disabled_true`) ); expect(result(["disabled"])).toMatch( - className(debugId, `${debugId}_disabled_true`) + identifierName(debugId, `${debugId}_disabled_true`) ); expect(result({ rounded: true })).toMatch( - className(debugId, `${debugId}_rounded_true`) + identifierName(debugId, `${debugId}_rounded_true`) ); expect(result(["rounded"])).toMatch( - className(debugId, `${debugId}_rounded_true`) + identifierName(debugId, `${debugId}_rounded_true`) ); expect(result(["disabled", "rounded"])).toMatch( - className( + identifierName( debugId, `${debugId}_disabled_true`, `${debugId}_rounded_true` ) ); expect(result(["rounded", "disabled"])).toMatch( - className( + identifierName( debugId, `${debugId}_rounded_true`, `${debugId}_disabled_true` @@ -723,39 +739,43 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId, `${debugId}_disabled_true`)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch( + identifierName(debugId, `${debugId}_disabled_true`) + ); + expect(result.classNames.base).toMatch(identifierName(debugId)); assert.hasAllKeys(result.classNames.variants, ["disabled", "rounded"]); expect(result.variants()).toEqual(["disabled", "rounded"]); // Each variant className check assert.hasAllKeys(result.classNames.variants.disabled, ["true"]); expect(result.classNames.variants.disabled.true).toMatch( - className(`${debugId}_disabled_true`) + identifierName(`${debugId}_disabled_true`) ); assert.hasAllKeys(result.classNames.variants.rounded, ["true"]); expect(result.classNames.variants.rounded.true).toMatch( - className(`${debugId}_rounded_true`) + identifierName(`${debugId}_rounded_true`) ); // Compose variant className check - expect(result()).toMatch(className(debugId, `${debugId}_disabled_true`)); + expect(result()).toMatch( + identifierName(debugId, `${debugId}_disabled_true`) + ); expect(result({ disabled: true })).toMatch( - className(debugId, `${debugId}_disabled_true`) + identifierName(debugId, `${debugId}_disabled_true`) ); expect(result(["disabled"])).toMatch( - className(debugId, `${debugId}_disabled_true`) + identifierName(debugId, `${debugId}_disabled_true`) ); expect(result({ rounded: true })).toMatch( - className( + identifierName( debugId, `${debugId}_disabled_true`, `${debugId}_rounded_true` ) ); expect(result(["rounded"])).toMatch( - className( + identifierName( debugId, `${debugId}_disabled_true`, `${debugId}_rounded_true` @@ -763,14 +783,14 @@ if (import.meta.vitest) { ); expect(result(["disabled", "rounded"])).toMatch( - className( + identifierName( debugId, `${debugId}_disabled_true`, `${debugId}_rounded_true` ) ); expect(result(["rounded", "disabled"])).toMatch( - className( + identifierName( debugId, // disabled: true already exist `${debugId}_disabled_true`, @@ -825,8 +845,8 @@ if (import.meta.vitest) { assert.hasAllKeys(result.classNames, ["base", "variants"]); assert.isFunction(result.props); - expect(result()).toMatch(className(debugId)); - expect(result.classNames.base).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); + expect(result.classNames.base).toMatch(identifierName(debugId)); assert.hasAllKeys(result.classNames.variants, [ "color", "size", @@ -837,10 +857,10 @@ if (import.meta.vitest) { // Each variant className check assert.hasAllKeys(result.classNames.variants.color, ["brand", "accent"]); expect(result.classNames.variants.color.brand).toMatch( - className(`${debugId}_color_brand`) + identifierName(`${debugId}_color_brand`) ); expect(result.classNames.variants.color.accent).toMatch( - className(`${debugId}_color_accent`) + identifierName(`${debugId}_color_accent`) ); assert.hasAllKeys(result.classNames.variants.size, [ @@ -849,46 +869,54 @@ if (import.meta.vitest) { "large" ]); expect(result.classNames.variants.size.small).toMatch( - className(`${debugId}_size_small`) + identifierName(`${debugId}_size_small`) ); expect(result.classNames.variants.size.medium).toMatch( - className(`${debugId}_size_medium`) + identifierName(`${debugId}_size_medium`) ); expect(result.classNames.variants.size.large).toMatch( - className(`${debugId}_size_large`) + identifierName(`${debugId}_size_large`) ); assert.hasAllKeys(result.classNames.variants.outlined, ["true", "false"]); expect(result.classNames.variants.outlined.true).toMatch( - className(`${debugId}_outlined_true`) + identifierName(`${debugId}_outlined_true`) ); expect(result.classNames.variants.outlined.false).toMatch( - className(`${debugId}_outlined_false`) + identifierName(`${debugId}_outlined_false`) ); // Compose variant className check - expect(result()).toMatch(className(debugId)); + expect(result()).toMatch(identifierName(debugId)); expect(result({ color: "brand" })).toMatch( - className(debugId, `${debugId}_color_brand`) + identifierName(debugId, `${debugId}_color_brand`) ); expect(result({ size: "small" })).toMatch( - className(debugId, `${debugId}_size_small`) + identifierName(debugId, `${debugId}_size_small`) ); expect(result({ outlined: true })).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); expect(result(["outlined"])).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); expect(result({ color: "brand", size: "small" })).toMatch( - className(debugId, `${debugId}_color_brand`, `${debugId}_size_small`) + identifierName( + debugId, + `${debugId}_color_brand`, + `${debugId}_size_small` + ) ); expect(result({ size: "small", color: "brand" })).toMatch( - className(debugId, `${debugId}_size_small`, `${debugId}_color_brand`) + identifierName( + debugId, + `${debugId}_size_small`, + `${debugId}_color_brand` + ) ); expect(result(["outlined", { color: "brand" }])).toMatch( - className( + identifierName( debugId, `${debugId}_outlined_true`, `${debugId}_color_brand`, @@ -897,7 +925,7 @@ if (import.meta.vitest) { ) ); expect(result([{ color: "brand" }, "outlined"])).toMatch( - className( + identifierName( debugId, `${debugId}_color_brand`, `${debugId}_outlined_true`, @@ -906,7 +934,7 @@ if (import.meta.vitest) { ) ); expect(result(["outlined", { color: "brand", size: "medium" }])).toMatch( - className( + identifierName( debugId, `${debugId}_outlined_true`, `${debugId}_color_brand`, @@ -918,13 +946,13 @@ if (import.meta.vitest) { ); expect(result([{ color: "brand" }, { color: "accent" }])).toMatch( - className(debugId, `${debugId}_color_accent`) + identifierName(debugId, `${debugId}_color_accent`) ); expect(result(["outlined", { outlined: false }])).toMatch( - className(debugId, `${debugId}_outlined_false`) + identifierName(debugId, `${debugId}_outlined_false`) ); expect(result(["outlined", { outlined: false }, "outlined"])).toMatch( - className(debugId, `${debugId}_outlined_true`) + identifierName(debugId, `${debugId}_outlined_true`) ); }); @@ -963,7 +991,7 @@ if (import.meta.vitest) { // Test variant transformation expect(result({ color: "brand", size: "small" })).toMatch( - className( + identifierName( debugId, `${debugId}_color_brand`, `${debugId}_size_small`, @@ -993,7 +1021,7 @@ if (import.meta.vitest) { expect( resultMultiple({ color: "brand", size: "small", outlined: true }) ).toMatch( - className( + identifierName( debugId, `${debugId}_color_brand`, `${debugId}_size_small`, @@ -1016,7 +1044,11 @@ if (import.meta.vitest) { debugId ); expect(resultBoolean({ outlined: true })).toMatch( - className(debugId, `${debugId}_outlined_true`, `${debugId}_compound_0`) + identifierName( + debugId, + `${debugId}_outlined_true`, + `${debugId}_compound_0` + ) ); // Test empty conditions @@ -1028,7 +1060,7 @@ if (import.meta.vitest) { debugId ); expect(resultEmpty({ color: "brand" })).toMatch( - className(debugId, `${debugId}_color_brand`) + identifierName(debugId, `${debugId}_color_brand`) ); }); @@ -1052,7 +1084,7 @@ if (import.meta.vitest) { ).forEach(([varName, propValue]) => { // Partial expect(propValue).toBe("red"); - expect(varName).toMatch(className(`--${debugId}_color`)); + expect(varName).toMatch(identifierName(`--${debugId}_color`)); }); Object.entries( result1.props({ @@ -1064,10 +1096,10 @@ if (import.meta.vitest) { expect(propValue).toBeOneOf(["red", "blue"]); if (propValue === "red") { - expect(varName).toMatch(className(`--${debugId}_color`)); + expect(varName).toMatch(identifierName(`--${debugId}_color`)); } if (propValue === "blue") { - expect(varName).toMatch(className(`--${debugId}_background`)); + expect(varName).toMatch(identifierName(`--${debugId}_background`)); } }); Object.entries( @@ -1099,10 +1131,10 @@ if (import.meta.vitest) { expect(propValue).toBeOneOf(["999px", "2rem"]); if (propValue === "999px") { - expect(varName).toMatch(className(`--${debugId}_rounded`)); + expect(varName).toMatch(identifierName(`--${debugId}_rounded`)); } if (propValue === "2rem") { - expect(varName).toMatch(className(`--${debugId}_size`)); + expect(varName).toMatch(identifierName(`--${debugId}_size`)); } }); @@ -1131,16 +1163,16 @@ if (import.meta.vitest) { expect(propValue).toBeOneOf(["red", "blue", "999px", "2rem"]); if (propValue === "red") { - expect(varName).toMatch(className(`--${debugId}_color`)); + expect(varName).toMatch(identifierName(`--${debugId}_color`)); } if (propValue === "blue") { - expect(varName).toMatch(className(`--${debugId}_background`)); + expect(varName).toMatch(identifierName(`--${debugId}_background`)); } if (propValue === "999px") { - expect(varName).toMatch(className(`--${debugId}_rounded`)); + expect(varName).toMatch(identifierName(`--${debugId}_rounded`)); } if (propValue === "2rem") { - expect(varName).toMatch(className(`--${debugId}_size`)); + expect(varName).toMatch(identifierName(`--${debugId}_size`)); } }); }); @@ -1318,9 +1350,9 @@ if (import.meta.vitest) { // Check button pattern assert.hasAllKeys(result.button, ["props", "variants", "classNames"]); assert.hasAllKeys(result.button.classNames, ["base", "variants"]); - expect(result.button()).toMatch(className(`${debugId}_button`)); + expect(result.button()).toMatch(identifierName(`${debugId}_button`)); expect(result.button.classNames.base).toMatch( - className(`${debugId}_button`) + identifierName(`${debugId}_button`) ); assert.hasAllKeys(result.button.classNames.variants, ["variant"]); assert.hasAllKeys(result.button.classNames.variants.variant, [ @@ -1331,9 +1363,9 @@ if (import.meta.vitest) { // Check input pattern assert.hasAllKeys(result.input, ["props", "variants", "classNames"]); assert.hasAllKeys(result.input.classNames, ["base", "variants"]); - expect(result.input()).toMatch(className(`${debugId}_input`)); + expect(result.input()).toMatch(identifierName(`${debugId}_input`)); expect(result.input.classNames.base).toMatch( - className(`${debugId}_input`) + identifierName(`${debugId}_input`) ); assert.hasAllKeys(result.input.classNames.variants, ["state"]); assert.hasAllKeys(result.input.classNames.variants.state, [ @@ -1343,10 +1375,10 @@ if (import.meta.vitest) { // Test usage expect(result.button({ variant: "primary" })).toMatch( - className(`${debugId}_button`, `${debugId}_button_variant_primary`) + identifierName(`${debugId}_button`, `${debugId}_button_variant_primary`) ); expect(result.input({ state: "error" })).toMatch( - className(`${debugId}_input`, `${debugId}_input_state_error`) + identifierName(`${debugId}_input`, `${debugId}_input_state_error`) ); }); @@ -1379,7 +1411,7 @@ if (import.meta.vitest) { // Check light theme pattern assert.hasAllKeys(result.light, ["props", "variants", "classNames"]); - expect(result.light()).toMatch(className(`${debugId}_light`)); + expect(result.light()).toMatch(identifierName(`${debugId}_light`)); assert.hasAllKeys(result.light.classNames.variants, ["emphasis"]); assert.hasAllKeys(result.light.classNames.variants.emphasis, [ "subtle", @@ -1388,7 +1420,7 @@ if (import.meta.vitest) { // Check dark theme pattern assert.hasAllKeys(result.dark, ["props", "variants", "classNames"]); - expect(result.dark()).toMatch(className(`${debugId}_dark`)); + expect(result.dark()).toMatch(identifierName(`${debugId}_dark`)); assert.hasAllKeys(result.dark.classNames.variants, ["emphasis"]); assert.hasAllKeys(result.dark.classNames.variants.emphasis, [ "subtle", @@ -1397,10 +1429,10 @@ if (import.meta.vitest) { // Test usage expect(result.light({ emphasis: "strong" })).toMatch( - className(`${debugId}_light`, `${debugId}_light_emphasis_strong`) + identifierName(`${debugId}_light`, `${debugId}_light_emphasis_strong`) ); expect(result.dark({ emphasis: "subtle" })).toMatch( - className(`${debugId}_dark`, `${debugId}_dark_emphasis_subtle`) + identifierName(`${debugId}_dark`, `${debugId}_dark_emphasis_subtle`) ); }); @@ -1427,7 +1459,7 @@ if (import.meta.vitest) { // Test usage expect(result.md({ direction: "horizontal" })).toMatch( - className(`${debugId}_md`, `${debugId}_md_direction_horizontal`) + identifierName(`${debugId}_md`, `${debugId}_md_direction_horizontal`) ); }); }); diff --git a/packages/css/src/theme/index.ts b/packages/css/src/theme/index.ts index 594dff82..f7099d06 100644 --- a/packages/css/src/theme/index.ts +++ b/packages/css/src/theme/index.ts @@ -1,6 +1,6 @@ -import { generateIdentifier } from "@vanilla-extract/css"; +import { createVar, generateIdentifier } from "@vanilla-extract/css"; import { registerClassName } from "@vanilla-extract/css/adapter"; -import { getFileScope } from "@vanilla-extract/css/fileScope"; +import { getFileScope, setFileScope } from "@vanilla-extract/css/fileScope"; import type { GlobalCSSRule, CSSVarValue, @@ -8,12 +8,11 @@ import type { PureCSSVarKey } from "@mincho-js/transform-to-vanilla"; import { globalCss } from "../css/index.js"; -import { camelToKebab } from "../utils.js"; +import { identifierName, camelToKebab, getVarName } from "../utils.js"; import type { Resolve } from "../types.js"; import type { Theme, ResolveTheme, - ThemeValue, TokenValue, TokenDefinition, TokenPrimitiveValue, @@ -25,14 +24,19 @@ import type { TokenFontWeightValue } from "./types.js"; +// == Public API ============================================================== type WithOptionalLayer = T & { "@layer"?: string; }; -export function globalTheme( +type ResolveThemeOutput = Resolve>; +type ThemeTokensInput = + WithOptionalLayer & ThisType>; + +export function globalTheme( selector: string, - tokens: WithOptionalLayer -): Resolve> { + tokens: ThemeTokensInput +): ResolveThemeOutput { const { layerName, tokens: themeTokens } = extractLayerFromTokens(tokens); const { vars, resolvedTokens } = assignTokens(themeTokens); @@ -53,10 +57,10 @@ export function globalTheme( return resolvedTokens; } -export function theme( - tokens: WithOptionalLayer, +export function theme( + tokens: ThemeTokensInput, debugId?: string -): [string, Resolve>] { +): [string, ResolveThemeOutput] { const themeClassName = generateIdentifier(debugId); registerClassName(themeClassName, getFileScope()); @@ -80,7 +84,27 @@ function extractLayerFromTokens( return { tokens }; } -// === Assign Variables ======================================================== +// == Token Assignment Orchestration =========================================== +interface AssignedVars { + [cssVarName: string]: CSSVarValue; +} +interface CSSVarMap { + [varPath: string]: PureCSSVarKey; +} + +function assignTokens( + tokens: ThemeTokens +): { + vars: AssignedVars; + resolvedTokens: ResolveTheme; +} { + return assignTokensWithPrefix(tokens, ""); +} + +/** + * Assigns CSS variables to theme tokens using a two-pass algorithm. + * Pass 1 assigns variables to all tokens, Pass 2 resolves semantic token references. + */ function assignTokensWithPrefix( tokens: ThemeTokens, prefix = "" @@ -91,34 +115,227 @@ function assignTokensWithPrefix( const vars: AssignedVars = {}; const resolvedTokens = {} as ResolveTheme; - for (const [key, value] of Object.entries(tokens)) { - const varPath = buildVarPath(prefix, key); + // Execute two-pass token resolution: + // 1. Assign CSS variables to all tokens + // 2. Resolve semantic token references + const context: TokenProcessingContext = { + prefix, + path: [], + parentPath: prefix, + cssVarMap: {} + }; + assignTokenVariables(tokens, vars, resolvedTokens, context); + resolveSemanticTokens(tokens, vars, resolvedTokens, context); + + return { vars, resolvedTokens }; +} - if (isThemeValue(value)) { - processThemeValue(key, value, varPath, vars, resolvedTokens); - } else if (isNestedTheme(value)) { - const { vars: nestedVars, resolvedTokens: nestedResolved } = - assignTokensWithPrefix(value as Theme, varPath); +// == Two-Pass Token Resolution =============================================== +/** + * Context object for token processing functions + */ +interface TokenProcessingContext { + prefix: string; // Variable name prefix + path: string[]; // Current path in object tree + parentPath: string; // Current variable path + cssVarMap: CSSVarMap; // Cache for CSS variable names +} - // Merge nested vars directly (they already have correct prefixes) - Object.assign(vars, nestedVars); - (resolvedTokens as Record)[key] = nestedResolved; +/** + * Pass 1: Assigns CSS variables to all tokens and builds resolved structure. + * Processes regular tokens immediately, creates placeholders for semantic tokens. + */ +function assignTokenVariables( + themeNode: Record, + vars: AssignedVars, + resolvedTokens: Record, + context: TokenProcessingContext +): void { + const descriptors = Object.getOwnPropertyDescriptors(themeNode); + + for (const [key, descriptor] of Object.entries(descriptors)) { + const varPath = context.parentPath + ? `${context.parentPath}-${camelToKebab(key)}` + : camelToKebab(key); + const cssVar = getCSSVarByPath(varPath, context); + const varRef = getVarReference(varPath, context); + const currentPath = [...context.path, key]; + + // Handle getters (referencing tokens) + if (typeof descriptor.get === "function") { + // Store placeholder CSS variable reference for now + setByPath(resolvedTokens, currentPath, varRef); + continue; } - } - return { vars, resolvedTokens }; + const value = descriptor.value; + + // Handle TokenDefinition + if (isTokenDefinition(value)) { + // Check if $value is a structured value for the token type + const tokenType = value.$type; + const tokenValue = value.$value; + + if (isStructuredTokenValue(tokenType, tokenValue)) { + // Process token definition as a single value + const cssValue = extractTokenDefinitionValue(value); + vars[cssVar] = cssValue; + setByPath(resolvedTokens, currentPath, varRef); + } else if (isNestedTheme(tokenValue)) { + // For nested objects in token definitions, recurse into them + setByPath(resolvedTokens, currentPath, {}); + assignTokenVariables( + tokenValue as Record, + vars, + resolvedTokens, + { + prefix: context.prefix, + path: currentPath, + parentPath: varPath, + cssVarMap: context.cssVarMap + } + ); + } else { + // Fallback for other types + const cssValue = extractTokenDefinitionValue(value); + vars[cssVar] = cssValue; + setByPath(resolvedTokens, currentPath, varRef); + } + continue; + } + + // Handle arrays + if (Array.isArray(value)) { + const resolvedArray: PureCSSVarFunction[] = []; + value.forEach((item, index) => { + const indexedPath = `${varPath}-${index}`; + const indexedCssVar = getCSSVarByPath(indexedPath, context); + vars[indexedCssVar] = extractCSSValue(item); + resolvedArray.push(getVarReference(indexedPath, context)); + }); + setByPath(resolvedTokens, currentPath, resolvedArray); + continue; + } + + // Handle TokenCompositeValue + if (isTokenCompositeValue(value)) { + const resolvedComposite: Record = {}; + + // Process resolved property + vars[cssVar] = extractCSSValue(value.resolved); + + // Create getter for resolved property + Object.defineProperty(resolvedComposite, "resolved", { + get() { + return varRef; + }, + enumerable: true, + configurable: true + }); + + // Process other properties + for (const [propKey, propValue] of Object.entries(value)) { + if (propKey === "resolved") continue; + + const propPath = `${varPath}-${camelToKebab(propKey)}`; + const propCssVar = getCSSVarByPath(propPath, context); + vars[propCssVar] = extractCSSValue(propValue as TokenValue); + resolvedComposite[propKey] = getVarReference(propPath, context); + } + + setByPath(resolvedTokens, currentPath, resolvedComposite); + continue; + } + + // Handle nested objects + if (isNestedTheme(value)) { + setByPath(resolvedTokens, currentPath, {}); + assignTokenVariables(value, vars, resolvedTokens, { + prefix: context.prefix, + path: currentPath, + parentPath: varPath, + cssVarMap: context.cssVarMap + }); + continue; + } + + // Handle primitive values and TokenUnitValue + const cssValue = extractCSSValue(value as TokenValue); + vars[cssVar] = cssValue; + setByPath(resolvedTokens, currentPath, varRef); + } } -function assignTokens( - tokens: ThemeTokens -): { - vars: AssignedVars; - resolvedTokens: ResolveTheme; -} { - return assignTokensWithPrefix(tokens, ""); +/** + * Pass 2: Resolves semantic token references using completed structure. + * Evaluates getter functions with resolvedTokens as context to enable + * semantic tokens to reference other tokens via CSS variables. + */ +function resolveSemanticTokens( + themeNode: Record, + vars: AssignedVars, + resolvedTokens: Record, + context: TokenProcessingContext +): void { + const descriptors = Object.getOwnPropertyDescriptors(themeNode); + + for (const [key, descriptor] of Object.entries(descriptors)) { + const currentPath = [...context.path, key]; + const varPath = context.parentPath + ? `${context.parentPath}-${camelToKebab(key)}` + : camelToKebab(key); + + // Handle getters (semantic tokens) + if (typeof descriptor.get === "function") { + const cssVar = getCSSVarByPath(varPath, context); + + // Call getter with resolvedTokens as this context + // This allows semantic tokens to reference other tokens + const computedValue = descriptor.get.call(resolvedTokens); + + // Store the computed reference (should be a var() reference) + vars[cssVar] = computedValue; + + // Update resolvedTokens with the actual reference + setByPath(resolvedTokens, currentPath, computedValue); + continue; + } + + const value = descriptor.value; + + // Handle nested TokenDefinition with object $value + if ( + isTokenDefinition(value) && + isPlainObject(value.$value) && + !Array.isArray(value.$value) + ) { + resolveSemanticTokens( + value.$value as Record, + vars, + resolvedTokens, + { + prefix: context.prefix, + path: currentPath, + parentPath: varPath, + cssVarMap: context.cssVarMap + } + ); + continue; + } + + // Recurse for nested objects (but not other types) + if (isNestedTheme(value)) { + resolveSemanticTokens(value, vars, resolvedTokens, { + prefix: context.prefix, + path: currentPath, + parentPath: varPath, + cssVarMap: context.cssVarMap + }); + } + } } -// === Type Guards ============================================================= +// == Type Guards ============================================================= function isPrimitive(value: unknown): value is TokenPrimitiveValue { const type = typeof value; return ( @@ -129,6 +346,45 @@ function isPrimitive(value: unknown): value is TokenPrimitiveValue { ); } +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === "[object Object]"; +} + +function isNestedTheme(value: unknown): value is Theme { + if (!isPlainObject(value)) return false; + if (Array.isArray(value)) return false; + if (isTokenUnitValue(value)) return false; + if (isTokenCompositeValue(value)) return false; + if (isTokenDefinition(value)) return false; + return true; +} + +/** + * Checks if a token type has a structured value that should be processed as a single unit + * rather than recursed into as a nested object. + */ +function isStructuredTokenValue( + tokenType: string, + tokenValue: unknown +): boolean { + return ( + ((tokenType === "dimension" || tokenType === "duration") && + isPlainObject(tokenValue) && + "value" in tokenValue && + "unit" in tokenValue) || + (tokenType === "cubicBezier" && Array.isArray(tokenValue)) || + (tokenType === "fontFamily" && + (typeof tokenValue === "string" || Array.isArray(tokenValue))) || + tokenType === "fontWeight" || + tokenType === "number" || + (tokenType === "color" && + (typeof tokenValue === "string" || + (isPlainObject(tokenValue) && + "colorSpace" in tokenValue && + "components" in tokenValue))) + ); +} + function isTokenUnitValue(value: unknown): value is TokenDimensionValue { return ( typeof value === "object" && @@ -156,40 +412,53 @@ function isTokenDefinition(value: unknown): value is TokenDefinition { ); } -function isThemeValue(value: unknown): value is ThemeValue { - if (isPrimitive(value)) return true; - if (Array.isArray(value)) return true; - if (isTokenUnitValue(value)) return true; - if (isTokenCompositeValue(value)) return true; - if (isTokenDefinition(value)) return true; - return false; -} - -function isNestedTheme(value: unknown): value is Theme { - if (typeof value !== "object" || value === null) return false; - if (Array.isArray(value)) return false; - if (isTokenUnitValue(value)) return false; - if (isTokenCompositeValue(value)) return false; - if (isTokenDefinition(value)) return false; - return true; -} - // === Path Utilities ========================================================== -function pathToCSSVar(path: string): PureCSSVarKey { - return `--${path}` as PureCSSVarKey; +/** + * Gets a cached CSS variable name for the given path. + * Creates and caches the CSS variable name if not already cached. + */ +function getCSSVarByPath( + varPath: string, + context: TokenProcessingContext +): PureCSSVarKey { + if (!context.cssVarMap[varPath]) { + const varValue = createVar(varPath); + context.cssVarMap[varPath] = getVarName(varValue); + } + return context.cssVarMap[varPath]; } -function pathToVarReference(path: string): PureCSSVarFunction { - return `var(--${path})` as PureCSSVarFunction; +/** + * Gets a CSS variable reference using the cached variable name. + * Ensures consistency between variable definitions and references. + */ +function getVarReference( + varPath: string, + context: TokenProcessingContext +): PureCSSVarFunction { + const cssVar = getCSSVarByPath(varPath, context); + return `var(${cssVar})` as PureCSSVarFunction; } -function buildVarPath(prefix: string, key: string): string { - const kebabKey = camelToKebab(key); - return prefix ? `${prefix}-${kebabKey}` : kebabKey; +function setByPath( + obj: Record, + path: string[], + value: unknown +): void { + let target: Record = obj; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!isPlainObject(target[key])) { + target[key] = {}; + } + target = target[key] as Record; + } + if (path.length > 0) { + target[path[path.length - 1]] = value; + } } -// === Value Extraction ======================================================== -// === Token Type Value Extractors ============================================ +// == Token Value Extractors ================================================== function extractFontFamilyValue(value: TokenFontFamilyValue): CSSVarValue { if (Array.isArray(value)) { return value.join(", ") as CSSVarValue; @@ -220,9 +489,14 @@ function extractColorValue(value: string | TokenColorValue): CSSVarValue { // Otherwise, construct from color space and components // This is a simplified version - full implementation would need proper color space handling - const components = value.components.join(", "); - const alpha = value.alpha !== undefined ? ` / ${value.alpha}` : ""; - return `${value.colorSpace}(${components}${alpha})` as CSSVarValue; + if (value.components && Array.isArray(value.components)) { + const components = value.components.join(", "); + const alpha = value.alpha !== undefined ? ` / ${value.alpha}` : ""; + return `${value.colorSpace}(${components}${alpha})` as CSSVarValue; + } + + // Fallback for invalid color object + return "#000000" as CSSVarValue; } function extractFontWeightValue(value: TokenFontWeightValue): CSSVarValue { @@ -286,7 +560,6 @@ function extractTokenDefinitionValue(definition: TokenDefinition): CSSVarValue { } } -// === Value Extraction ======================================================== function extractCSSValue(value: TokenValue): CSSVarValue { if (isPrimitive(value)) { return String(value) as CSSVarValue; @@ -315,103 +588,7 @@ function extractCSSValue(value: TokenValue): CSSVarValue { throw new Error(`Unexpected value type in extractCSSValue: ${typeof value}`); } -// === Theme Value Processing ================================================== -interface AssignedVars { - [tokenName: string]: CSSVarValue; -} - -function processThemeValue( - key: string, - value: ThemeValue, - varPath: string, - vars: AssignedVars, - resolvedTokens: Record -): void { - // Handle TokenDefinition - extract value based on $type - if (isTokenDefinition(value)) { - // Check if it's a specific token type that needs special handling - if ( - (value.$type && typeof value.$value !== "object") || - (typeof value.$value === "object" && !isNestedTheme(value.$value)) - ) { - // Use the token-specific extractor - const cssVarName = pathToCSSVar(varPath); - vars[cssVarName] = extractTokenDefinitionValue(value); - resolvedTokens[key] = pathToVarReference(varPath); - return; - } - - // Otherwise, handle as before for nested themes - const innerValue = value.$value; - if (isThemeValue(innerValue)) { - processThemeValue(key, innerValue, varPath, vars, resolvedTokens); - } else if (isNestedTheme(innerValue)) { - // It's a nested theme, process it recursively - const { vars: nestedVars, resolvedTokens: nestedResolved } = - assignTokensWithPrefix(innerValue as Theme, varPath); - - // Merge nested vars directly - Object.assign(vars, nestedVars); - resolvedTokens[key] = nestedResolved; - } - return; - } - - // Handle arrays - create indexed variables - if (Array.isArray(value)) { - const resolvedArray: PureCSSVarFunction[] = []; - - value.forEach((item, index) => { - const indexedPath = `${varPath}-${index}`; - const cssVarName = pathToCSSVar(indexedPath); - const varRef = pathToVarReference(indexedPath); - - vars[cssVarName] = extractCSSValue(item); - resolvedArray.push(varRef); - }); - - resolvedTokens[key] = resolvedArray; - return; - } - - // Handle TokenComposedValue - process resolved and all properties - if (isTokenCompositeValue(value)) { - const resolvedComposite: Record = {}; - - // Process 'resolved' property - const resolvedCssVar = pathToCSSVar(varPath); - vars[resolvedCssVar] = extractCSSValue(value.resolved); - - // Create getter for resolved property to match original structure - Object.defineProperty(resolvedComposite, "resolved", { - get() { - return pathToVarReference(varPath); - }, - enumerable: true, - configurable: true - }); - - // Process other properties - for (const [propKey, propValue] of Object.entries(value)) { - if (propKey === "resolved") continue; - - const propPath = `${varPath}-${camelToKebab(propKey)}`; - const propCssVar = pathToCSSVar(propPath); - vars[propCssVar] = extractCSSValue(propValue as TokenValue); - resolvedComposite[propKey] = pathToVarReference(propPath); - } - - resolvedTokens[key] = resolvedComposite; - return; - } - - // Handle primitives and TokenUnitValue - const cssVarName = pathToCSSVar(varPath); - vars[cssVarName] = extractCSSValue(value as TokenValue); - resolvedTokens[key] = pathToVarReference(varPath); -} - -// == Tests ==================================================================== +// == Tests =================================================================== // Ignore errors when compiling to CommonJS. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. @@ -420,13 +597,65 @@ if (import.meta.vitest) { // @ts-ignore error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', or 'nodenext'. const { describe, it, expect, assertType } = import.meta.vitest; + const debugId = "myCSS"; + setFileScope("test"); + function composedValue( value: ComposedValue & ThisType ): ComposedValue { return value; } - describe("assignTokens", () => { + // Test utility functions for handling hashed CSS variables + function stripHash(str: string): string { + // Remove hash suffix from CSS variable names and var() references + return str.replace(/__[a-zA-Z0-9]+/g, ""); + } + + function normalizeVars(vars: AssignedVars): AssignedVars { + const normalized: AssignedVars = {}; + for (const [key, value] of Object.entries(vars)) { + normalized[stripHash(key)] = value; + } + return normalized; + } + + function normalizeResolvedTokens(tokens: T): T { + if (typeof tokens === "string") { + return stripHash(tokens) as T; + } + if (Array.isArray(tokens)) { + return tokens.map(normalizeResolvedTokens) as T; + } + if (tokens && typeof tokens === "object") { + const normalized: T = {} as T; + const source = tokens as Record; + const target = normalized as Record; + for (const [key, value] of Object.entries(source)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (descriptor?.get) { + Object.defineProperty(target, key, { + get: () => normalizeResolvedTokens(source[key]), + enumerable: true, + configurable: true + }); + } else { + target[key] = normalizeResolvedTokens(value); + } + } + return normalized; + } + return tokens; + } + + // Validate that CSS variables have proper hash format + function validateHashFormat(vars: AssignedVars): void { + for (const key of Object.keys(vars)) { + expect(key).toMatch(/^--[a-zA-Z0-9-]+__[a-zA-Z0-9]+$/); + } + } + + describe.concurrent("assignTokens", () => { it("handles primitive values", () => { const result = assignTokens({ color: "red", @@ -435,14 +664,18 @@ if (import.meta.vitest) { nothing: undefined }); - expect(result.vars).toEqual({ + // Validate hash format is correct + validateHashFormat(result.vars); + + // Compare normalized values (without hashes) + expect(normalizeVars(result.vars)).toEqual({ "--color": "red", "--size": "16", "--enabled": "true", "--nothing": "undefined" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ color: "var(--color)", size: "var(--size)", enabled: "var(--enabled)", @@ -457,13 +690,15 @@ if (import.meta.vitest) { lineHeight: 1.5 }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + + expect(normalizeVars(result.vars)).toEqual({ "--background-color": "white", "--font-size": "16px", "--line-height": "1.5" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ backgroundColor: "var(--background-color)", fontSize: "var(--font-size)", lineHeight: "var(--line-height)" @@ -475,7 +710,9 @@ if (import.meta.vitest) { space: [2, 4, 8, 16, 32] }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + + expect(normalizeVars(result.vars)).toEqual({ "--space-0": "2", "--space-1": "4", "--space-2": "8", @@ -483,7 +720,7 @@ if (import.meta.vitest) { "--space-4": "32" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ space: [ "var(--space-0)", "var(--space-1)", @@ -509,7 +746,9 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + + expect(normalizeVars(result.vars)).toEqual({ "--color-base-red": "#ff0000", "--color-base-green": "#00ff00", "--color-base-blue": "#0000ff", @@ -517,21 +756,63 @@ if (import.meta.vitest) { "--color-semantic-secondary": "#6c757d" }); + const normalizedTokens = normalizeResolvedTokens(result.resolvedTokens); assertType(result.resolvedTokens.color.base.red); - expect(result.resolvedTokens.color.base.red).toBe( - "var(--color-base-red)" + expect(normalizedTokens.color.base.red).toBe("var(--color-base-red)"); + expect(normalizedTokens.color.base.green).toBe("var(--color-base-green)"); + expect(normalizedTokens.color.base.blue).toBe("var(--color-base-blue)"); + expect(normalizedTokens.color.semantic.primary).toBe( + "var(--color-semantic-primary)" ); - expect(result.resolvedTokens.color.base.green).toBe( - "var(--color-base-green)" + expect(normalizedTokens.color.semantic.secondary).toBe( + "var(--color-semantic-secondary)" + ); + }); + + it("handles semantic token references with getters", () => { + const result = assignTokens( + composedValue({ + color: { + base: { + red: "#ff0000", + blue: "#0000ff" + }, + semantic: { + get primary(): string { + return this.color.base.blue; + }, + get danger(): string { + return this.color.base.red; + } + } + } + }) ); - expect(result.resolvedTokens.color.base.blue).toBe( + + validateHashFormat(result.vars); + + // CSS variables should be created for both base and semantic tokens + // Note: Semantic tokens reference other tokens, so their values are var() references + const normalizedVars = normalizeVars(result.vars); + expect(normalizedVars["--color-base-red"]).toBe("#ff0000"); + expect(normalizedVars["--color-base-blue"]).toBe("#0000ff"); + // Semantic tokens contain var() references WITH hashes that need to be normalized + expect(stripHash(normalizedVars["--color-semantic-primary"])).toBe( "var(--color-base-blue)" ); - expect(result.resolvedTokens.color.semantic.primary).toBe( - "var(--color-semantic-primary)" + expect(stripHash(normalizedVars["--color-semantic-danger"])).toBe( + "var(--color-base-red)" ); - expect(result.resolvedTokens.color.semantic.secondary).toBe( - "var(--color-semantic-secondary)" + + // Resolved tokens should all use var() references + const normalizedTokens = normalizeResolvedTokens(result.resolvedTokens); + expect(normalizedTokens.color.base.red).toBe("var(--color-base-red)"); + expect(normalizedTokens.color.base.blue).toBe("var(--color-base-blue)"); + expect(normalizedTokens.color.semantic.primary).toBe( + "var(--color-base-blue)" + ); + expect(normalizedTokens.color.semantic.danger).toBe( + "var(--color-base-red)" ); }); @@ -541,12 +822,13 @@ if (import.meta.vitest) { borderWidth: { value: 2, unit: "px" } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--spacing": "1.5rem", "--border-width": "2px" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ spacing: "var(--spacing)", borderWidth: "var(--border-width)" }); @@ -565,12 +847,13 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--primary": "#0000ff", "--font-size": "16px" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ primary: "var(--primary)", fontSize: "var(--font-size)" }); @@ -588,12 +871,13 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--font-primary": "Helvetica, Arial, sans-serif", "--font-secondary": "Georgia, serif" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ fontPrimary: "var(--font-primary)", fontSecondary: "var(--font-secondary)" }); @@ -611,12 +895,13 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--transition-fast": "200ms", "--transition-slow": "1s" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ transitionFast: "var(--transition-fast)", transitionSlow: "var(--transition-slow)" }); @@ -634,12 +919,13 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--easing-default": "cubic-bezier(0.5, 0, 1, 1)", "--easing-bounce": "cubic-bezier(0.68, -0.55, 0.265, 1.55)" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ easingDefault: "var(--easing-default)", easingBounce: "var(--easing-bounce)" }); @@ -653,11 +939,12 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--color-brand": "#ff5500" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ colorBrand: "var(--color-brand)" }); }); @@ -682,14 +969,15 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--weight-normal": "400", "--weight-bold": "700", "--weight-semi-bold": "600", "--weight-numeric": "300" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ weightNormal: "var(--weight-normal)", weightBold: "var(--weight-bold)", weightSemiBold: "var(--weight-semi-bold)", @@ -709,12 +997,13 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--line-height-base": "1.5", "--z-index-modal": "1000" }); - expect(result.resolvedTokens).toEqual({ + expect(normalizeResolvedTokens(result.resolvedTokens)).toEqual({ lineHeightBase: "var(--line-height-base)", zIndexModal: "var(--z-index-modal)" }); @@ -748,7 +1037,8 @@ if (import.meta.vitest) { } }); - expect(result.vars).toEqual({ + validateHashFormat(result.vars); + expect(normalizeVars(result.vars)).toEqual({ "--typography-font-family": "Inter, system-ui, sans-serif", "--typography-font-weight": "500", "--typography-line-height": "1.6", @@ -756,21 +1046,18 @@ if (import.meta.vitest) { "--animation-easing": "cubic-bezier(0.4, 0, 0.2, 1)" }); - expect(result.resolvedTokens.typography.fontFamily).toBe( + const normalized = normalizeResolvedTokens(result.resolvedTokens); + expect(normalized.typography.fontFamily).toBe( "var(--typography-font-family)" ); - expect(result.resolvedTokens.typography.fontWeight).toBe( + expect(normalized.typography.fontWeight).toBe( "var(--typography-font-weight)" ); - expect(result.resolvedTokens.typography.lineHeight).toBe( + expect(normalized.typography.lineHeight).toBe( "var(--typography-line-height)" ); - expect(result.resolvedTokens.animation.duration).toBe( - "var(--animation-duration)" - ); - expect(result.resolvedTokens.animation.easing).toBe( - "var(--animation-easing)" - ); + expect(normalized.animation.duration).toBe("var(--animation-duration)"); + expect(normalized.animation.easing).toBe("var(--animation-easing)"); }); it("handles TokenComposedValue with resolved getter", () => { @@ -790,13 +1077,17 @@ if (import.meta.vitest) { } }); - expect(result.vars["--shadow-light"]).toMatch(/^#00000080/); - expect(result.vars["--shadow-light-color"]).toBe("#00000080"); - expect(result.vars["--shadow-light-offset-x"]).toBe("0.5rem"); - expect(result.vars["--shadow-light-offset-y"]).toBe("0.5rem"); - expect(result.vars["--shadow-light-blur"]).toBe("1.5rem"); + validateHashFormat(result.vars); + const normalized = normalizeVars(result.vars); + + expect(normalized["--shadow-light"]).toMatch(/^#00000080/); + expect(normalized["--shadow-light-color"]).toBe("#00000080"); + expect(normalized["--shadow-light-offset-x"]).toBe("0.5rem"); + expect(normalized["--shadow-light-offset-y"]).toBe("0.5rem"); + expect(normalized["--shadow-light-blur"]).toBe("1.5rem"); - const resolvedShadow = result.resolvedTokens.shadow.light; + const resolvedShadow = normalizeResolvedTokens(result.resolvedTokens) + .shadow.light; expect(resolvedShadow.resolved).toBe("var(--shadow-light)"); expect(resolvedShadow.color).toBe("var(--shadow-light-color)"); expect(resolvedShadow.offsetX).toBe("var(--shadow-light-offset-x)"); @@ -826,16 +1117,94 @@ if (import.meta.vitest) { } }); - expect(result.vars["--typography-heading-sizes-0"]).toBe("48"); - expect(result.vars["--typography-heading-sizes-4"]).toBe("16"); - expect(result.vars["--typography-heading-weight"]).toBe("700"); - expect(result.vars["--typography-heading-family"]).toBe( + validateHashFormat(result.vars); + const normalized = normalizeVars(result.vars); + + expect(normalized["--typography-heading-sizes-0"]).toBe("48"); + expect(normalized["--typography-heading-sizes-4"]).toBe("16"); + expect(normalized["--typography-heading-weight"]).toBe("700"); + expect(normalized["--typography-heading-family"]).toBe( "Helvetica, sans-serif" ); - expect(result.vars["--typography-body-size"]).toBe("14px"); - expect(result.vars["--typography-body-line-height"]).toBe("1.5"); - expect(result.vars["--colors-primary"]).toBe("#007bff"); - expect(result.vars["--colors-secondary"]).toBe("#6c757d"); + expect(normalized["--typography-body-size"]).toBe("14px"); + expect(normalized["--typography-body-line-height"]).toBe("1.5"); + expect(normalized["--colors-primary"]).toBe("#007bff"); + expect(normalized["--colors-secondary"]).toBe("#6c757d"); + }); + }); + + describe.concurrent("theme", () => { + it("handles complex color tokens and semantic references", () => { + const [className, themeVars] = theme( + { + color: { + base: { + red: { + $type: "color", + $value: { + colorSpace: "srgb", + components: [255, 0, 0] + } + }, + green: "#00ff00", + blue: "#0000ff" + }, + semantic: { + get primary(): string { + return this.color.base.blue; + }, + get error(): string { + return this.color.base.red; + } + } + }, + space: { + base: [2, 4, 8, 16, 32, 64], + semantic: { + get small(): string { + return this.space.base[1]; + }, + get medium(): string { + return this.space.base[3]; + }, + get large(): string { + return this.space.base[5]; + } + } + } + }, + debugId + ); + + expect(className).toMatch(identifierName(`${debugId}`)); + expect(normalizeResolvedTokens(themeVars)).toEqual({ + color: { + base: { + red: "var(--color-base-red)", + green: "var(--color-base-green)", + blue: "var(--color-base-blue)" + }, + semantic: { + primary: "var(--color-base-blue)", + error: "var(--color-base-red)" + } + }, + space: { + base: [ + "var(--space-base-0)", + "var(--space-base-1)", + "var(--space-base-2)", + "var(--space-base-3)", + "var(--space-base-4)", + "var(--space-base-5)" + ], + semantic: { + small: "var(--space-base-1)", + medium: "var(--space-base-3)", + large: "var(--space-base-5)" + } + } + }); }); }); } diff --git a/packages/css/src/theme/types.ts b/packages/css/src/theme/types.ts index dd8f20e0..5613144b 100644 --- a/packages/css/src/theme/types.ts +++ b/packages/css/src/theme/types.ts @@ -1,20 +1,35 @@ -import type { PureCSSVarFunction } from "@mincho-js/transform-to-vanilla"; +/* eslint-disable @typescript-eslint/no-empty-object-type */ +import type { + PureCSSVarFunction, + NonNullableString +} from "@mincho-js/transform-to-vanilla"; import type { Resolve } from "../types.js"; -/** - * Based on W3C Design Tokens Community Group - * https://www.w3.org/community/design-tokens/ - * https://www.designtokens.org/tr/drafts/format/ - **/ export interface Theme { [tokenName: string]: TokenValue | Theme; } export type ThemeValue = + | { get(): TokenPrimitiveValue } | TokenPrimitiveValue | TokenPrimitiveValue[] | TokenCompositeValue - | TokenDefinition; + | TokenAllDefinition; + +/** + * Based on W3C Design Tokens Community Group + * @see https://www.w3.org/community/design-tokens/ + * @see https://www.designtokens.org/tr/drafts/format/ + **/ +export type TokenAllDefinition = + | TokenOtherDefinition + | TokenColorDefinition + | TokenDimensionDefinition + | TokenFontFamilyDefinition + | TokenFontWeightDefinition + | TokenDurationDefinition + | TokenCubicBezierDefinition + | TokenNumberDefinition; export interface TokenDefinition { $type: string; @@ -22,11 +37,16 @@ export interface TokenDefinition { $description?: string; } -export interface TokenColorDefinition { - $type: "color"; - $value: string | TokenColorValue; +interface TokenDefinitionBase { + $type: TokenType; + $value: TokenValue; $description?: string; } +export interface TokenOtherDefinition + extends TokenDefinitionBase {} + +export interface TokenColorDefinition + extends TokenDefinitionBase<"color", string | TokenColorValue> {} export interface TokenColorValue { /** * A string that specifies the color space or color model @@ -47,11 +67,8 @@ export interface TokenColorValue { } type ColorComponentValue = number | "none"; -export interface TokenDimensionDefinition { - $type: "dimension"; - $value: TokenDimensionValue; - $description?: string; -} +export interface TokenDimensionDefinition + extends TokenDefinitionBase<"dimension", TokenDimensionValue> {} export interface TokenDimensionValue { /** * An integer or floating-point value representing the numeric value. @@ -63,18 +80,12 @@ export interface TokenDimensionValue { unit: string; } -export interface TokenFontFamilyDefinition { - $type: "fontFamily"; - $value: TokenFontFamilyValue; - $description?: string; -} +export interface TokenFontFamilyDefinition + extends TokenDefinitionBase<"fontFamily", TokenFontFamilyValue> {} export type TokenFontFamilyValue = string | string[]; -export interface TokenFontWeightDefinition { - $type: "fontWeight"; - $value: TokenFontWeightValue; - $description?: string; -} +export interface TokenFontWeightDefinition + extends TokenDefinitionBase<"fontWeight", TokenFontWeightValue> {} export type TokenFontWeightValue = | number // 100 @@ -106,11 +117,8 @@ export type TokenFontWeightValue = | "extra-black" | "ultra-black"; -export interface TokenDurationDefinition { - $type: "duration"; - $value: TokenDurationValue; - $description?: string; -} +export interface TokenDurationDefinition + extends TokenDefinitionBase<"duration", TokenDurationValue> {} export interface TokenDurationValue { /** * An integer or floating-point value representing the numeric value. @@ -122,18 +130,12 @@ export interface TokenDurationValue { unit: string; } -export interface TokenCubicBezierDefinition { - $type: "cubicBezier"; - $value: TokenCubicBezierValue; - $description?: string; -} +export interface TokenCubicBezierDefinition + extends TokenDefinitionBase<"cubicBezier", TokenCubicBezierValue> {} type TokenCubicBezierValue = [number, number, number, number]; -export interface TokenNumberDefinition { - $type: "number"; - $value: number; - $description?: string; -} +export interface TokenNumberDefinition + extends TokenDefinitionBase<"number", number> {} export type TokenValue = TokenLeafValue | TokenCompositeValue; export interface TokenCompositeValue { @@ -150,7 +152,7 @@ export type TokenPrimitiveValue = string | number | boolean | undefined; // export type TokenReferencedValue = `${string}{${string}}${string}`; export type ResolveTheme = { - [K in keyof T]: T[K] extends ThemeValue + -readonly [K in keyof T]: T[K] extends ThemeValue ? ResolveThemeValue : T[K] extends Theme ? Resolve> @@ -159,13 +161,15 @@ export type ResolveTheme = { export type ResolveThemeValue = T extends TokenDefinition ? ResolveTokenValue - : T extends TokenPrimitiveValue + : T extends TokenAllDefinition ? PureCSSVarFunction - : T extends TokenPrimitiveValue[] - ? PureCSSVarFunction[] - : T extends TokenCompositeValue - ? ResolveCompositeValue - : never; + : T extends TokenPrimitiveValue + ? PureCSSVarFunction + : T extends TokenPrimitiveValue[] + ? PureCSSVarFunction[] + : T extends TokenCompositeValue + ? ResolveCompositeValue + : never; export type ResolveTokenValue = T extends TokenCompositeValue @@ -270,17 +274,79 @@ if (import.meta.vitest) { }); }); + it("matches specialized token definition variants", () => { + const colorValue: TokenColorValue = { + colorSpace: "srgb", + components: [255, 0, 0], + alpha: 1, + hex: "#ff0000" + }; + const colorDefinition = { + $type: "color", + $value: colorValue, + $description: "red" + } as const satisfies TokenColorDefinition; + + const dimensionValue: TokenDimensionValue = { value: 4, unit: "px" }; + const dimensionDefinition = { + $type: "dimension", + $value: dimensionValue + } as const satisfies TokenDimensionDefinition; + + const fontFamilyDefinition = { + $type: "fontFamily", + $value: ["Inter", "sans-serif"] + } as const satisfies TokenFontFamilyDefinition; + + const fontWeightDefinition = { + $type: "fontWeight", + $value: "semi-bold" + } as const satisfies TokenFontWeightDefinition; + + const durationValue: TokenDurationValue = { value: 250, unit: "ms" }; + const durationDefinition = { + $type: "duration", + $value: durationValue + } as const satisfies TokenDurationDefinition; + + const cubicBezierDefinition = { + $type: "cubicBezier", + $value: [0.25, 0.1, 0.25, 1] + } as const satisfies TokenCubicBezierDefinition; + + const numberDefinition = { + $type: "number", + $value: 1.618 + } as const satisfies TokenNumberDefinition; + + expectTypeOf(colorDefinition).toExtend(); + expectTypeOf(dimensionDefinition).toExtend(); + expectTypeOf(fontFamilyDefinition).toExtend(); + expectTypeOf(fontWeightDefinition).toExtend(); + expectTypeOf(durationDefinition).toExtend(); + expectTypeOf(cubicBezierDefinition).toExtend(); + expectTypeOf(numberDefinition).toExtend(); + }); + it("Theme Type", () => { const myTheme = compositeValue({ color: { base: { - red: "#ff0000", + red: { + $type: "color", + $value: { + colorSpace: "srgb", + components: [255, 0, 0], + alpha: 1, + hex: "#ff0000" + } + }, green: "#00ff00", blue: "#0000ff" }, semantic: { - primary: "{color.base.blue}", - secondary: "{color.base.green}", + primary: "#0000ff", + secondary: "#00ff00", get error() { return this.color.base.red; } diff --git a/packages/css/src/utils.ts b/packages/css/src/utils.ts index e965a4d8..c429207b 100644 --- a/packages/css/src/utils.ts +++ b/packages/css/src/utils.ts @@ -2,7 +2,7 @@ import { createVar } from "@vanilla-extract/css"; import { setFileScope } from "@vanilla-extract/css/fileScope"; import type { PureCSSVarKey } from "@mincho-js/transform-to-vanilla"; -export function className(...debugIds: Array) { +export function identifierName(...debugIds: Array) { const hashRegex = "[a-zA-Z0-9]+"; const classStr = debugIds .map((id) => (id === undefined ? hashRegex : `${id}__${hashRegex}`)) @@ -63,10 +63,10 @@ if (import.meta.vitest) { it("createVar", () => { expect(getVarName(createVar("my-var-name"))).toMatch( - className(`--my-var-name`) + identifierName(`--my-var-name`) ); expect(getVarName(createVar("myCss-var-name23"))).toMatch( - className(`--myCss-var-name23`) + identifierName(`--myCss-var-name23`) ); }); });