From 9aa78a536b35e8c7832e99d760b3aed024ffc162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20Schr=C3=B6dl?= Date: Thu, 22 Oct 2026 17:32:52 +0200 Subject: [PATCH] :sparkles: Resolve tokens with the tokenscript-interpreter package (#12348) Replace the @penpot/tokenscript npm package (the TypeScript interpreter) with penpot/tokenscript-interpreter, a Clojure and ClojureScript interpreter that now lives in its own repository and compiles into the frontend build. The frontend adapter (app.main.data.tokenscript) resolves tokens with it behind the existing :tokenscript flag and maps its results to the values and errors the workspace uses: - Token values are validated by the interpreter's token types, passed by their DTCG names: `1foo` is not a dimension, a number has no unit, an opacity is between 0 and 1. Penpot only adds its own limits (font weight names, text decoration keywords). - Division by zero, incompatible units, unknown functions and function arguments out of range get messages of their own, and token pills show the message of their error. - Font family names are quoted before resolving, as in the adapter this replaces (#11750): `Red Hat` stays one name. The token form's value input passed the token name to the tokenscript resolver in place of the typed value, and no name at all for a new token. It now resolves the typed value under the form's token name, as the StyleDictionary path does. StyleDictionary stays the default until the interpreter is stable. frontend/deps.edn pins the interpreter by tag and commit, v0.1.0. Signed-off-by: Florian Schroedl --- common/src/app/common/types/token.cljc | 8 + frontend/deps.edn | 5 + frontend/package.json | 1 - frontend/packages/tokenscript/.gitignore | 3 - frontend/packages/tokenscript/index.js | 2 - frontend/packages/tokenscript/package.json | 13 - frontend/packages/tokenscript/schemas.js | 1631 ----------------- frontend/pnpm-lock.yaml | 68 - .../src/app/main/data/style_dictionary.cljs | 9 +- frontend/src/app/main/data/tokenscript.cljs | 484 +++-- .../data/workspace/tokens/application.cljs | 3 - .../app/main/data/workspace/tokens/color.cljs | 11 +- .../main/data/workspace/tokens/errors.cljs | 16 + .../main/data/workspace/tokens/format.cljs | 36 +- .../data/workspace/tokens/propagation.cljs | 5 +- .../forms/controls/color_input.cljs | 5 +- .../management/forms/controls/combobox.cljs | 5 +- .../forms/controls/fonts_combobox.cljs | 5 +- .../management/forms/controls/input.cljs | 32 +- .../tokens/management/forms/validators.cljs | 5 +- .../tokens/management/token_pill.cljs | 26 +- frontend/src/app/plugins/tokens.cljs | 41 +- frontend/test/frontend_tests/runner.cljs | 2 + .../tokens/tokenscript_test.cljs | 184 ++ frontend/translations/en.po | 16 + frontend/translations/es.po | 16 + 26 files changed, 619 insertions(+), 2013 deletions(-) delete mode 100644 frontend/packages/tokenscript/.gitignore delete mode 100644 frontend/packages/tokenscript/index.js delete mode 100644 frontend/packages/tokenscript/package.json delete mode 100644 frontend/packages/tokenscript/schemas.js create mode 100644 frontend/test/frontend_tests/tokens/tokenscript_test.cljs diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index fd7a96e55f..704fc55083 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -664,6 +664,14 @@ (when (contains? text-decoration-values normalized-value) normalized-value))) +(def text-case-values #{"none" "uppercase" "lowercase" "capitalize"}) + +(defn number-out-of-bounds? + "Whether a resolved numeric token value is outside the safe integer + range." + [n] + (or (>= n sm/max-safe-int) (<= n sm/min-safe-int))) + (def font-weight-aliases {"100" #{"thin" "hairline"}, "200" #{"ultra light" "extralight" "extraleicht" "extra-light" "ultra-light" "ultralight" "extra light"}, diff --git a/frontend/deps.edn b/frontend/deps.edn index c5184e43ae..7e19542857 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -30,6 +30,11 @@ :git/sha "d726717" :git/url "https://github.com/funcool/rumext.git"} + penpot/tokenscript-interpreter + {:git/tag "v0.1.0" + :git/sha "224547d" + :git/url "https://github.com/penpot/tokenscript-interpreter.git"} + instaparse/instaparse {:mvn/version "1.5.0"} garden/garden {:git/url "https://github.com/noprompt/garden" :git/sha "05590ecb5f6fa670856f3d1ab400aa4961047480"} diff --git a/frontend/package.json b/frontend/package.json index e470580a87..f8adeafd03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,7 +56,6 @@ "@penpot/plugins-runtime": "link:../plugins/libs/plugins-runtime", "@penpot/svgo": "penpot/svgo#3.3.0", "@penpot/text-editor": "link:text-editor", - "@penpot/tokenscript": "link:packages/tokenscript", "@penpot/ua-parser": "penpot/ua-parser#1.0.0", "@playwright/test": "1.63.0", "@storybook/addon-docs": "10.6.1", diff --git a/frontend/packages/tokenscript/.gitignore b/frontend/packages/tokenscript/.gitignore deleted file mode 100644 index 0db2a5c1d2..0000000000 --- a/frontend/packages/tokenscript/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -*.sublime-workspace -/.yarn \ No newline at end of file diff --git a/frontend/packages/tokenscript/index.js b/frontend/packages/tokenscript/index.js deleted file mode 100644 index 4d7deb9d72..0000000000 --- a/frontend/packages/tokenscript/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./schemas.js"; -export * from "@tokens-studio/tokenscript-interpreter"; diff --git a/frontend/packages/tokenscript/package.json b/frontend/packages/tokenscript/package.json deleted file mode 100644 index 8cafca6749..0000000000 --- a/frontend/packages/tokenscript/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@penpot/tokenscript", - "version": "1.0.0", - "description": "", - "main": "index.js", - "type": "module", - "packageManager": "pnpm@12.14.0+sha512.c18ba6d87993d3d164cb5e8948f32b2c06ee854487e06cd8d86781342fb3e5bb4634367034711e86802baa9c37bea906ce1929ad08d70884c97d801df13389a6", - "author": "Andrey Antukh", - "license": "MPL-2.0", - "dependencies": { - "@tokens-studio/tokenscript-interpreter": "^0.26.0" - } -} diff --git a/frontend/packages/tokenscript/schemas.js b/frontend/packages/tokenscript/schemas.js deleted file mode 100644 index 75ead35ce7..0000000000 --- a/frontend/packages/tokenscript/schemas.js +++ /dev/null @@ -1,1631 +0,0 @@ -// Auto-generated by @tokens-studio/tokenscript-schemas -// Version: @tokens-studio/tokenscript-schemas@v0.4.0 -// GitHub: https://github.com/tokens-studio/tokenscript-schemas -// Command: npx @tokens-studio/tokenscript-schemas bundle preset:css preset:cssColors --output ./schemas.js -// Generated: 2026-02-11T08:46:40.467Z - -import { Config } from "@tokens-studio/tokenscript-interpreter"; - -export const SCHEMAS = [ - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - schema: { - "name": "Hex", - "type": "color", - "description": "A color in hex format, e.g. #ff0000", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string" - } - } - }, - "initializers": [ - { - "title": "Hex Color Initializer", - "keyword": "hex", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "variable c: Color.Hex;\nc.value = {input};\nreturn c;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Hex Conversion\n// Converts sRGB (0-1) to hexadecimal string format\n//\n// Examples:\n// sRGB(1, 0, 0) → #ff0000\n// sRGB(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel\nvalue = round({input}.r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = round({input}.g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = round({input}.b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display P3 to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display P3 to Hex Conversion\n// Converts P3 (0-1) to hexadecimal string format\n// Note: P3 colors may be out of sRGB gamut, values are clamped to 0-1\n//\n// Examples:\n// P3(1, 0, 0) → #ff0000\n// P3(0, 1, 0.5) → #00ff80\n\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red channel (clamp P3 to sRGB range)\nvalue = {input}.r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green channel\nvalue = {input}.g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue channel\nvalue = {input}.b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to Hex format", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to Hex Conversion\n// Converts HSL to hexadecimal string format\n// Reference: Standard HSL to RGB algorithm\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Hex string #rrggbb\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// RGB values (default to achromatic)\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation\nif (s > 0) [\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n\n variable p: Number = 2 * l - q;\n\n // Red (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n\n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n\n // Green (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n\n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n\n // Blue (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n\n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Convert RGB to hex\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red\nvalue = round(r * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = round(g * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = round(b * 255);\nif (value < 0) [ value = 0; ];\nif (value > 255) [ value = 255; ];\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to Hex format (clamps to sRGB gamut)", - "lossless": false, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to Hex Conversion\n// Converts OKLCH perceptual color to hexadecimal string format\n// Path: OKLCH → OKLab → XYZ-D65 → Linear sRGB → sRGB → Hex\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360)\n// Output: Hex string #rrggbb\n\n// Get input OKLCH values\nvariable ok_l: Number = {input}.l;\nvariable ok_c: Number = {input}.c;\nvariable ok_h: Number = {input}.h;\n\n// === Step 1: OKLCH to OKLab (polar to cartesian) ===\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\nvariable h_rad: Number = ok_h * deg_to_rad;\n\nvariable lab_a: Number = ok_c * cos(h_rad);\nvariable lab_b: Number = ok_c * sin(h_rad);\n\n// === Step 2: OKLab to XYZ-D65 ===\n// Inverse Lab-to-LMS matrix\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * lab_a + 0.2158037573099136 * lab_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * lab_a + -0.0638541728258133 * lab_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * lab_a + -1.2914855480194092 * lab_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse LMS-to-XYZ matrix\nvariable xyz_x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable xyz_y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable xyz_z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// === Step 3: XYZ-D65 to Linear sRGB ===\nvariable linear_r: Number = 3.2409699419045226 * xyz_x + -1.537383177570094 * xyz_y + -0.4986107602930034 * xyz_z;\nvariable linear_g: Number = -0.9692436362808796 * xyz_x + 1.8759675015077202 * xyz_y + 0.04155505740717559 * xyz_z;\nvariable linear_b: Number = 0.05563007969699366 * xyz_x + -0.20397695888897652 * xyz_y + 1.0569715142428786 * xyz_z;\n\n// === Step 4: Linear sRGB to sRGB (gamma correction) ===\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exp: Number = 0.416666666666667;\n\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n if (linear_r > 0) [\n srgb_r = gamma_scale * pow(linear_r, gamma_exp) - gamma_offset;\n ] else [\n srgb_r = 0;\n ];\n];\n\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n if (linear_g > 0) [\n srgb_g = gamma_scale * pow(linear_g, gamma_exp) - gamma_offset;\n ] else [\n srgb_g = 0;\n ];\n];\n\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n if (linear_b > 0) [\n srgb_b = gamma_scale * pow(linear_b, gamma_exp) - gamma_offset;\n ] else [\n srgb_b = 0;\n ];\n];\n\n// === Step 5: sRGB to Hex ===\nvariable hex: String = \"#\";\nvariable value: Number = 0;\n\n// Red (clamp to 0-1)\nvalue = srgb_r;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Green\nvalue = srgb_g;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\n// Blue\nvalue = srgb_b;\nif (value < 0) [ value = 0; ];\nif (value > 1) [ value = 1; ];\nvalue = round(value * 255);\nif (value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n] else [\n hex = hex.concat(value.to_string(16));\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - schema: { - "name": "SRGB", - "type": "color", - "description": "sRGB color space with normalized 0-1 range. The standard color space for web and displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "sRGB Color Initializer", - "keyword": "srgb", - "description": "Creates an sRGB color from normalized 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB Color Initializer\n// Creates an sRGB color from normalized 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values in 0-1 range\n\nvariable color_values: List = {input};\nvariable output: Color.SRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to sRGB (0-1) by normalizing", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to sRGB Conversion\n// Converts RGB (0-255) to sRGB (0-1) by normalizing\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: Color.SRGB with r, g, b in 0-1 range\n// Lossless: Yes (simple division)\n\nvariable r_normalized: Number = {input}.r / 255;\nvariable g_normalized: Number = {input}.g / 255;\nvariable b_normalized: Number = {input}.b / 255;\n\nvariable output: Color.SRGB;\noutput.r = r_normalized;\noutput.g = g_normalized;\noutput.b = b_normalized;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to sRGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to sRGB Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. If saturation is 0, it's achromatic: R=G=B=L\n// 2. Otherwise use the HSL to RGB formula:\n// - Calculate intermediate values based on L\n// - Use hue to determine RGB components\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.SRGB with r, g, b in 0-1 range\n\n// Get input HSL values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable l: Number = {input}.l;\n\n// Normalize hue to 0-1 range\nvariable hue: Number = h / 360;\n\n// Output values\nvariable r: Number = l;\nvariable g: Number = l;\nvariable b: Number = l;\n\n// Only calculate if there's saturation (not achromatic)\nif (s > 0) [\n // Calculate intermediate value\n variable q: Number = 0;\n if (l < 0.5) [\n q = l * (1 + s);\n ] else [\n q = l + s - l * s;\n ];\n \n variable p: Number = 2 * l - q;\n \n // Helper function logic inlined for R (hue + 1/3)\n variable tr: Number = hue + 0.333333333333333;\n if (tr < 0) [ tr = tr + 1; ];\n if (tr > 1) [ tr = tr - 1; ];\n \n if (tr < 0.166666666666667) [\n r = p + (q - p) * 6 * tr;\n ] else [\n if (tr < 0.5) [\n r = q;\n ] else [\n if (tr < 0.666666666666667) [\n r = p + (q - p) * (0.666666666666667 - tr) * 6;\n ] else [\n r = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for G (hue)\n variable tg: Number = hue;\n if (tg < 0) [ tg = tg + 1; ];\n if (tg > 1) [ tg = tg - 1; ];\n \n if (tg < 0.166666666666667) [\n g = p + (q - p) * 6 * tg;\n ] else [\n if (tg < 0.5) [\n g = q;\n ] else [\n if (tg < 0.666666666666667) [\n g = p + (q - p) * (0.666666666666667 - tg) * 6;\n ] else [\n g = p;\n ];\n ];\n ];\n \n // Helper function logic inlined for B (hue - 1/3)\n variable tb: Number = hue - 0.333333333333333;\n if (tb < 0) [ tb = tb + 1; ];\n if (tb > 1) [ tb = tb - 1; ];\n \n if (tb < 0.166666666666667) [\n b = p + (q - p) * 6 * tb;\n ] else [\n if (tb < 0.5) [\n b = q;\n ] else [\n if (tb < 0.666666666666667) [\n b = p + (q - p) * (0.666666666666667 - tb) * 6;\n ] else [\n b = p;\n ];\n ];\n ];\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = r;\noutput.g = g;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to sRGB by applying gamma correction", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to sRGB Conversion\n// Applies gamma correction (transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if linear ≤ 0.0031308: srgb = linear * 12.92\n// else: srgb = 1.055 * linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.SRGB with r, g, b in gamma-corrected 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 0.416666666666667;\n\n// Get input linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable srgb_r: Number = 0;\nif (linear_r <= threshold) [\n srgb_r = linear_r * linear_scale;\n] else [\n srgb_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable srgb_g: Number = 0;\nif (linear_g <= threshold) [\n srgb_g = linear_g * linear_scale;\n] else [\n srgb_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable srgb_b: Number = 0;\nif (linear_b <= threshold) [\n srgb_b = linear_b * linear_scale;\n] else [\n srgb_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.SRGB;\noutput.r = srgb_r;\noutput.g = srgb_g;\noutput.b = srgb_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - schema: { - "name": "Rgb", - "type": "color", - "description": "RGB color", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number" - }, - "g": { - "type": "number" - }, - "b": { - "type": "number" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "function", - "keyword": "rgb", - "description": "Creates an RGB color", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB Color Initializer\n// Creates an RGB color from r, g, b values (0-255 range)\n//\n// Usage: rgb(255, 128, 64) → Color.Rgb { r: 255, g: 128, b: 64 }\n// rgb(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 3 or 4 numbers [r, g, b] or [r, g, b, alpha]\n// Output: Color.Rgb\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_parts.length() > 3) [\n output.alpha = color_parts.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "function", - "keyword": "rgba", - "description": "Creates an RGB color with alpha", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGBA Color Initializer\n// Creates an RGB color with alpha from r, g, b, a values\n//\n// Usage: rgba(255, 128, 64, 0.5) → Color.Rgb { r: 255, g: 128, b: 64, alpha: 0.5 }\n//\n// Input: List of 4 numbers [r, g, b, alpha]\n// Output: Color.Rgb with alpha\n\nvariable color_parts: List = {input}; \n\nvariable output: Color.Rgb;\noutput.r = color_parts.get(0);\noutput.g = color_parts.get(1);\noutput.b = color_parts.get(2);\noutput.alpha = color_parts.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "target": "$self", - "description": "Converts HEX to RGB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Hex to RGB Conversion\n// Converts hexadecimal color string to RGB (0-255 range)\n//\n// Supports both shorthand (#RGB) and standard (#RRGGBB) formats:\n// #f00 → RGB(255, 0, 0)\n// #ff0000 → RGB(255, 0, 0)\n//\n// Algorithm:\n// 1. Remove '#' prefix and split into characters\n// 2. For shorthand: duplicate each digit (f → ff)\n// 3. Parse hex pairs to decimal values\n//\n// Input: Color.Hex (e.g., #ff5733)\n// Output: Color.Rgb with r, g, b in 0-255 range\n\n// Split hex string on '#' and get the color value\nvariable color_parts: List = {input}.to_string().split('#'); \nvariable color: List = color_parts.get(1).split(); \nvariable length: Number = color.length(); \n\n// Initialize RGB values\nvariable rgb: List = 0, 0, 0; \n\n// Handle shorthand (#RGB) vs standard (#RRGGBB) format\nif(length == 3) [ \n // Shorthand: duplicate each digit\n rgb.update(0, parse_int(color.get(0).concat(color.get(0)), 16)); \n rgb.update(1, parse_int(color.get(1).concat(color.get(1)), 16)); \n rgb.update(2, parse_int(color.get(2).concat(color.get(2)), 16)); \n] else [ \n // Standard: pair adjacent digits\n rgb.update(0, parse_int(color.get(0).concat(color.get(1)), 16)); \n rgb.update(1, parse_int(color.get(2).concat(color.get(3)), 16)); \n rgb.update(2, parse_int(color.get(4).concat(color.get(5)), 16)); \n]; \n\n// Create output color\nvariable output: Color.Rgb; \noutput.r = rgb.get(0); \noutput.g = rgb.get(1); \noutput.b = rgb.get(2); \n\nreturn output;" - } - }, - { - "source": "$self", - "target": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hex-color/0/", - "description": "Converts RGB to HEX", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to Hex Conversion\n// Converts RGB (0-255) color to hexadecimal string format\n//\n// Examples:\n// RGB(255, 0, 0) → #ff0000\n// RGB(0, 255, 128) → #00ff80\n//\n// Algorithm:\n// 1. Round each channel to nearest integer\n// 2. Convert to base-16 string\n// 3. Pad single digits with leading zero\n// 4. Concatenate with '#' prefix\n//\n// Input: Color.Rgb with r, g, b in 0-255 range\n// Output: String (hex format #rrggbb)\n\nvariable rgba: List = {input}.r, {input}.g, {input}.b;\nvariable hex: String = \"#\";\nvariable i: Number = 0;\nvariable value: Number = 0;\n\n// Convert each RGB channel to hex\nwhile( i < min(rgba.length(), 3)) [\n value = round(rgba.get(i));\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n i = i + 1;\n];\n\nif (rgba.length() == 4) [\n value = rgba.get(3) * 255; // Convert alpha to 0-255 range\n if(value < 16) [\n hex = hex.concat(\"0\").concat(value.to_string(16));\n ] else [\n hex = hex.concat(value.to_string(16));\n ];\n];\n\nreturn hex;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - schema: { - "name": "HSL", - "type": "color", - "description": "HSL color space - Hue, Saturation, Lightness. Popular for color pickers and CSS.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "l": { - "type": "number", - "description": "Lightness (0-1)" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSL Color Initializer", - "keyword": "hsl", - "description": "Creates an HSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL Color Initializer\n// Creates an HSL color from H, S, L values\n// Input: List of [h, s, l] or [h, s, l, alpha] values\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsl_values.length() > 3) [\n output.alpha = hsl_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HSLA Color Initializer", - "keyword": "hsla", - "description": "Creates an HSL color with alpha from H, S, L, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSLA Color Initializer\n// Creates an HSL color with alpha from H, S, L, A values\n//\n// Usage: hsla(180, 0.5, 0.5, 0.8) → Color.HSL { h: 180, s: 0.5, l: 0.5, alpha: 0.8 }\n//\n// Input: List of 4 numbers [h, s, l, alpha]\n// Output: Color.HSL with alpha\n\nvariable hsl_values: List = {input};\nvariable output: Color.HSL;\n\noutput.h = hsl_values.get(0);\noutput.s = hsl_values.get(1);\noutput.l = hsl_values.get(2);\noutput.alpha = hsl_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSL", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSL Conversion\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/hsl.js\n//\n// Algorithm:\n// 1. Find max and min of R, G, B\n// 2. L = (max + min) / 2\n// 3. If max == min, S = 0 (achromatic)\n// 4. Else S = (max - min) / (1 - |2L - 1|)\n// 5. H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSL with h (0-360), s (0-1), l (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Calculate lightness\nvariable l: Number = (max_val + min_val) / 2;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (delta > 0) [\n // Not achromatic\n \n // Saturation formula\n variable abs_2l_minus_1: Number = 2 * l - 1;\n if (abs_2l_minus_1 < 0) [\n abs_2l_minus_1 = 0 - abs_2l_minus_1;\n ];\n s = delta / (1 - abs_2l_minus_1);\n \n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - schema: { - "name": "LinearSRGB", - "type": "color", - "description": "Linear sRGB color space (gamma-decoded). Used for matrix transformations to XYZ and other linear operations.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear sRGB Color Initializer", - "keyword": "linearsrgb", - "description": "Creates a linear sRGB color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB Color Initializer\n// Creates a linear sRGB color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearSRGB;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to Linear sRGB by removing gamma correction (IEC 61966-2-1)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to Linear sRGB Conversion\n// Removes gamma correction (inverse transfer function)\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Algorithm:\n// if srgb ≤ 0.04045: linear = srgb / 12.92\n// else: linear = ((srgb + 0.055) / 1.055) ^ 2.4\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Gamma correction constants (IEC 61966-2-1)\nvariable threshold: Number = 0.04045;\nvariable linear_scale: Number = 12.92;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_exponent: Number = 2.4;\n\n// Get input sRGB values\nvariable srgb_r: Number = {input}.r;\nvariable srgb_g: Number = {input}.g;\nvariable srgb_b: Number = {input}.b;\n\n// Convert red channel\nvariable linear_r: Number = 0;\nif (srgb_r <= threshold) [\n linear_r = srgb_r / linear_scale;\n] else [\n linear_r = pow((srgb_r + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert green channel\nvariable linear_g: Number = 0;\nif (srgb_g <= threshold) [\n linear_g = srgb_g / linear_scale;\n] else [\n linear_g = pow((srgb_g + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Convert blue channel\nvariable linear_b: Number = 0;\nif (srgb_b <= threshold) [\n linear_b = srgb_b / linear_scale;\n] else [\n linear_b = pow((srgb_b + gamma_offset) / gamma_scale, gamma_exponent);\n];\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear sRGB using inverse transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear sRGB Conversion\n// Reference: ColorJS exact matrix values (extracted via unit vector conversion)\n//\n// Matrix (XYZ D65 to Linear sRGB):\n// [ 3.2409699419045226, -1.537383177570094, -0.4986107602930034 ]\n// [-0.9692436362808796, 1.8759675015077202, 0.04155505740717559]\n// [ 0.05563007969699366, -0.20397695888897652, 1.0569715142428786]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearSRGB with r, g, b in linear 0-1 range\n\n// Get input XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Apply inverse matrix transformation (ColorJS exact values)\n// Row 1: R\nvariable linear_r: Number = 3.2409699419045226 * x + -1.537383177570094 * y + -0.4986107602930034 * z;\n\n// Row 2: G\nvariable linear_g: Number = -0.9692436362808796 * x + 1.8759675015077202 * y + 0.04155505740717559 * z;\n\n// Row 3: B\nvariable linear_b: Number = 0.05563007969699366 * x + -0.20397695888897652 * y + 1.0569715142428786 * z;\n\n// Create output\nvariable output: Color.LinearSRGB;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - schema: { - "name": "XYZD65", - "type": "color", - "description": "CIE XYZ color space with D65 white point. The primary connection hub for color space conversions.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D65 Color Initializer", - "keyword": "xyzd65", - "description": "Creates an XYZ-D65 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 Color Initializer\n// Creates an XYZ-D65 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD65;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to XYZ-D65 using the sRGB transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to XYZ-D65 Conversion\n// Uses the sRGB primaries transformation matrix\n// Reference: IEC 61966-2-1:1999 (sRGB specification)\n//\n// Matrix (Linear RGB → XYZ-D65):\n// [X] [0.4123908 0.3575843 0.1804808] [R]\n// [Y] = [0.2126390 0.7151687 0.0721923] [G]\n// [Z] [0.0193308 0.1191948 0.9505322] [B]\n//\n// Input: Color.LinearSRGB with r, g, b in linear 0-1 range\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input linear sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Matrix multiplication: M × [R, G, B]ᵀ\n// Row 1: X = 0.4123908 * R + 0.3575843 * G + 0.1804808 * B\nvariable x: Number = r * 0.41239079926595934 + g * 0.357584339383878 + b * 0.1804807884018343;\n\n// Row 2: Y = 0.2126390 * R + 0.7151687 * G + 0.0721923 * B\nvariable y: Number = r * 0.21263900587151027 + g * 0.715168678767756 + b * 0.07219231536073371;\n\n// Row 3: Z = 0.0193308 * R + 0.1191948 * G + 0.9505322 * B\nvariable z: Number = r * 0.01933081871559182 + g * 0.11919477979462598 + b * 0.9505321522496607;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to XYZ-D65 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to XYZ-D65 Conversion\n// Converts linear Display-P3 RGB to CIE XYZ (D65)\n// Reference: CSS Color Level 4 specification\n//\n// Uses the P3 to XYZ-D65 transformation matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get linear P3 values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Linear P3 to XYZ-D65 matrix\n// Row 1\nvariable m00: Number = 0.4865709486482162;\nvariable m01: Number = 0.26566769316909306;\nvariable m02: Number = 0.1982172852343625;\n\n// Row 2\nvariable m10: Number = 0.2289745640697488;\nvariable m11: Number = 0.6917385218365064;\nvariable m12: Number = 0.079286914093745;\n\n// Row 3\nvariable m20: Number = 0.0;\nvariable m21: Number = 0.04511338185890264;\nvariable m22: Number = 1.043944368900976;\n\n// Matrix multiplication: [x, y, z] = M × [r, g, b]\nvariable x: Number = m00 * r + m01 * g + m02 * b;\nvariable y: Number = m10 * r + m11 * g + m12 * b;\nvariable z: Number = m20 * r + m21 * g + m22 * b;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to XYZ-D65 using inverse LMS transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to XYZ-D65 Conversion\n// Reference: ColorJS oklab.js (inverse transformation)\n//\n// Algorithm:\n// 1. Apply inverse Lab-to-LMS matrix to get LMS cone responses\n// 2. Cube the LMS values (inverse of cube root)\n// 3. Apply inverse LMS-to-XYZ matrix\n//\n// Input: Color.OKLab with l, a, b perceptual coordinates\n// Output: Color.XYZD65 with x, y, z tristimulus values\n\n// Get input OKLab values\nvariable ok_l: Number = {input}.l;\nvariable ok_a: Number = {input}.a;\nvariable ok_b: Number = {input}.b;\n\n// Inverse LMStoLab_M matrix (Lab to LMS')\n// These are the inverse of the matrix used in from-xyz-d65.tokenscript\nvariable lms_l: Number = 1.0 * ok_l + 0.3963377773761749 * ok_a + 0.2158037573099136 * ok_b;\nvariable lms_m: Number = 1.0 * ok_l + -0.1055613458156586 * ok_a + -0.0638541728258133 * ok_b;\nvariable lms_s: Number = 1.0 * ok_l + -0.0894841775298119 * ok_a + -1.2914855480194092 * ok_b;\n\n// Cube the values (inverse of cube root)\nvariable lms_l_cubed: Number = lms_l * lms_l * lms_l;\nvariable lms_m_cubed: Number = lms_m * lms_m * lms_m;\nvariable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n\n// Inverse XYZtoLMS_M matrix (LMS to XYZ)\n// From ColorJS oklab.js\nvariable x: Number = 1.2268798758459243 * lms_l_cubed + -0.5578149944602171 * lms_m_cubed + 0.2813910456659647 * lms_s_cubed;\nvariable y: Number = -0.0405757452148008 * lms_l_cubed + 1.1122868032803170 * lms_m_cubed + -0.0717110580655164 * lms_s_cubed;\nvariable z: Number = -0.0763729366746601 * lms_l_cubed + -0.4214933324022432 * lms_m_cubed + 1.5869240198367816 * lms_s_cubed;\n\n// Create output\nvariable output: Color.XYZD65;\noutput.x = x;\noutput.y = y;\noutput.z = z;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - schema: { - "name": "LinearP3", - "type": "color", - "description": "Linear Display-P3 color space (gamma-decoded). Used for matrix transformations to XYZ.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Linear red channel (0-1, can exceed for HDR)" - }, - "g": { - "type": "number", - "description": "Linear green channel (0-1, can exceed for HDR)" - }, - "b": { - "type": "number", - "description": "Linear blue channel (0-1, can exceed for HDR)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Linear P3 Color Initializer", - "keyword": "linearp3", - "description": "Creates a linear Display-P3 color from linear 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 Color Initializer\n// Creates a linear Display-P3 color from linear 0-1 values\n// Input: List of [r, g, b] linear values\n\nvariable color_values: List = {input};\nvariable output: Color.LinearP3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to Linear P3 using the P3 transformation matrix", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to Linear P3 Conversion\n// Converts CIE XYZ (D65) to linear Display-P3 RGB\n// Reference: CSS Color Level 4 specification\n//\n// Uses the inverse of the P3 to XYZ-D65 matrix\n// Matrix values from ColorJS / CSS Color Level 4\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values\n// Output: Color.LinearP3 with linear r, g, b values (may be outside 0-1 for out-of-gamut)\n\n// Get XYZ values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// XYZ to Linear P3 matrix (inverse of P3 to XYZ)\n// Row 1\nvariable m00: Number = 2.4934969119414254;\nvariable m01: Number = -0.9313836179191239;\nvariable m02: Number = -0.40271078445071684;\n\n// Row 2\nvariable m10: Number = -0.8294889695615747;\nvariable m11: Number = 1.7626640603183463;\nvariable m12: Number = 0.023624685841943577;\n\n// Row 3\nvariable m20: Number = 0.03584583024378447;\nvariable m21: Number = -0.07617238926804182;\nvariable m22: Number = 0.9568845240076872;\n\n// Matrix multiplication: [r, g, b] = M × [x, y, z]\nvariable linear_r: Number = m00 * x + m01 * y + m02 * z;\nvariable linear_g: Number = m10 * x + m11 * y + m12 * z;\nvariable linear_b: Number = m20 * x + m21 * y + m22 * z;\n\n// Create output (note: values may be outside 0-1 for out-of-gamut colors)\nvariable output: Color.LinearP3;\noutput.r = linear_r;\noutput.g = linear_g;\noutput.b = linear_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - schema: { - "name": "OKLab", - "type": "color", - "description": "OKLab perceptually uniform color space by Björn Ottosson. L is lightness (0-1), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -0.4 to 0.4)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -0.4 to 0.4)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLab Color Initializer", - "keyword": "oklab", - "description": "Creates an OKLab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab Color Initializer\n// Creates an OKLab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.OKLab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to OKLab using Björn Ottosson's algorithm", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to OKLab Conversion\n// Uses ColorJS-compatible matrices (recalculated for consistent D65 white)\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/oklab.js\n//\n// Algorithm:\n// 1. XYZ-D65 → LMS (M1 matrix)\n// 2. LMS → LMS' (cube root transformation)\n// 3. LMS' → OKLab (M2 matrix)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input XYZ-D65 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// Step 1: XYZ-D65 → LMS cone response\n// ColorJS XYZtoLMS_M matrix (recalculated for consistent reference white)\nvariable lms_l: Number = x * 0.8190224379967030 + y * 0.3619062600528904 + z * -0.1288737815209879;\nvariable lms_m: Number = x * 0.0329836539323885 + y * 0.9292868615863434 + z * 0.0361446663506424;\nvariable lms_s: Number = x * 0.0481771893596242 + y * 0.2642395317527308 + z * 0.6335478284694309;\n\n// Step 2: Apply cube root (γ = 1/3)\n// Note: Using pow with 1/3 exponent (cbrt equivalent for positive values)\nvariable cube_root_exp: Number = 0.3333333333333333;\nvariable lms_l_prime: Number = pow(lms_l, cube_root_exp);\nvariable lms_m_prime: Number = pow(lms_m, cube_root_exp);\nvariable lms_s_prime: Number = pow(lms_s, cube_root_exp);\n\n// Step 3: LMS' → OKLab\n// ColorJS LMStoLab_M matrix\nvariable oklab_l: Number = lms_l_prime * 0.2104542683093140 + lms_m_prime * 0.7936177747023054 + lms_s_prime * -0.0040720430116193;\nvariable oklab_a: Number = lms_l_prime * 1.9779985324311684 + lms_m_prime * -2.4285922420485799 + lms_s_prime * 0.4505937096174110;\nvariable oklab_b: Number = lms_l_prime * 0.0259040424655478 + lms_m_prime * 0.7827717124575296 + lms_s_prime * -0.8086757549230774;\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = oklab_l;\noutput.a = oklab_a;\noutput.b = oklab_b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to OKLab (polar to cartesian)", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to OKLab Conversion\n// Converts polar (C, H) back to cartesian (a, b) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// a = C * cos(H * π/180)\n// b = C * sin(H * π/180)\n//\n// Input: Color.OKLCH with l, c, h coordinates\n// Output: Color.OKLab with l, a, b coordinates\n\n// Get input OKLCH values\nvariable l: Number = {input}.l;\nvariable c: Number = {input}.c;\nvariable h: Number = {input}.h;\n\n// Constants\nvariable pi: Number = pi();\nvariable deg_to_rad: Number = pi / 180;\n\n// Convert hue to radians\nvariable h_rad: Number = h * deg_to_rad;\n\n// Convert polar to cartesian\nvariable a: Number = c * cos(h_rad);\nvariable b: Number = c * sin(h_rad);\n\n// Create output\nvariable output: Color.OKLab;\noutput.l = l;\noutput.a = a;\noutput.b = b;\n\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - "target": "$self", - "description": "Converts OKHSL to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n//\n// Converts OKHSL back to OKLab using:\n// 1. Inverse toe function to recover OKLab lightness\n// 2. Cusp finding to determine max chroma at this hue\n// 3. Saturation denormalization to get actual chroma\n//\n// Input: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Apply inverse toe function to get OKLab lightness\n// toe_inv(x) = (x^2 + k1*x) / (k3 * (x + k2))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = l;\nif (l > 0.0001 && l < 0.9999) [\n lab_l = (l * l + toe_k1 * l) / (toe_k3 * (l + toe_k2));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Handle achromatic case\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 4 and 5)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation at this hue (same as forward conversion)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 5)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Compute chroma from saturation\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n \n // Denormalize saturation to get chroma\n c = s * lab_l * s_t_cusp;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 7: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - "target": "$self", - "description": "Converts OKHSV to OKLab", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV to OKLab Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n//\n// Converts OKHSV back to OKLab using:\n// 1. Cusp finding to determine gamut boundary at this hue\n// 2. V and S denormalization to get L and C\n//\n// Input: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n// Output: Color.OKLab with l (0-1), a, b coordinates\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Handle edge cases\n// ═══════════════════════════════════════════════════════════════════════════\nvariable lab_l: Number = v;\nvariable lab_a: Number = 0;\nvariable lab_b: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Compute hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable h_rad: Number = h * pi_val / 180;\nvariable a_: Number = cos(h_rad);\nvariable b_: Number = sin(h_rad);\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in steps 3 and 4)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Find maximum saturation at this hue\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\n\n// Variables for LMS intermediate calculations (reused in step 4)\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001) [\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find cusp\n// ═══════════════════════════════════════════════════════════════════════════\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && s_max > 0) [\n // Recompute LMS values with refined s_max (kl, km, ks already computed)\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute L and C from V and S\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = 0;\n\nif (s >= 0.0001 && v >= 0.0001 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Compute max chroma at this V\n variable c_max_at_v: Number = 0;\n \n if (v <= l_cusp) [\n c_max_at_v = v * s_t_cusp;\n ] else [\n c_max_at_v = (1 - v) * t_t_cusp;\n ];\n \n // Compute actual chroma\n c = s * c_max_at_v;\n \n // Compute L from V and C\n // Reverse of: V = L + C / s_t_cusp\n lab_l = v - c / (s_t_cusp + 0.0001);\n if (lab_l < 0) [ lab_l = 0; ];\n if (lab_l > 1) [ lab_l = 1; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 6: Convert polar to cartesian\n// ═══════════════════════════════════════════════════════════════════════════\nif (s >= 0.0001 && v >= 0.0001) [\n lab_a = c * a_;\n lab_b = c * b_;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKLab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - schema: { - "name": "OKLCH", - "type": "color", - "description": "OKLCH color space - the polar form of OKLab. L is lightness (0-1), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-1)" - }, - "c": { - "type": "number", - "description": "Chroma (0 to ~0.4 for sRGB gamut)" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKLCH Color Initializer", - "keyword": "oklch", - "description": "Creates an OKLCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH Color Initializer\n// Creates an OKLCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.OKLCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKLCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKLCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Reference: Björn Ottosson's OKLab specification\n//\n// Algorithm:\n// L stays the same (lightness)\n// C = sqrt(a² + b²) (chroma - distance from neutral axis)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.OKLab with l, a, b coordinates\n// Output: Color.OKLCH with l, c, h coordinates\n\n// Get input OKLab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\n// atan2(y, x) returns angle in radians from -π to π\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.OKLCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsl-color/0/", - schema: { - "name": "OKHSL", - "type": "color", - "description": "OKHSL color space by Björn Ottosson - a perceptually uniform HSL based on OKLab. H is hue (0-360), S is saturation (0-1, normalized to sRGB gamut), L is lightness (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), normalized relative to sRGB gamut boundary" - }, - "l": { - "type": "number", - "description": "Lightness (0-1), perceptually uniform with toe function applied" - } - }, - "required": [ - "h", - "s", - "l" - ], - "order": [ - "h", - "s", - "l" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSL Color Initializer", - "keyword": "okhsl", - "description": "Creates an OKHSL color from H, S, L values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSL Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSL color from hue, saturation, and lightness values.\n// OKHSL is a perceptually uniform version of HSL built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), normalized to sRGB gamut boundary\n// - l: Lightness (0-1), perceptually uniform with toe function\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, l, and optional alpha properties\n// Output: Color.OKHSL\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable l: Number = input.l;\n\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSL using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSL Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsl.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSL provides a perceptually uniform HSL with proper saturation scaling.\n// The algorithm uses three key chroma values (C_0, C_mid, C_max) and\n// performs piecewise interpolation to map chroma to saturation.\n//\n// This implementation includes the FULL findGamutIntersection algorithm\n// with Halley's method refinement for accurate gamut boundary detection.\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSL with h (0-360), s (0-1), l (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\nvariable float_max: Number = 999999999;\n\n// Toe function constants\n// K3 = (1 + K1) / (1 + K2)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// LMS to OKLab matrix coefficients (LabtoLMS_M columns 1,2)\nvariable lab_lms_kl_a: Number = 0.3963377774;\nvariable lab_lms_kl_b: Number = 0.2158037573;\nvariable lab_lms_km_a: Number = -0.1055613458;\nvariable lab_lms_km_b: Number = -0.0638541728;\nvariable lab_lms_ks_a: Number = -0.0894841775;\nvariable lab_lms_ks_b: Number = -1.2914855480;\n\n// LMS to sRGB-linear matrix\nvariable lms_r0: Number = 4.0767416360759583;\nvariable lms_r1: Number = -3.3077115392580629;\nvariable lms_r2: Number = 0.2309699031821043;\nvariable lms_g0: Number = -1.2684379732850315;\nvariable lms_g1: Number = 2.6097573492876882;\nvariable lms_g2: Number = -0.3413193760026570;\nvariable lms_b0: Number = -0.0041960761386756;\nvariable lms_b1: Number = -0.7034186179359362;\nvariable lms_b2: Number = 1.7076146940746117;\n\n// RGB limit coefficients for determining which channel clips first\nvariable red_limit_a: Number = -1.8817031;\nvariable red_limit_b: Number = -0.80936501;\nvariable green_limit_a: Number = 1.8144408;\nvariable green_limit_b: Number = -1.19445267;\n\n// Red Kn coefficients\nvariable red_k0: Number = 1.19086277;\nvariable red_k1: Number = 1.76576728;\nvariable red_k2: Number = 0.59662641;\nvariable red_k3: Number = 0.75515197;\nvariable red_k4: Number = 0.56771245;\n\n// Green Kn coefficients\nvariable green_k0: Number = 0.73956515;\nvariable green_k1: Number = -0.45954404;\nvariable green_k2: Number = 0.08285427;\nvariable green_k3: Number = 0.12541073;\nvariable green_k4: Number = -0.14503204;\n\n// Blue Kn coefficients\nvariable blue_k0: Number = 1.35733652;\nvariable blue_k1: Number = -0.00915799;\nvariable blue_k2: Number = -1.1513021;\nvariable blue_k3: Number = -0.50559606;\nvariable blue_k4: Number = 0.00692167;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates and apply toe function\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\n\n// Apply toe function: toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\nvariable l: Number = lab_l;\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n l = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// Compute hue using atan2(-b, -a) + 0.5 (ColorJS convention)\nvariable h: Number = 0;\nif (c > 0.00001) [\n variable h_normalized: Number = 0.5 + atan2(-lab_b, -lab_a) / (2 * pi_val);\n h = h_normalized * 360;\n if (h < 0) [ h = h + 360; ];\n if (h >= 360) [ h = h - 360; ];\n];\n\n// Initialize saturation\nvariable s: Number = 0;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Check if chromatic (non-achromatic, non-edge)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable is_chromatic: Number = 1;\nif (c < 0.00001) [ is_chromatic = 0; ];\nif (l < 0.0001) [ is_chromatic = 0; ];\nif (l > 0.9999) [ is_chromatic = 0; ];\n\nif (is_chromatic > 0.5) [\n // ═══════════════════════════════════════════════════════════════════════\n // Step 3: Normalize hue direction (a_, b_)\n // ═══════════════════════════════════════════════════════════════════════\n variable a_: Number = lab_a / c;\n variable b_: Number = lab_b / c;\n \n // Pre-compute LMS coefficients\n variable kl: Number = lab_lms_kl_a * a_ + lab_lms_kl_b * b_;\n variable km: Number = lab_lms_km_a * a_ + lab_lms_km_b * b_;\n variable ks: Number = lab_lms_ks_a * a_ + lab_lms_ks_b * b_;\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 4: computeMaxSaturation - Find S_max for this hue\n // ═══════════════════════════════════════════════════════════════════════\n variable test_red: Number = red_limit_a * a_ + red_limit_b * b_;\n variable test_green: Number = green_limit_a * a_ + green_limit_b * b_;\n \n variable k0: Number = blue_k0;\n variable k1_coef: Number = blue_k1;\n variable k2_coef: Number = blue_k2;\n variable k3_coef: Number = blue_k3;\n variable k4_coef: Number = blue_k4;\n variable wl: Number = lms_b0;\n variable wm: Number = lms_b1;\n variable ws: Number = lms_b2;\n \n if (test_red > 1) [\n k0 = red_k0; k1_coef = red_k1; k2_coef = red_k2; k3_coef = red_k3; k4_coef = red_k4;\n wl = lms_r0; wm = lms_r1; ws = lms_r2;\n ] else [\n if (test_green > 1) [\n k0 = green_k0; k1_coef = green_k1; k2_coef = green_k2; k3_coef = green_k3; k4_coef = green_k4;\n wl = lms_g0; wm = lms_g1; ws = lms_g2;\n ];\n ];\n \n // Polynomial approximation\n variable s_max: Number = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement for s_max\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable halley_denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(halley_denom) > 0.000001) [\n s_max = s_max - f * f1 / halley_denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 5: findCusp - Get L_cusp and C_cusp\n // ═══════════════════════════════════════════════════════════════════════\n variable l_cusp: Number = 1;\n variable c_cusp: Number = 0;\n \n if (s_max > 0) [\n variable cusp_l_: Number = 1 + s_max * kl;\n variable cusp_m_: Number = 1 + s_max * km;\n variable cusp_s_: Number = 1 + s_max * ks;\n \n variable cusp_l: Number = cusp_l_ * cusp_l_ * cusp_l_;\n variable cusp_m: Number = cusp_m_ * cusp_m_ * cusp_m_;\n variable cusp_s: Number = cusp_s_ * cusp_s_ * cusp_s_;\n \n variable cusp_r: Number = lms_r0 * cusp_l + lms_r1 * cusp_m + lms_r2 * cusp_s;\n variable cusp_g: Number = lms_g0 * cusp_l + lms_g1 * cusp_m + lms_g2 * cusp_s;\n variable cusp_b: Number = lms_b0 * cusp_l + lms_b1 * cusp_m + lms_b2 * cusp_s;\n \n variable max_rgb: Number = cusp_r;\n if (cusp_g > max_rgb) [ max_rgb = cusp_g; ];\n if (cusp_b > max_rgb) [ max_rgb = cusp_b; ];\n if (max_rgb < 0.0001) [ max_rgb = 0.0001; ];\n \n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 6: findGamutIntersection - Find C_max at current L\n // This is the FULL algorithm with Halley's method for upper half\n //\n // Finds intersection of line: L = L0*(1-t) + t*L1, C = t*C1\n // where L0=L1=lab_l and C1=1 (seeking max chroma at this L)\n // ═══════════════════════════════════════════════════════════════════════\n variable c_max: Number = 0;\n \n if (l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Parameters for gamut intersection: l1=lab_l, c1=1, l0=lab_l\n variable l1: Number = lab_l;\n variable c1: Number = 1;\n variable l0: Number = lab_l;\n \n // Check which half: (l1 - l0) * c_cusp - (l_cusp - l0) * c1\n // Since l1 = l0 = lab_l, this simplifies to: -(l_cusp - lab_l) * 1 = lab_l - l_cusp\n variable half_test: Number = lab_l - l_cusp;\n variable t_intersect: Number = 0;\n \n if (half_test <= 0) [\n // Lower half (below cusp) - simple triangle intersection\n // t = (c_cusp * l0) / (c1 * l_cusp + c_cusp * (l0 - l1))\n // Since l0 = l1, denominator = c1 * l_cusp = l_cusp\n variable lower_denom: Number = l_cusp;\n if (abs(lower_denom) > 0.00001) [\n t_intersect = (c_cusp * lab_l) / lower_denom;\n ];\n ] else [\n // Upper half (above cusp) - triangle + Halley's method\n // First: triangle intersection\n // t = (c_cusp * (l0 - 1)) / (c1 * (l_cusp - 1) + c_cusp * (l0 - l1))\n // Since l0 = l1 = lab_l: t = (c_cusp * (lab_l - 1)) / (l_cusp - 1)\n variable upper_denom: Number = l_cusp - 1;\n if (abs(upper_denom) > 0.00001) [\n t_intersect = c_cusp * (lab_l - 1) / upper_denom;\n ];\n \n // Halley's method refinement for upper half\n // dl = l1 - l0 = 0, dc = c1 = 1\n variable dl: Number = 0;\n variable dc: Number = 1;\n \n variable ldt_: Number = dl + dc * kl;\n variable mdt_: Number = dl + dc * km;\n variable sdt_: Number = dl + dc * ks;\n \n // Compute L and C at current t\n variable L_at_t: Number = l0 * (1 - t_intersect) + t_intersect * l1;\n variable C_at_t: Number = t_intersect * c1;\n \n // LMS values at (L, C)\n variable l_at: Number = L_at_t + C_at_t * kl;\n variable m_at: Number = L_at_t + C_at_t * km;\n variable s_at: Number = L_at_t + C_at_t * ks;\n \n variable l_lms: Number = l_at * l_at * l_at;\n variable m_lms: Number = m_at * m_at * m_at;\n variable s_lms: Number = s_at * s_at * s_at;\n \n // First derivatives\n variable ldt: Number = 3 * ldt_ * l_at * l_at;\n variable mdt: Number = 3 * mdt_ * m_at * m_at;\n variable sdt: Number = 3 * sdt_ * s_at * s_at;\n \n // Second derivatives\n variable ldt2: Number = 6 * ldt_ * ldt_ * l_at;\n variable mdt2: Number = 6 * mdt_ * mdt_ * m_at;\n variable sdt2: Number = 6 * sdt_ * sdt_ * s_at;\n \n // Red channel Halley step\n variable r_val: Number = lms_r0 * l_lms + lms_r1 * m_lms + lms_r2 * s_lms - 1;\n variable r1: Number = lms_r0 * ldt + lms_r1 * mdt + lms_r2 * sdt;\n variable r2: Number = lms_r0 * ldt2 + lms_r1 * mdt2 + lms_r2 * sdt2;\n variable r_denom: Number = r1 * r1 - 0.5 * r_val * r2;\n variable ur: Number = 0;\n variable tr: Number = float_max;\n if (abs(r_denom) > 0.000001) [\n ur = r1 / r_denom;\n if (ur >= 0) [ tr = -r_val * ur; ];\n ];\n \n // Green channel Halley step\n variable g_val: Number = lms_g0 * l_lms + lms_g1 * m_lms + lms_g2 * s_lms - 1;\n variable g1: Number = lms_g0 * ldt + lms_g1 * mdt + lms_g2 * sdt;\n variable g2: Number = lms_g0 * ldt2 + lms_g1 * mdt2 + lms_g2 * sdt2;\n variable g_denom: Number = g1 * g1 - 0.5 * g_val * g2;\n variable ug: Number = 0;\n variable tg: Number = float_max;\n if (abs(g_denom) > 0.000001) [\n ug = g1 / g_denom;\n if (ug >= 0) [ tg = -g_val * ug; ];\n ];\n \n // Blue channel Halley step\n variable b_val: Number = lms_b0 * l_lms + lms_b1 * m_lms + lms_b2 * s_lms - 1;\n variable b1: Number = lms_b0 * ldt + lms_b1 * mdt + lms_b2 * sdt;\n variable b2: Number = lms_b0 * ldt2 + lms_b1 * mdt2 + lms_b2 * sdt2;\n variable b_denom: Number = b1 * b1 - 0.5 * b_val * b2;\n variable ub: Number = 0;\n variable tb: Number = float_max;\n if (abs(b_denom) > 0.000001) [\n ub = b1 / b_denom;\n if (ub >= 0) [ tb = -b_val * ub; ];\n ];\n \n // Take minimum of the three corrections\n variable t_correction: Number = tr;\n if (tg < t_correction) [ t_correction = tg; ];\n if (tb < t_correction) [ t_correction = tb; ];\n if (t_correction < float_max) [\n t_intersect = t_intersect + t_correction;\n ];\n ];\n \n // C_max = t * c1 = t (since c1 = 1)\n c_max = t_intersect;\n if (c_max < 0) [ c_max = 0; ];\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 7: getStMid - Polynomial approximation for mid-saturation ST\n // ═══════════════════════════════════════════════════════════════════════\n variable st_mid_s: Number = 0.11516993 + 1 / (\n 7.44778970 + 4.15901240 * b_ +\n a_ * (-2.19557347 + 1.75198401 * b_ +\n a_ * (-2.13704948 - 10.02301043 * b_ +\n a_ * (-4.24894561 + 5.38770819 * b_ + 4.69891013 * a_))));\n \n variable st_mid_t: Number = 0.11239642 + 1 / (\n 1.61320320 - 0.68124379 * b_ +\n a_ * (0.40370612 + 0.90148123 * b_ +\n a_ * (-0.27087943 + 0.61223990 * b_ +\n a_ * (0.00299215 - 0.45399568 * b_ - 0.14661872 * a_))));\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 8: getCs - Compute C_0, C_mid, C_max with scale factor k\n // ═══════════════════════════════════════════════════════════════════════\n variable st_max_s: Number = c_cusp / (l_cusp + 0.0001);\n variable st_max_t: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Scale factor k = c_max / min(L * st_max_s, (1-L) * st_max_t)\n variable min_st: Number = lab_l * st_max_s;\n variable min_st_t: Number = (1 - lab_l) * st_max_t;\n if (min_st_t < min_st) [ min_st = min_st_t; ];\n \n variable k_factor: Number = 1;\n if (min_st > 0.0001 && c_max > 0.0001) [\n k_factor = c_max / min_st;\n ];\n \n // C_mid = 0.9 * k * sqrt(sqrt(1 / (1/ca^4 + 1/cb^4)))\n variable ca: Number = lab_l * st_mid_s;\n variable cb: Number = (1 - lab_l) * st_mid_t;\n variable ca4: Number = ca * ca * ca * ca;\n variable cb4: Number = cb * cb * cb * cb;\n variable c_mid: Number = 0;\n if (ca4 > 0.0000001 && cb4 > 0.0000001) [\n c_mid = 0.9 * k_factor * sqrt(sqrt(1 / (1 / ca4 + 1 / cb4)));\n ];\n \n // C_0 using average ST values (0.4, 0.8)\n variable ca0: Number = lab_l * 0.4;\n variable cb0: Number = (1 - lab_l) * 0.8;\n variable ca0_sq: Number = ca0 * ca0;\n variable cb0_sq: Number = cb0 * cb0;\n variable c_0: Number = 0;\n if (ca0_sq > 0.0000001 && cb0_sq > 0.0000001) [\n c_0 = sqrt(1 / (1 / ca0_sq + 1 / cb0_sq));\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Step 9: Compute saturation using piecewise interpolation\n // ═══════════════════════════════════════════════════════════════════════\n variable mid: Number = 0.8;\n \n if (c < c_mid && c_mid > 0.0001 && c_0 > 0.0001) [\n // Below mid-point: s = t * 0.8 where t = c / (k1 + k2*c)\n variable k1_low: Number = mid * c_0;\n variable k2_low: Number = 1 - k1_low / c_mid;\n variable t_low: Number = c / (k1_low + k2_low * c + 0.0001);\n s = t_low * mid;\n ] else [\n if (c_mid > 0.0001 && c_0 > 0.0001 && c_max > c_mid) [\n // Above mid-point: s = 0.8 + 0.2 * t where t = (c - c_mid) / (k1 + k2*(c - c_mid))\n variable mid_inv: Number = 1.25;\n variable k0_high: Number = c_mid;\n variable k1_high: Number = 0.2 * c_mid * c_mid * mid_inv * mid_inv / c_0;\n variable k2_high: Number = 1 - k1_high / (c_max - c_mid + 0.0001);\n variable c_diff: Number = c - k0_high;\n variable t_high: Number = c_diff / (k1_high + k2_high * c_diff + 0.0001);\n s = mid + 0.2 * t_high;\n ];\n ];\n \n // Clamp saturation\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSL;\noutput.h = h;\noutput.s = s;\noutput.l = l;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/okhsv-color/0/", - schema: { - "name": "OKHSV", - "type": "color", - "description": "OKHSV color space by Björn Ottosson - a perceptually uniform HSV based on OKLab. H is hue (0-360), S is saturation (0-1), V is value (0-1). Reference: https://bottosson.github.io/posts/colorpicker/", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees), same as OKLCH hue" - }, - "s": { - "type": "number", - "description": "Saturation (0-1), ratio of chroma to maximum at this value" - }, - "v": { - "type": "number", - "description": "Value/brightness (0-1), with V=1 being the brightest for that saturation" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "OKHSV Color Initializer", - "keyword": "okhsv", - "description": "Creates an OKHSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKHSV Color Initializer\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n//\n// Creates an OKHSV color from hue, saturation, and value.\n// OKHSV is a perceptually uniform version of HSV built on OKLab.\n//\n// Parameters:\n// - h: Hue angle (0-360 degrees), same as OKLCH hue\n// - s: Saturation (0-1), ratio of chroma to maximum at this value\n// - v: Value/brightness (0-1), with V=1 being brightest for that saturation\n// - alpha: Optional alpha channel (0-1)\n//\n// Input: Object with h, s, v, and optional alpha properties\n// Output: Color.OKHSV\n\nvariable h: Number = input.h;\nvariable s: Number = input.s;\nvariable v: Number = input.v;\n\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\n// Set alpha if provided\nif (input.alpha != null) [\n output.alpha = input.alpha;\n];\n\noutput" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to OKHSV using Ottosson's algorithm with Halley's method refinement for gamut boundary", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to OKHSV Conversion\n// Reference: Björn Ottosson - \"A perceptual color picker: OKHSL and OKHSV\"\n// URL: https://bottosson.github.io/posts/colorpicker/\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/okhsv.js\n// Reference: https://bottosson.github.io/posts/oklab/ (original OKLab paper)\n//\n// OKHSV maps colors to a perceptually uniform HSV cylinder.\n// The algorithm finds the exact gamut boundary using compute_max_saturation\n// and positions colors relative to the cusp (maximum chroma point).\n//\n// Key difference from OKHSL:\n// - V=1 means maximum brightness (white at S=0, cusp color at S=1)\n// - More intuitive for picking saturated colors\n//\n// Input: Color.OKLab with l (0-1), a, b coordinates\n// Output: Color.OKHSV with h (0-360), s (0-1), v (0-1)\n\nvariable lab_l: Number = input.l;\nvariable lab_a: Number = input.a;\nvariable lab_b: Number = input.b;\n\n// Native constants\nvariable pi_val: Number = pi();\n\n// Toe function constants (same as OKHSL)\nvariable toe_k1: Number = 0.206;\nvariable toe_k2: Number = 0.03;\nvariable toe_k3: Number = 1.17009708737864;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 1: Convert to polar coordinates (L, C, H)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable c: Number = sqrt(lab_a * lab_a + lab_b * lab_b);\nvariable h: Number = 0;\n\nif (c > 0.00001) [\n // Note: ColorJS uses atan2(-b, -a) and adds 0.5, we use atan2(b, a)\n h = atan2(lab_b, lab_a) * 180 / pi_val;\n if (h < 0) [\n h = h + 360;\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 2: Initialize defaults and apply toe function to L for V\n// toe(x) = 0.5 * (k3*x - k1 + sqrt((k3*x - k1)^2 + 4*k2*k3*x))\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s: Number = 0;\nvariable v: Number = lab_l;\n\n// Apply toe function to get initial V\nif (lab_l > 0.0001 && lab_l < 0.9999) [\n variable term: Number = toe_k3 * lab_l - toe_k1;\n v = 0.5 * (term + sqrt(term * term + 4 * toe_k2 * toe_k3 * lab_l));\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 3: Compute normalized hue direction\n// ═══════════════════════════════════════════════════════════════════════════\nvariable a_: Number = 0;\nvariable b_: Number = 0;\nif (c > 0.00001) [\n a_ = lab_a / c;\n b_ = lab_b / c;\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Pre-compute LMS coefficients (used in multiple steps)\n// ═══════════════════════════════════════════════════════════════════════════\nvariable kl: Number = 0.3963377774 * a_ + 0.2158037573 * b_;\nvariable km: Number = -0.1055613458 * a_ - 0.0638541728 * b_;\nvariable ks: Number = -0.0894841775 * a_ - 1.2914855480 * b_;\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 4: Find maximum saturation and cusp\n// Only process if we have chroma (non-achromatic) and L is valid\n// ═══════════════════════════════════════════════════════════════════════════\nvariable s_max: Number = 0;\nvariable l_cusp: Number = 1;\nvariable c_cusp: Number = 0;\n\n// Variables for LMS intermediate calculations\nvariable l_lms: Number = 0;\nvariable m_lms: Number = 0;\nvariable s_lms: Number = 0;\n\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999) [\n // Determine which RGB component clips first\n variable k0: Number = 0;\n variable k1_coef: Number = 0;\n variable k2_coef: Number = 0;\n variable k3_coef: Number = 0;\n variable k4_coef: Number = 0;\n variable wl: Number = 0;\n variable wm: Number = 0;\n variable ws: Number = 0;\n \n variable test_r: Number = -1.88170328 * a_ - 0.80936493 * b_;\n variable test_g: Number = 1.81444104 * a_ - 1.19445276 * b_;\n \n if (test_r > 1) [\n k0 = 1.19086277;\n k1_coef = 1.76576728;\n k2_coef = 0.59662641;\n k3_coef = 0.75515197;\n k4_coef = 0.56771245;\n wl = 4.0767416621;\n wm = -3.3077115913;\n ws = 0.2309699292;\n ] else [\n if (test_g > 1) [\n k0 = 0.73956515;\n k1_coef = -0.45954404;\n k2_coef = 0.08285427;\n k3_coef = 0.12541073;\n k4_coef = -0.14503204;\n wl = -1.2684380046;\n wm = 2.6097574011;\n ws = -0.3413193965;\n ] else [\n k0 = 1.35733652;\n k1_coef = -0.00915799;\n k2_coef = -1.15130210;\n k3_coef = -0.50559606;\n k4_coef = 0.00692167;\n wl = -0.0041960863;\n wm = -0.7034186147;\n ws = 1.7076147010;\n ];\n ];\n \n // Polynomial approximation\n s_max = k0 + k1_coef * a_ + k2_coef * b_ + k3_coef * a_ * a_ + k4_coef * a_ * b_;\n \n // Halley's method refinement\n variable l_temp: Number = 1 + s_max * kl;\n variable m_temp: Number = 1 + s_max * km;\n variable s_temp: Number = 1 + s_max * ks;\n \n variable l_cubed: Number = l_temp * l_temp * l_temp;\n variable m_cubed: Number = m_temp * m_temp * m_temp;\n variable s_cubed: Number = s_temp * s_temp * s_temp;\n \n variable l_ds: Number = 3 * kl * l_temp * l_temp;\n variable m_ds: Number = 3 * km * m_temp * m_temp;\n variable s_ds: Number = 3 * ks * s_temp * s_temp;\n \n variable l_ds2: Number = 6 * kl * kl * l_temp;\n variable m_ds2: Number = 6 * km * km * m_temp;\n variable s_ds2: Number = 6 * ks * ks * s_temp;\n \n variable f: Number = wl * l_cubed + wm * m_cubed + ws * s_cubed;\n variable f1: Number = wl * l_ds + wm * m_ds + ws * s_ds;\n variable f2: Number = wl * l_ds2 + wm * m_ds2 + ws * s_ds2;\n \n variable denom: Number = f1 * f1 - 0.5 * f * f2;\n if (abs(denom) > 0.000001) [\n s_max = s_max - f * f1 / denom;\n ];\n \n // ═══════════════════════════════════════════════════════════════════════\n // Find cusp (L_cusp, C_cusp)\n // ═══════════════════════════════════════════════════════════════════════\n if (s_max > 0) [\n variable l_cusp_temp: Number = 1 + s_max * kl;\n variable m_cusp_temp: Number = 1 + s_max * km;\n variable s_cusp_temp: Number = 1 + s_max * ks;\n \n l_lms = l_cusp_temp * l_cusp_temp * l_cusp_temp;\n m_lms = m_cusp_temp * m_cusp_temp * m_cusp_temp;\n s_lms = s_cusp_temp * s_cusp_temp * s_cusp_temp;\n \n variable r_lin: Number = 4.0767416621 * l_lms - 3.3077115913 * m_lms + 0.2309699292 * s_lms;\n variable g_lin: Number = -1.2684380046 * l_lms + 2.6097574011 * m_lms - 0.3413193965 * s_lms;\n variable b_lin: Number = -0.0041960863 * l_lms - 0.7034186147 * m_lms + 1.7076147010 * s_lms;\n \n variable max_rgb: Number = r_lin;\n if (g_lin > max_rgb) [ max_rgb = g_lin; ];\n if (b_lin > max_rgb) [ max_rgb = b_lin; ];\n \n if (max_rgb > 0) [\n l_cusp = pow(1 / max_rgb, 0.3333333333333333);\n c_cusp = l_cusp * s_max;\n ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Step 5: Compute OKHSV S and V using ColorJS algorithm\n//\n// The algorithm uses the ST coordinate system where:\n// - S_t = C/L (slope from origin)\n// - T_t = C/(1-L) (slope from white)\n//\n// Key formulas from ColorJS:\n// - t = tMax / (c + l * tMax)\n// - lv = t * l, cv = t * c\n// - Apply RGB scaling and toe compensation\n// - v = l / lv, s = ((s0 + tMax) * cv) / (tMax * s0 + tMax * k * cv)\n// ═══════════════════════════════════════════════════════════════════════════\nif (c > 0.00001 && lab_l > 0.00001 && lab_l < 0.99999 && l_cusp > 0.0001 && c_cusp > 0.0001) [\n // Compute ST values at cusp\n variable s_t_cusp: Number = c_cusp / l_cusp;\n variable t_t_cusp: Number = c_cusp / (1 - l_cusp + 0.0001);\n \n // Fixed s0 parameter\n variable s_0: Number = 0.5;\n variable k_param: Number = 1 - s_0 / s_t_cusp;\n \n // Compute t and derived values (following ColorJS exactly)\n variable t: Number = t_t_cusp / (c + lab_l * t_t_cusp + 0.0001);\n variable lv: Number = t * lab_l;\n variable cv: Number = t * c;\n \n // Apply inverse toe to lv for compensation\n variable lvt: Number = lv;\n if (lv > 0.0001 && lv < 0.9999) [\n lvt = (lv * lv + toe_k1 * lv) / (toe_k3 * (lv + toe_k2));\n ];\n \n variable cvt: Number = cv;\n if (lv > 0.0001) [\n cvt = cv * lvt / lv;\n ];\n \n // RGB scale computation\n variable scale_l_: Number = 1 + s_max * kl;\n variable scale_m_: Number = 1 + s_max * km;\n variable scale_s_: Number = 1 + s_max * ks;\n \n variable lms_l: Number = lvt + a_ * cvt * kl;\n variable lms_m: Number = lvt + a_ * cvt * km;\n variable lms_s: Number = lvt + a_ * cvt * ks;\n \n // Convert to linear RGB using LMS\n variable lms_l_cubed: Number = lms_l * lms_l * lms_l;\n variable lms_m_cubed: Number = lms_m * lms_m * lms_m;\n variable lms_s_cubed: Number = lms_s * lms_s * lms_s;\n \n variable rs: Number = 4.0767416621 * lms_l_cubed - 3.3077115913 * lms_m_cubed + 0.2309699292 * lms_s_cubed;\n variable gs: Number = -1.2684380046 * lms_l_cubed + 2.6097574011 * lms_m_cubed - 0.3413193965 * lms_s_cubed;\n variable bs: Number = -0.0041960863 * lms_l_cubed - 0.7034186147 * lms_m_cubed + 1.7076147010 * lms_s_cubed;\n \n variable max_s: Number = rs;\n if (gs > max_s) [ max_s = gs; ];\n if (bs > max_s) [ max_s = bs; ];\n if (max_s < 0.0001) [ max_s = 0.0001; ];\n \n variable scale_l: Number = pow(1 / max_s, 0.3333333333333333);\n \n // Scale L and C\n variable l_scaled: Number = lab_l / scale_l;\n variable c_scaled: Number = c / scale_l;\n \n // Apply toe to scaled L for compensation\n variable l_toed: Number = l_scaled;\n if (l_scaled > 0.0001 && l_scaled < 0.9999) [\n variable term2: Number = toe_k3 * l_scaled - toe_k1;\n l_toed = 0.5 * (term2 + sqrt(term2 * term2 + 4 * toe_k2 * toe_k3 * l_scaled));\n ];\n \n c_scaled = c_scaled * l_toed / (l_scaled + 0.0001);\n \n // Compute final v and s\n if (lv > 0.0001) [\n v = l_toed / lv;\n if (v > 1) [ v = 1; ];\n if (v < 0) [ v = 0; ];\n ];\n \n variable denom_s: Number = t_t_cusp * s_0 + t_t_cusp * k_param * cv;\n if (abs(denom_s) > 0.0001) [\n s = ((s_0 + t_t_cusp) * cv) / denom_s;\n if (s > 1) [ s = 1; ];\n if (s < 0) [ s = 0; ];\n ];\n];\n\n// ═══════════════════════════════════════════════════════════════════════════\n// Output\n// ═══════════════════════════════════════════════════════════════════════════\nvariable output: Color.OKHSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\noutput" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - schema: { - "name": "P3", - "type": "color", - "description": "Display-P3 color space with sRGB transfer function. Wider gamut than sRGB, common on modern Apple displays.", - "schema": { - "type": "object", - "properties": { - "r": { - "type": "number", - "description": "Red channel (0-1, can exceed for out-of-gamut)" - }, - "g": { - "type": "number", - "description": "Green channel (0-1, can exceed for out-of-gamut)" - }, - "b": { - "type": "number", - "description": "Blue channel (0-1, can exceed for out-of-gamut)" - } - }, - "required": [ - "r", - "g", - "b" - ], - "order": [ - "r", - "g", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Display-P3 Color Initializer", - "keyword": "p3", - "description": "Creates a Display-P3 color from 0-1 values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 Color Initializer\n// Creates a Display-P3 color from 0-1 values\n// Input: List of [r, g, b] or [r, g, b, alpha] values\n\nvariable color_values: List = {input};\nvariable output: Color.P3;\n\noutput.r = color_values.get(0);\noutput.g = color_values.get(1);\noutput.b = color_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (color_values.length() > 3) [\n output.alpha = color_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-linear-color/0/", - "target": "$self", - "description": "Converts Linear P3 to P3 by applying sRGB transfer function", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear P3 to P3 Conversion\n// Applies sRGB transfer function (gamma encoding)\n// P3 uses the same transfer function as sRGB\n// Reference: CSS Color Level 4\n//\n// Algorithm (same as sRGB):\n// if linear ≤ 0.0031308: encoded = 12.92 × linear\n// else: encoded = 1.055 × linear^(1/2.4) - 0.055\n//\n// Input: Color.LinearP3 with linear r, g, b values\n// Output: Color.P3 with gamma-encoded r, g, b values\n\n// Transfer function constants (same as sRGB)\nvariable threshold: Number = 0.0031308;\nvariable linear_scale: Number = 12.92;\nvariable gamma_scale: Number = 1.055;\nvariable gamma_offset: Number = 0.055;\nvariable gamma_exponent: Number = 0.4166666666666667;\n\n// Get linear values\nvariable linear_r: Number = {input}.r;\nvariable linear_g: Number = {input}.g;\nvariable linear_b: Number = {input}.b;\n\n// Convert red channel\nvariable encoded_r: Number = 0;\nif (linear_r <= threshold) [\n encoded_r = linear_scale * linear_r;\n] else [\n encoded_r = gamma_scale * pow(linear_r, gamma_exponent) - gamma_offset;\n];\n\n// Convert green channel\nvariable encoded_g: Number = 0;\nif (linear_g <= threshold) [\n encoded_g = linear_scale * linear_g;\n] else [\n encoded_g = gamma_scale * pow(linear_g, gamma_exponent) - gamma_offset;\n];\n\n// Convert blue channel\nvariable encoded_b: Number = 0;\nif (linear_b <= threshold) [\n encoded_b = linear_scale * linear_b;\n] else [\n encoded_b = gamma_scale * pow(linear_b, gamma_exponent) - gamma_offset;\n];\n\n// Create output\nvariable output: Color.P3;\noutput.r = encoded_r;\noutput.g = encoded_g;\noutput.b = encoded_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/css-color/0/", - schema: { - "name": "CSS", - "type": "color", - "description": "CSS color string representation. Outputs the appropriate CSS syntax for any color space.", - "schema": { - "type": "object", - "properties": { - "value": { - "type": "string", - "description": "CSS color string (e.g., 'rgb(255 128 64)', 'oklch(0.7 0.15 180)')" - } - }, - "required": [ - "value" - ] - }, - "initializers": [ - { - "title": "CSS Color Initializer", - "keyword": "css", - "description": "Creates a CSS color from a string value", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CSS Color Initializer\n// Accepts a CSS color string and returns it\n// This is primarily used for type registration; \n// the main usage is converting TO css from other color types\n//\n// Input: String (CSS color value)\n// Output: String (same value)\n\nvariable input: List = {input};\nvariable value: String = input.get(0);\n\nreturn value;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/", - "target": "$self", - "description": "Converts RGB (0-255) to CSS rgb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// RGB to CSS Conversion\n// Converts RGB (0-255) to CSS rgb() syntax\n// CSS Color Level 4 modern syntax: rgb(r g b) or rgb(r g b / alpha)\n//\n// Input: Color.Rgb with r, g, b in 0-255 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"rgb(255 128 64)\" or \"rgb(255 128 64 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable r: Number = round({input}.r);\nvariable g: Number = round({input}.g);\nvariable b: Number = round({input}.b);\n\nvariable css_value: String = \"rgb(\".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB (0-1) to CSS color(srgb) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to CSS Conversion\n// Converts sRGB (0-1) to CSS color(srgb) syntax\n// CSS Color Level 4: color(srgb r g b) or color(srgb r g b / alpha)\n//\n// Input: Color.SRGB with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(srgb 1 0.5 0.25)\" or \"color(srgb 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(srgb \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsl-color/0/", - "target": "$self", - "description": "Converts HSL to CSS hsl() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSL to CSS Conversion\n// Converts HSL to CSS hsl() syntax\n// CSS Color Level 4: hsl(h s l) or hsl(h s l / alpha) where s and l are percentages\n//\n// Input: Color.HSL with h (0-360), s (0-1), l (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hsl(120 50% 75%)\" or \"hsl(120 50% 75% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable s: Number = round({input}.s * 100 * precision) / precision;\nvariable l: Number = round({input}.l * 100 * precision) / precision;\n\nvariable css_value: String = \"hsl(\".concat(h.to_string()).concat(\" \").concat(s.to_string()).concat(\"% \").concat(l.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - "target": "$self", - "description": "Converts HWB to CSS hwb() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB to CSS Conversion\n// Converts HWB to CSS hwb() syntax\n// CSS Color Level 4: hwb(h w b) or hwb(h w b / alpha) where w and b are percentages\n//\n// Input: Color.HWB with h (0-360), w (0-1), b (0-1), optional alpha (0-1)\n// Output: Color.CSS with value like \"hwb(120 10% 20%)\" or \"hwb(120 10% 20% / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100;\nvariable h: Number = round({input}.h * precision) / precision;\nvariable w: Number = round({input}.w * 100 * precision) / precision;\nvariable b_val: Number = round({input}.b * 100 * precision) / precision;\n\nvariable css_value: String = \"hwb(\".concat(h.to_string()).concat(\" \").concat(w.to_string()).concat(\"% \").concat(b_val.to_string()).concat(\"%\");\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to CSS lab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to CSS Conversion\n// Converts CIE Lab to CSS lab() syntax\n// CSS Color Level 4: lab(L a b) or lab(L a b / alpha) where L is a percentage\n//\n// Input: Color.Lab with l (0-100), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"lab(75% 20 -30)\" or \"lab(75% 20 -30 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"lab(\".concat(l.to_string()).concat(\"% \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - "target": "$self", - "description": "Converts CIE LCH to CSS lch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH to CSS Conversion\n// Converts CIE LCH to CSS lch() syntax\n// CSS Color Level 4: lch(L C H) or lch(L C H / alpha) where L is a percentage\n//\n// Input: Color.LCH with l (0-100), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"lch(75% 50 180)\" or \"lch(75% 50 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 10000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"lch(\".concat(l.to_string()).concat(\"% \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "target": "$self", - "description": "Converts OKLab to CSS oklab() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLab to CSS Conversion\n// Converts OKLab to CSS oklab() syntax\n// CSS Color Level 4: oklab(L a b) or oklab(L a b / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLab with l (0-1), a, b, optional alpha (0-1)\n// Output: Color.CSS with value like \"oklab(0.7 0.1 -0.05)\" or \"oklab(0.7 0.1 -0.05 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable a: Number = round({input}.a * precision) / precision;\nvariable b_val: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"oklab(\".concat(l.to_string()).concat(\" \").concat(a.to_string()).concat(\" \").concat(b_val.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "target": "$self", - "description": "Converts OKLCH to CSS oklch() syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// OKLCH to CSS Conversion\n// Converts OKLCH to CSS oklch() syntax\n// CSS Color Level 4: oklch(L C H) or oklch(L C H / alpha) where L is 0-1 decimal\n//\n// Input: Color.OKLCH with l (0-1), c, h (0-360), optional alpha (0-1)\n// Output: Color.CSS with value like \"oklch(0.7 0.15 180)\" or \"oklch(0.7 0.15 180 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable l: Number = round({input}.l * precision) / precision;\nvariable c: Number = round({input}.c * precision) / precision;\nvariable h: Number = round({input}.h * precision) / precision;\n\nvariable css_value: String = \"oklch(\".concat(l.to_string()).concat(\" \").concat(c.to_string()).concat(\" \").concat(h.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-linear-color/0/", - "target": "$self", - "description": "Converts Linear sRGB to CSS color(srgb-linear) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Linear sRGB to CSS Conversion\n// Converts Linear sRGB to CSS color(srgb-linear) syntax\n// CSS Color Level 4: color(srgb-linear r g b)\n//\n// Input: Color.LinearSRGB with r, g, b in 0-1 range\n// Output: Color.CSS with value like \"color(srgb-linear 1 0.25 0.0625)\"\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable output: Color.CSS;\noutput.value = \"color(srgb-linear \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string()).concat(\")\");\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to CSS color(xyz-d65) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to CSS Conversion\n// Converts XYZ-D65 to CSS color(xyz-d65) syntax\n// CSS Color Level 4: color(xyz-d65 x y z) or color(xyz-d65 x y z / alpha)\n//\n// Input: Color.XYZD65 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d65 0.4124 0.2126 0.0193)\" or \"color(xyz-d65 0.4124 0.2126 0.0193 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d65 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CSS color(xyz-d50) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CSS Conversion\n// Converts XYZ-D50 to CSS color(xyz-d50) syntax\n// CSS Color Level 4: color(xyz-d50 x y z) or color(xyz-d50 x y z / alpha)\n//\n// Input: Color.XYZD50 with x, y, z tristimulus values, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(xyz-d50 0.4360 0.2225 0.0139)\" or \"color(xyz-d50 0.4360 0.2225 0.0139 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable x: Number = round({input}.x * precision) / precision;\nvariable y: Number = round({input}.y * precision) / precision;\nvariable z: Number = round({input}.z * precision) / precision;\n\nvariable css_value: String = \"color(xyz-d50 \".concat(x.to_string()).concat(\" \").concat(y.to_string()).concat(\" \").concat(z.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - }, - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/p3-color/0/", - "target": "$self", - "description": "Converts Display-P3 to CSS color(display-p3) syntax", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Display-P3 to CSS Conversion\n// Converts Display-P3 to CSS color(display-p3) syntax\n// CSS Color Level 4: color(display-p3 r g b) or color(display-p3 r g b / alpha)\n//\n// Input: Color.P3 with r, g, b in 0-1 range, optional alpha (0-1)\n// Output: Color.CSS with value like \"color(display-p3 1 0.5 0.25)\" or \"color(display-p3 1 0.5 0.25 / 0.5)\"\n// Note: Alpha is omitted if null or 1.0 (fully opaque)\n\nvariable precision: Number = 100000;\nvariable r: Number = round({input}.r * precision) / precision;\nvariable g: Number = round({input}.g * precision) / precision;\nvariable b: Number = round({input}.b * precision) / precision;\n\nvariable css_value: String = \"color(display-p3 \".concat(r.to_string()).concat(\" \").concat(g.to_string()).concat(\" \").concat(b.to_string());\n\n// Add alpha if present, not null, and not 1.0 (per CSS convention)\nvariable alpha: Number = {input}.alpha;\nif (alpha != null) [\n if (alpha != 1) [\n css_value = css_value.concat(\" / \").concat(alpha.to_string());\n ];\n];\n\ncss_value = css_value.concat(\")\");\n\nvariable output: Color.CSS;\noutput.value = css_value;\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hwb-color/0/", - schema: { - "name": "HWB", - "type": "color", - "description": "HWB color space - Hue, Whiteness, Blackness. CSS Color Level 4.", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "w": { - "type": "number", - "description": "Whiteness (0-1)" - }, - "b": { - "type": "number", - "description": "Blackness (0-1)" - } - }, - "required": [ - "h", - "w", - "b" - ], - "order": [ - "h", - "w", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HWB Color Initializer", - "keyword": "hwb", - "description": "Creates an HWB color from H, W, B values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWB Color Initializer\n// Creates an HWB color from H, W, B values\n// Input: List of [h, w, b] or [h, w, b, alpha] values\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hwb_values.length() > 3) [\n output.alpha = hwb_values.get(3);\n];\n\nreturn output;" - } - }, - { - "title": "HWBA Color Initializer", - "keyword": "hwba", - "description": "Creates an HWB color with alpha from H, W, B, A values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HWBA Color Initializer\n// Creates an HWB color with alpha from H, W, B, A values\n//\n// Usage: hwba(180, 0.2, 0.3, 0.9) → Color.HWB { h: 180, w: 0.2, b: 0.3, alpha: 0.9 }\n//\n// Input: List of 4 numbers [h, w, b, alpha]\n// Output: Color.HWB with alpha\n\nvariable hwb_values: List = {input};\nvariable output: Color.HWB;\n\noutput.h = hwb_values.get(0);\noutput.w = hwb_values.get(1);\noutput.b = hwb_values.get(2);\noutput.alpha = hwb_values.get(3);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - "target": "$self", - "description": "Converts HSV to HWB", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV to HWB Conversion\n// Reference: CSS Color Level 4\n//\n// Algorithm:\n// H stays the same\n// W = (1 - S) * V (Whiteness)\n// B = 1 - V (Blackness)\n//\n// Input: Color.HSV with h, s, v values\n// Output: Color.HWB with h, w, b values\n\n// Get input HSV values\nvariable h: Number = {input}.h;\nvariable s: Number = {input}.s;\nvariable v: Number = {input}.v;\n\n// Calculate whiteness and blackness\nvariable w: Number = (1 - s) * v;\nvariable b: Number = 1 - v;\n\n// Create output\nvariable output: Color.HWB;\noutput.h = h;\noutput.w = w;\noutput.b = b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/hsv-color/0/", - schema: { - "name": "HSV", - "type": "color", - "description": "HSV color space - Hue, Saturation, Value. Also known as HSB (Hue, Saturation, Brightness).", - "schema": { - "type": "object", - "properties": { - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - }, - "s": { - "type": "number", - "description": "Saturation (0-1)" - }, - "v": { - "type": "number", - "description": "Value/Brightness (0-1)" - } - }, - "required": [ - "h", - "s", - "v" - ], - "order": [ - "h", - "s", - "v" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "HSV Color Initializer", - "keyword": "hsv", - "description": "Creates an HSV color from H, S, V values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// HSV Color Initializer\n// Creates an HSV color from H, S, V values\n// Input: List of [h, s, v] or [h, s, v, alpha] values\n\nvariable hsv_values: List = {input};\nvariable output: Color.HSV;\n\noutput.h = hsv_values.get(0);\noutput.s = hsv_values.get(1);\noutput.v = hsv_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (hsv_values.length() > 3) [\n output.alpha = hsv_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/", - "target": "$self", - "description": "Converts sRGB to HSV", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// sRGB to HSV Conversion\n// Reference: Standard RGB to HSV algorithm\n//\n// Algorithm:\n// V = max(R, G, B)\n// S = (V - min(R, G, B)) / V (if V > 0)\n// H depends on which channel is max\n//\n// Input: Color.SRGB with r, g, b in 0-1 range\n// Output: Color.HSV with h (0-360), s (0-1), v (0-1)\n\n// Get input sRGB values\nvariable r: Number = {input}.r;\nvariable g: Number = {input}.g;\nvariable b: Number = {input}.b;\n\n// Find max and min\nvariable max_val: Number = r;\nif (g > max_val) [\n max_val = g;\n];\nif (b > max_val) [\n max_val = b;\n];\n\nvariable min_val: Number = r;\nif (g < min_val) [\n min_val = g;\n];\nif (b < min_val) [\n min_val = b;\n];\n\n// Value is the max channel\nvariable v: Number = max_val;\n\n// Calculate saturation and hue\nvariable s: Number = 0;\nvariable h: Number = 0;\nvariable delta: Number = max_val - min_val;\n\nif (max_val > 0) [\n s = delta / max_val;\n];\n\nif (delta > 0) [\n // Hue calculation depends on which channel is max\n if (max_val == r) [\n h = ((g - b) / delta);\n if (g < b) [\n h = h + 6;\n ];\n ] else [\n if (max_val == g) [\n h = (b - r) / delta + 2;\n ] else [\n h = (r - g) / delta + 4;\n ];\n ];\n \n // Convert to degrees\n h = h * 60;\n];\n\n// Normalize hue to 0-360\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.HSV;\noutput.h = h;\noutput.s = s;\noutput.v = v;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - schema: { - "name": "Lab", - "type": "color", - "description": "CIE Lab (L*a*b*) perceptually uniform color space. L is lightness (0-100), a is green-red axis, b is blue-yellow axis.", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "a": { - "type": "number", - "description": "Green-red axis (typically -125 to 125)" - }, - "b": { - "type": "number", - "description": "Blue-yellow axis (typically -125 to 125)" - } - }, - "required": [ - "l", - "a", - "b" - ], - "order": [ - "l", - "a", - "b" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "Lab Color Initializer", - "keyword": "lab", - "description": "Creates a CIE Lab color from L, a, b values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab Color Initializer\n// Creates a Lab color from L, a, b values\n// Input: List of [l, a, b] or [l, a, b, alpha] values\n\nvariable lab_values: List = {input};\nvariable output: Color.Lab;\n\noutput.l = lab_values.get(0);\noutput.a = lab_values.get(1);\noutput.b = lab_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lab_values.length() > 3) [\n output.alpha = lab_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - "target": "$self", - "description": "Converts XYZ-D50 to CIE Lab using the CIE standard formula", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 to CIE Lab Conversion\n// Reference: CIE 15.3:2004 section 8.2.1.1\n// Reference: https://github.com/color-js/color.js/blob/main/src/spaces/lab.js\n//\n// Algorithm:\n// 1. Scale XYZ by D50 white point reference\n// 2. Apply f function: f(x) = x > ε ? cbrt(x) : (κ*x + 16)/116\n// 3. L = 116 * f[Y] - 16\n// 4. a = 500 * (f[X] - f[Y])\n// 5. b = 200 * (f[Y] - f[Z])\n//\n// Input: Color.XYZD50 with x, y, z tristimulus\n// Output: Color.Lab with l, a, b coordinates\n\n// Get input XYZ-D50 values\nvariable x: Number = {input}.x;\nvariable y: Number = {input}.y;\nvariable z: Number = {input}.z;\n\n// CIE constants (exact rational fractions)\n// ε = 216/24389 = (6/29)^3\nvariable epsilon: Number = 0.008856451679035631;\n// κ = 24389/27 = (29/3)^3 \nvariable kappa: Number = 903.2962962962963;\n\n// D50 white point reference (ColorJS exact values)\n// D50: [0.3457/0.3585, 1.0, (1-0.3457-0.3585)/0.3585]\nvariable white_x: Number = 0.9642956764295677;\nvariable white_y: Number = 1.0;\nvariable white_z: Number = 0.8251046025104602;\n\n// Scale XYZ by white point\nvariable xr: Number = x / white_x;\nvariable yr: Number = y / white_y;\nvariable zr: Number = z / white_z;\n\n// Apply f function with cube root\n// f(t) = t > ε ? t^(1/3) : (κt + 16) / 116\nvariable cube_root_exp: Number = 0.3333333333333333;\n\nvariable fx: Number = 0;\nvariable fy: Number = 0;\nvariable fz: Number = 0;\n\nif (xr > epsilon) [\n fx = pow(xr, cube_root_exp);\n] else [\n fx = (kappa * xr + 16) / 116;\n];\n\nif (yr > epsilon) [\n fy = pow(yr, cube_root_exp);\n] else [\n fy = (kappa * yr + 16) / 116;\n];\n\nif (zr > epsilon) [\n fz = pow(zr, cube_root_exp);\n] else [\n fz = (kappa * zr + 16) / 116;\n];\n\n// Calculate Lab values\nvariable lab_l: Number = 116 * fy - 16;\nvariable lab_a: Number = 500 * (fx - fy);\nvariable lab_b: Number = 200 * (fy - fz);\n\n// Create output\nvariable output: Color.Lab;\noutput.l = lab_l;\noutput.a = lab_a;\noutput.b = lab_b;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d50-color/0/", - schema: { - "name": "XYZD50", - "type": "color", - "description": "CIE XYZ color space with D50 white point. Used for Lab and LCH color spaces.", - "schema": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "X tristimulus value" - }, - "y": { - "type": "number", - "description": "Y tristimulus value (luminance)" - }, - "z": { - "type": "number", - "description": "Z tristimulus value" - } - }, - "required": [ - "x", - "y", - "z" - ], - "order": [ - "x", - "y", - "z" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "XYZ-D50 Color Initializer", - "keyword": "xyzd50", - "description": "Creates an XYZ-D50 color from tristimulus values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D50 Color Initializer\n// Creates an XYZ-D50 color from tristimulus values\n// Input: List of [x, y, z] tristimulus values\n\nvariable xyz_values: List = {input};\nvariable output: Color.XYZD50;\n\noutput.x = xyz_values.get(0);\noutput.y = xyz_values.get(1);\noutput.z = xyz_values.get(2);\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/xyz-d65-color/0/", - "target": "$self", - "description": "Converts XYZ-D65 to XYZ-D50 using Bradford chromatic adaptation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// XYZ-D65 to XYZ-D50 Conversion\n// Chromatic adaptation using Bradford transform\n// Reference: https://github.com/color-js/color.js/blob/main/src/adapt.js\n//\n// Bradford CAT matrix for D65 → D50:\n// [1.0479298 0.0229469 -0.0501923]\n// [0.0296278 0.9904344 -0.0170738]\n// [-0.0092430 0.0150552 0.7518743]\n//\n// Input: Color.XYZD65 with x, y, z tristimulus (D65 white)\n// Output: Color.XYZD50 with x, y, z tristimulus (D50 white)\n\n// Get input XYZ-D65 values\nvariable x65: Number = {input}.x;\nvariable y65: Number = {input}.y;\nvariable z65: Number = {input}.z;\n\n// Bradford chromatic adaptation matrix (ColorJS exact values)\n// Row 1\nvariable x50: Number = x65 * 1.0479297925449969 + y65 * 0.022946870601609652 + z65 * -0.05019226628920524;\n// Row 2\nvariable y50: Number = x65 * 0.02962780877005599 + y65 * 0.9904344267538799 + z65 * -0.017073799063418826;\n// Row 3\nvariable z50: Number = x65 * -0.009243040646204504 + y65 * 0.015055191490298152 + z65 * 0.7518742814281371;\n\n// Create output\nvariable output: Color.XYZD50;\noutput.x = x50;\noutput.y = y50;\noutput.z = z50;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lch-color/0/", - schema: { - "name": "LCH", - "type": "color", - "description": "CIE LCH color space - the polar form of CIE Lab. L is lightness (0-100), C is chroma, H is hue angle (0-360).", - "schema": { - "type": "object", - "properties": { - "l": { - "type": "number", - "description": "Lightness (0-100)" - }, - "c": { - "type": "number", - "description": "Chroma" - }, - "h": { - "type": "number", - "description": "Hue angle (0-360 degrees)" - } - }, - "required": [ - "l", - "c", - "h" - ], - "order": [ - "l", - "c", - "h" - ], - "additionalProperties": false - }, - "initializers": [ - { - "title": "LCH Color Initializer", - "keyword": "lch", - "description": "Creates a CIE LCH color from L, C, H values", - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE LCH Color Initializer\n// Creates a LCH color from L, C, H values\n// Input: List of [l, c, h] or [l, c, h, alpha] values\n\nvariable lch_values: List = {input};\nvariable output: Color.LCH;\n\noutput.l = lch_values.get(0);\noutput.c = lch_values.get(1);\noutput.h = lch_values.get(2);\n\n// Set alpha if provided as 4th parameter\nif (lch_values.length() > 3) [\n output.alpha = lch_values.get(3);\n];\n\nreturn output;" - } - } - ], - "conversions": [ - { - "source": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/lab-color/0/", - "target": "$self", - "description": "Converts CIE Lab to LCH using Cartesian to polar transformation", - "lossless": true, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// CIE Lab to LCH Conversion\n// Converts Cartesian (a, b) to polar (C, H) coordinates\n// Same algorithm as OKLab → OKLCH, but for CIE Lab\n//\n// Algorithm:\n// L stays the same (lightness 0-100)\n// C = sqrt(a² + b²) (chroma)\n// H = atan2(b, a) * 180/π (hue angle in degrees)\n//\n// Input: Color.Lab with l, a, b coordinates\n// Output: Color.LCH with l, c, h coordinates\n\n// Get input Lab values\nvariable l: Number = {input}.l;\nvariable a: Number = {input}.a;\nvariable b: Number = {input}.b;\n\n// Constants\nvariable pi: Number = pi();\nvariable rad_to_deg: Number = 180 / pi;\n\n// Calculate chroma (distance from neutral axis)\nvariable c: Number = sqrt(a * a + b * b);\n\n// Calculate hue angle using atan2\nvariable h_rad: Number = atan2(b, a);\nvariable h: Number = h_rad * rad_to_deg;\n\n// Normalize hue to 0-360 range\nif (h < 0) [\n h = h + 360;\n];\n\n// Create output\nvariable output: Color.LCH;\noutput.l = l;\noutput.c = c;\noutput.h = h;\n\nreturn output;" - } - } - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/lighten/0/", - schema: { - "name": "Lighten", - "type": "function", - "description": "Makes a color lighter by increasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% lighter.", - "keyword": "lighten", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to lighten" - }, - "amount": { - "type": "number", - "description": "Amount to lighten (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// lighten: Increase lightness proportionally towards white\n// Amount: 0-1 where 0.25 means 25% closer to white\n//\n// Algorithm: L' = L + (1 - L) * amount\n// This ensures we approach white (L=1) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: lighten(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 1)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l + (1 - current_l) * amount;\n\n// Clamp to valid range\nif (new_l > 1) [ new_l = 1; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/darken/0/", - schema: { - "name": "Darken", - "type": "function", - "description": "Makes a color darker by decreasing its lightness in OKLab space. Amount is 0-1 where 0.25 = 25% darker.", - "keyword": "darken", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to darken" - }, - "amount": { - "type": "number", - "description": "Amount to darken (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// darken: Decrease lightness proportionally towards black\n// Amount: 0-1 where 0.25 means 25% closer to black\n//\n// Algorithm: L' = L * (1 - amount)\n// This ensures we approach black (L=0) proportionally.\n//\n// Input: Any color space (converted to OKLab internally)\n// Output: OKLCH (working space)\n// To get sRGB: darken(color, 0.25).to.srgb()\n\nvariable input: List = {input};\nvariable color: Color.OKLab = input.get(0).to.oklab();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new lightness (move toward 0)\nvariable current_l: Number = color.l;\nvariable new_l: Number = current_l * (1 - amount);\n\n// Clamp to valid range\nif (new_l < 0) [ new_l = 0; ];\n\n// Create output in OKLab, return as OKLCH (working space)\nvariable result: Color.OKLab;\nresult.l = new_l;\nresult.a = color.a;\nresult.b = color.b;\n\nreturn result.to.oklch();" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklab-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/saturate/0/", - schema: { - "name": "Saturate", - "type": "function", - "description": "Increases color saturation by boosting chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% more saturated.", - "keyword": "saturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to saturate" - }, - "amount": { - "type": "number", - "description": "Amount to saturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Saturate a color by increasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% increase\n//\n// Algorithm: C' = C * (1 + amount)\n// Chroma has no strict upper bound but ~0.4 is practical max for sRGB\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 + amount);\n\n// Soft clamp at 0.4 (approximate sRGB gamut max)\nif (new_c > 0.4) [ new_c = 0.4; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/desaturate/0/", - schema: { - "name": "Desaturate", - "type": "function", - "description": "Decreases color saturation by reducing chroma in OKLCH space. Amount is 0-1 where 0.25 = 25% less saturated.", - "keyword": "desaturate", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to desaturate" - }, - "amount": { - "type": "number", - "description": "Amount to desaturate (0-1). Default is 0.25" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Desaturate a color by decreasing its OKLCH chroma\n// Amount: 0-1 where 0.25 means 25% decrease, 1.0 = full grayscale\n//\n// Algorithm: C' = C * (1 - amount)\n\nvariable input: List = {input};\nvariable color: Color.OKLCH = input.get(0).to.oklch();\n\n// Default amount is 0.25 (25%)\nvariable amount: Number = 0.25;\nif (input.length() > 1) [\n amount = input.get(1);\n];\n\n// Calculate new chroma\nvariable current_c: Number = color.c;\nvariable new_c: Number = current_c * (1 - amount);\n\n// Clamp to valid range\nif (new_c < 0) [ new_c = 0; ];\n\n// Create output in OKLCH, return as sRGB\nvariable result: Color.OKLCH;\nresult.l = color.l;\nresult.c = new_c;\nresult.h = color.h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/mix/0/", - schema: { - "name": "Mix", - "type": "function", - "description": "Blends two colors together in OKLCH space. Amount 0 = first color, 1 = second color, 0.5 = equal blend.", - "keyword": "mix", - "input": { - "type": "object", - "properties": { - "color1": { - "type": "color", - "description": "First color" - }, - "color2": { - "type": "color", - "description": "Second color" - }, - "amount": { - "type": "number", - "description": "Blend amount (0-1). Default is 0.5" - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// mix: Blend two colors in perceptual space\n// Uses shortest hue path for natural color blending.\n//\n// Algorithm: Linear interpolation on L, C, and shortest-path H\n//\n// Input: Any color space (converted to OKLCH internally)\n// Output: OKLCH (working space)\n// To get sRGB: mix(color1, color2, 0.5).to.srgb()\n\nvariable input: List = {input};\nvariable color1: Color.OKLCH = input.get(0).to.oklch();\nvariable color2: Color.OKLCH = input.get(1).to.oklch();\n\n// Default amount is 0.5 (50/50 blend)\nvariable amount: Number = 0.5;\nif (input.length() > 2) [\n amount = input.get(2);\n];\n\n// Interpolate lightness and chroma linearly\nvariable new_l: Number = color1.l + (color2.l - color1.l) * amount;\nvariable new_c: Number = color1.c + (color2.c - color1.c) * amount;\n\n// Interpolate hue using shortest path\nvariable h1: Number = color1.h;\nvariable h2: Number = color2.h;\nvariable h_diff: Number = h2 - h1;\n\n// Normalize to shortest path (-180 to 180)\nif (h_diff > 180) [ h_diff = h_diff - 360; ];\nif (h_diff < -180) [ h_diff = h_diff + 360; ];\n\nvariable new_h: Number = h1 + h_diff * amount;\nif (new_h < 0) [ new_h = new_h + 360; ];\nif (new_h >= 360) [ new_h = new_h - 360; ];\n\n// Create mixed color\nvariable result: Color.OKLCH;\nresult.l = new_l;\nresult.c = new_c;\nresult.h = new_h;\n\nreturn result;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/oklch-color/0/", - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/srgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/function/invert/0/", - schema: { - "name": "Invert Color", - "type": "function", - "description": "Inverts a color by inverting each RGB channel (R' = 255 - R, G' = 255 - G, B' = 255 - B).", - "keyword": "invert", - "input": { - "type": "object", - "properties": { - "color": { - "type": "color", - "description": "The color to invert." - } - } - }, - "script": { - "type": "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/tokenscript/0/", - "script": "// Invert Color Function\n// Returns the inverse/negative of a color by subtracting each RGB channel from 255\n//\n// Algorithm:\n// R' = 255 - R\n// G' = 255 - G \n// B' = 255 - B\n//\n// Use cases:\n// - Creating negative images\n// - High contrast accessibility variants\n// - Artistic color effects\n//\n// Input: Any color (converted to RGB internally)\n// Output: Color.Rgb with inverted channels\n\nvariable input: List = {input};\n\n// Convert input to RGB (0-255 range)\nvariable rgb_color: Color.Rgb = input.get(0).to.rgb();\n\n// Invert each channel by subtracting from 255\nvariable inverted_r: Number = 255 - rgb_color.r;\nvariable inverted_g: Number = 255 - rgb_color.g;\nvariable inverted_b: Number = 255 - rgb_color.b;\n\n// Create and return the inverted color\nvariable inverted_color: Color.Rgb = rgb(inverted_r, inverted_g, inverted_b);\nreturn inverted_color;" - }, - "requirements": [ - "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/core/rgb-color/0/" - ] - } - }, - { - uri: "https://schema.tokenscript.dev.gcp.tokens.studio/api/v1/constants/css-hex-colors/0/", - schema: { - "name": "CSS Hex Colors", - "type": "constants", - "description": "CSS named colors mapped to their hex values (CSS Color Level 4)", - "inline": true, - "values": { - "aliceblue": "#F0F8FF", - "antiquewhite": "#FAEBD7", - "aqua": "#00FFFF", - "aquamarine": "#7FFFD4", - "azure": "#F0FFFF", - "beige": "#F5F5DC", - "bisque": "#FFE4C4", - "black": "#000000", - "blanchedalmond": "#FFEBCD", - "blue": "#0000FF", - "blueviolet": "#8A2BE2", - "brown": "#A52A2A", - "burlywood": "#DEB887", - "cadetblue": "#5F9EA0", - "chartreuse": "#7FFF00", - "chocolate": "#D2691E", - "coral": "#FF7F50", - "cornflowerblue": "#6495ED", - "cornsilk": "#FFF8DC", - "crimson": "#DC143C", - "cyan": "#00FFFF", - "darkblue": "#00008B", - "darkcyan": "#008B8B", - "darkgoldenrod": "#B8860B", - "darkgray": "#A9A9A9", - "darkgreen": "#006400", - "darkgrey": "#A9A9A9", - "darkkhaki": "#BDB76B", - "darkmagenta": "#8B008B", - "darkolivegreen": "#556B2F", - "darkorange": "#FF8C00", - "darkorchid": "#9932CC", - "darkred": "#8B0000", - "darksalmon": "#E9967A", - "darkseagreen": "#8FBC8F", - "darkslateblue": "#483D8B", - "darkslategray": "#2F4F4F", - "darkslategrey": "#2F4F4F", - "darkturquoise": "#00CED1", - "darkviolet": "#9400D3", - "deeppink": "#FF1493", - "deepskyblue": "#00BFFF", - "dimgray": "#696969", - "dimgrey": "#696969", - "dodgerblue": "#1E90FF", - "firebrick": "#B22222", - "floralwhite": "#FFFAF0", - "forestgreen": "#228B22", - "fuchsia": "#FF00FF", - "gainsboro": "#DCDCDC", - "ghostwhite": "#F8F8FF", - "gold": "#FFD700", - "goldenrod": "#DAA520", - "gray": "#808080", - "green": "#008000", - "greenyellow": "#ADFF2F", - "grey": "#808080", - "honeydew": "#F0FFF0", - "hotpink": "#FF69B4", - "indianred": "#CD5C5C", - "indigo": "#4B0082", - "ivory": "#FFFFF0", - "khaki": "#F0E68C", - "lavender": "#E6E6FA", - "lavenderblush": "#FFF0F5", - "lawngreen": "#7CFC00", - "lemonchiffon": "#FFFACD", - "lightblue": "#ADD8E6", - "lightcoral": "#F08080", - "lightcyan": "#E0FFFF", - "lightgoldenrodyellow": "#FAFAD2", - "lightgray": "#D3D3D3", - "lightgreen": "#90EE90", - "lightgrey": "#D3D3D3", - "lightpink": "#FFB6C1", - "lightsalmon": "#FFA07A", - "lightseagreen": "#20B2AA", - "lightskyblue": "#87CEFA", - "lightslategray": "#778899", - "lightslategrey": "#778899", - "lightsteelblue": "#B0C4DE", - "lightyellow": "#FFFFE0", - "lime": "#00FF00", - "limegreen": "#32CD32", - "linen": "#FAF0E6", - "magenta": "#FF00FF", - "maroon": "#800000", - "mediumaquamarine": "#66CDAA", - "mediumblue": "#0000CD", - "mediumorchid": "#BA55D3", - "mediumpurple": "#9370DB", - "mediumseagreen": "#3CB371", - "mediumslateblue": "#7B68EE", - "mediumspringgreen": "#00FA9A", - "mediumturquoise": "#48D1CC", - "mediumvioletred": "#C71585", - "midnightblue": "#191970", - "mintcream": "#F5FFFA", - "mistyrose": "#FFE4E1", - "moccasin": "#FFE4B5", - "navajowhite": "#FFDEAD", - "navy": "#000080", - "oldlace": "#FDF5E6", - "olive": "#808000", - "olivedrab": "#6B8E23", - "orange": "#FFA500", - "orangered": "#FF4500", - "orchid": "#DA70D6", - "palegoldenrod": "#EEE8AA", - "palegreen": "#98FB98", - "paleturquoise": "#AFEEEE", - "palevioletred": "#DB7093", - "papayawhip": "#FFEFD5", - "peachpuff": "#FFDAB9", - "peru": "#CD853F", - "pink": "#FFC0CB", - "plum": "#DDA0DD", - "powderblue": "#B0E0E6", - "purple": "#800080", - "rebeccapurple": "#663399", - "red": "#FF0000", - "rosybrown": "#BC8F8F", - "royalblue": "#4169E1", - "saddlebrown": "#8B4513", - "salmon": "#FA8072", - "sandybrown": "#F4A460", - "seagreen": "#2E8B57", - "seashell": "#FFF5EE", - "sienna": "#A0522D", - "silver": "#C0C0C0", - "skyblue": "#87CEEB", - "slateblue": "#6A5ACD", - "slategray": "#708090", - "slategrey": "#708090", - "snow": "#FFFAFA", - "springgreen": "#00FF7F", - "steelblue": "#4682B4", - "tan": "#D2B48C", - "teal": "#008080", - "thistle": "#D8BFD8", - "tomato": "#FF6347", - "turquoise": "#40E0D0", - "violet": "#EE82EE", - "wheat": "#F5DEB3", - "white": "#FFFFFF", - "whitesmoke": "#F5F5F5", - "yellow": "#FFFF00", - "yellowgreen": "#9ACD32" - } - } - }, -]; - -export function makeConfig() { - return new Config().registerSchemas(SCHEMAS); -} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3f045541a8..5f1dad88cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -210,9 +210,6 @@ importers: '@penpot/text-editor': specifier: link:text-editor version: link:text-editor - '@penpot/tokenscript': - specifier: link:packages/tokenscript - version: link:packages/tokenscript '@penpot/ua-parser': specifier: penpot/ua-parser#1.0.0 version: https://codeload.github.com/penpot/ua-parser/tar.gz/90b970f39f2dc08378b975a0f01045b4ec8e89a4 @@ -445,12 +442,6 @@ importers: packages/mousetrap: {} - packages/tokenscript: - dependencies: - '@tokens-studio/tokenscript-interpreter': - specifier: ^0.26.0 - version: 0.26.0 - packages/ui: dependencies: react: @@ -547,12 +538,6 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} - '@ark/schema@0.56.6': - resolution: {integrity: sha512-OmLv80BCF2ib+zjecrXDosip/CSnZSYBGjblhqWYZWFOzdLQka6QymHToE9u7Z/HZW2KA8Hwc8MCNXoI3uGSIA==} - - '@ark/util@0.56.6': - resolution: {integrity: sha512-mHqq0f0HyDd9YadpFl25w0QECAP7d8HGIUpEC85duD+L+xr4i/CwfOid/Z7zJFFF/47oLt6+pZB9RbqWpBYTYg==} - '@asamuzakjp/css-color@7.1.3': resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -1902,11 +1887,6 @@ packages: peerDependencies: style-dictionary: ^5.0.0 - '@tokens-studio/tokenscript-interpreter@0.26.0': - resolution: {integrity: sha512-dGjvUJnXRspWYp98FZw43l4cN+0ey/cF5sEJjL3coKc5C7DY7MsKgkmOONizmaZqf13GUIzklTEas3gt3jvrOQ==} - engines: {node: '>=16.0.0'} - hasBin: true - '@tokens-studio/types@0.5.2': resolution: {integrity: sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==} @@ -2134,12 +2114,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arkregex@0.0.12: - resolution: {integrity: sha512-sOxL0eX50pnRYO4OyajKZChGuuj/iHwC5tysiAaGOPBu+zJwJQckWn7/DRH2++ol5uaKpyRn7N0hKT7nDqGmgg==} - - arktype@2.2.7: - resolution: {integrity: sha512-AbgM5SQvwCTURSR+ENfPtOG7cL5VZ0hqOVtoWyXKw2SviFddZhCBEUAjjVJYKCNFlPPR1VX5LGUKxsSqqc4Kdw==} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3873,9 +3847,6 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@0.2.1: resolution: {integrity: sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==} @@ -4174,10 +4145,6 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - readline-sync@1.4.10: - resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} - engines: {node: '>= 0.8.0'} - recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -5203,10 +5170,6 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yauzl@3.4.0: - resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} - engines: {node: '>=12'} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} @@ -5215,12 +5178,6 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@ark/schema@0.56.6': - dependencies: - '@ark/util': 0.56.6 - - '@ark/util@0.56.6': {} - '@asamuzakjp/css-color@7.1.3': dependencies: '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) @@ -6461,13 +6418,6 @@ snapshots: is-mergeable-object: 1.1.1 style-dictionary: 5.5.5 - '@tokens-studio/tokenscript-interpreter@0.26.0': - dependencies: - arktype: 2.2.7 - commander: 14.0.3 - readline-sync: 1.4.10 - yauzl: 3.4.0 - '@tokens-studio/types@0.5.2': {} '@tybys/wasm-util@0.10.4': @@ -6717,16 +6667,6 @@ snapshots: aria-query@5.3.2: {} - arkregex@0.0.12: - dependencies: - '@ark/util': 0.56.6 - - arktype@2.2.7: - dependencies: - '@ark/schema': 0.56.6 - '@ark/util': 0.56.6 - arkregex: 0.0.12 - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -8555,8 +8495,6 @@ snapshots: pathval@2.0.1: {} - pend@1.2.0: {} - picocolors@0.2.1: {} picocolors@1.1.1: {} @@ -8897,8 +8835,6 @@ snapshots: readdirp@5.1.1: {} - readline-sync@1.4.10: {} - recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -10018,8 +9954,4 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 - yauzl@3.4.0: - dependencies: - pend: 1.2.0 - yocto-queue@1.2.2: {} diff --git a/frontend/src/app/main/data/style_dictionary.cljs b/frontend/src/app/main/data/style_dictionary.cljs index 9b37274b5f..8021298f2c 100644 --- a/frontend/src/app/main/data/style_dictionary.cljs +++ b/frontend/src/app/main/data/style_dictionary.cljs @@ -10,7 +10,6 @@ ["style-dictionary$default" :as sd] [app.common.files.tokens :as cfo] [app.common.logging :as l] - [app.common.schema :as sm] [app.common.time :as ct] [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] @@ -86,8 +85,7 @@ (let [number? (or (number? value) (numeric-string? value)) parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (cond (and parsed-value (not out-of-bounds) number?) @@ -112,8 +110,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [parsed-value (cfo/parse-token-value value) - out-of-bounds (or (>= (:value parsed-value) sm/max-safe-int) - (<= (:value parsed-value) sm/min-safe-int))] + out-of-bounds (cto/number-out-of-bounds? (:value parsed-value))] (if (and parsed-value (not out-of-bounds)) parsed-value (if out-of-bounds @@ -185,7 +182,7 @@ If the `value` is not parseable and/or has missing references returns a map with `:errors`." [value] (let [normalized-value (str/lower (str/trim value)) - valid? (contains? #{"none" "uppercase" "lowercase" "capitalize"} normalized-value) + valid? (contains? cto/text-case-values normalized-value) references (seq (cto/find-token-value-references value))] (cond valid? diff --git a/frontend/src/app/main/data/tokenscript.cljs b/frontend/src/app/main/data/tokenscript.cljs index 7f0cbee92e..f002d49bed 100644 --- a/frontend/src/app/main/data/tokenscript.cljs +++ b/frontend/src/app/main/data/tokenscript.cljs @@ -1,166 +1,255 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + (ns app.main.data.tokenscript + "Resolves the workspace tokens with the tokenscript interpreter + (`tokenscript.core`, from penpot/tokenscript-interpreter). + + `resolve-tokens` takes the usual map of token name to token and returns + the same map with `:resolved-value` (a value ready for shape attributes: + numbers in pixels, colors as hex strings, composites as maps), + `:resolved-symbol` (the tokenscript value, see + `tokenscript.symbols`) and `:errors` (Penpot error maps, see + `app.main.data.workspace.tokens.errors`) added to each token." (:require - ["@penpot/tokenscript" :refer [BaseSymbolType - ColorSymbol - ListSymbol NumberSymbol - NumberWithUnitSymbol - ProcessorError - processTokens - TokenSymbol - makeConfig]] [app.common.data :as d] [app.common.logging :as l] [app.common.time :as ct] [app.common.types.token :as cto] [app.main.data.workspace.tokens.errors :as wte] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [tokenscript.core :as tsc] + [tokenscript.errors :as tse] + [tokenscript.symbols :as sym])) -(l/set-level! :debug) +;; Value conversion ----------------------------------------------------------- -;; Config ---------------------------------------------------------------------- +(defn- color->hex-string + "Hex string of a color symbol (`#rrggbbaa` when it has alpha)." + [color] + (sym/to-string (tsc/color->hex color))) -(def config (makeConfig)) +(defn- symbol->value + "Converts a resolved tokenscript value into the shape used by shape + attributes and the token forms: -;; Class predicates ------------------------------------------------------------ -;; Predicates to get information about the tokenscript interpreter symbol type -;; Or to determine the error + - colors: hex strings (alpha as `#rrggbbaa`) + - `rem` values: pixels (16px root font size), `%` values: fractions + - other numbers with unit: the number + - structured tokens (typography, shadows): maps with keyword keys and + vectors + - null: nil" + [v] + (let [entries->map (fn [entries] + (into {} (map (fn [[k v]] [(keyword k) (symbol->value v)])) entries))] + (cond + (nil? v) nil + (sym/null? v) nil + (sym/token? v) (let [content (sym/value v)] + (if (map? content) + (entries->map content) + (mapv symbol->value content))) + (sym/list? v) (mapv symbol->value (sym/value v)) + (sym/dictionary? v) (entries->map (sym/value v)) + (sym/color? v) (color->hex-string v) + (sym/number-with-unit? v) (let [n (sym/value v)] + (case (:unit v) + "rem" (* n 16) + "%" (/ n 100) + n)) + (sym/symbol? v) (sym/value v) + :else v))) -(defn tokenscript-symbol? [v] - (instance? BaseSymbolType v)) +;; Value validation ----------------------------------------------------------- +;; +;; Tokenscript resolves any expression; these are the Penpot level checks +;; on the resolved value of each token type (ranges, allowed keywords, +;; composite shapes) that decide whether a token can be applied. -(defn structured-token? [v] - (instance? TokenSymbol v)) +(defn- words->string + "Values like `700 Italic` resolve to a list of words; font weights are + strings." + [v] + (if (sequential? v) (str/join " " (map str v)) v)) -(defn structured-record-token? [^js v] - (and (structured-token? v) (instance? js/Map (.-value v)))) - -(defn structured-array-token? [^js v] - (and (structured-token? v) (instance? js/Array (.-value v)))) - -(defn number-with-unit-symbol? [v] - (instance? NumberWithUnitSymbol v)) - -(defn number-symbol? [v] - (instance? NumberSymbol v)) - -(defn list-symbol? [v] - (instance? ListSymbol v)) - -(defn color-symbol? [v] - (instance? ColorSymbol v)) - -(defn processor-error? [err] - (instance? ProcessorError err)) - -;; Conversion Tools ------------------------------------------------------------ -;; Helpers to convert tokenscript symbols to penpot accepted formats - -(defn color-symbol->hex-string [^js v] - (when (color-symbol? v) - (.toString (.to v "hex")))) - -(defn color-alpha [^js v] - (if (.isHex v) - 1 - (or (.getAttribute v "alpha") 1))) - -(defn color-symbol->penpot-color [^js v] - {:color (color-symbol->hex-string v) - :opacity (color-alpha v)}) - -(defn rem-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "rem"))) - -(defn percent-number-with-unit? [v] - (and (number-with-unit-symbol? v) - (= (.-unit v) "%"))) - -(defn rem->px [^js v] - (* (.-value v) 16)) - -(declare tokenscript-symbols->penpot-unit) - -(defn font-family-symbols->penpot-unit - "Converts a resolved font-family value into a vector of family names. - Each family name is the string form of its whole entry, which is a list of - word symbols when the family reached tokenscript unquoted." - [^js v] - (when (some? v) - (let [entries (.-value v)] - (if (instance? js/Array entries) - (mapv str entries) - [(str v)])))) - -(defn structured-token->penpot-map - "Converts structured token (record or array) to penpot map format. - Structured tokens are non-primitive token types like `typography` or `box-shadow`." - [^js token-symbol] - (if (instance? js/Array (.-value token-symbol)) - (mapv tokenscript-symbols->penpot-unit (.-value token-symbol)) - (let [entries (es6-iterator-seq (.entries (.-value token-symbol)))] - (into {} (map (fn [[k v]] - (let [k (keyword k)] - ;; The font-family member of a composite is a family list - [k (if (= :font-family k) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))]))) - entries)))) - -(defn tokenscript-symbols->penpot-unit [^js v] +(defn- font-family->vector + "Font families are always a flat vector of family names, also when the + value is a single name or references a font family token." + [v] (cond - (nil? v) nil - (structured-token? v) (structured-token->penpot-map v) - (list-symbol? v) (structured-token->penpot-map v) - (color-symbol? v) (.-value (.to v "hex")) - (rem-number-with-unit? v) (rem->px v) - (percent-number-with-unit? v) (/ (.-value v) 100) - :else (.-value v))) + (string? v) [v] + (sequential? v) (vec (flatten v)) + :else v)) -(defn resolved-value->penpot-unit - "Converts the resolved value of a token of the given `type`. - Font families need the type: a list of symbols is a list of families at the - top level and the words of a single family name inside an entry." - [type ^js v] - (if (= :font-family type) - (font-family-symbols->penpot-unit v) - (tokenscript-symbols->penpot-unit v))) +(defn- line-height->ratio + "Line heights in pixels are stored relative to the font size. Returns + the ratio or an error when the font size is not available." + [line-height font-size] + (if (and (sym/number-with-unit? line-height) (= "px" (:unit line-height))) + (if (number? font-size) + {:value (/ (sym/value line-height) font-size)} + {:error (wte/error-with-value :error.style-dictionary/composite-line-height-needs-font-size font-size)}) + {:value (symbol->value line-height)})) -;; Processors ------------------------------------------------------------------ -;; The processor resolves tokens -;; resolved/error tokens get put back into a clojure structure directly during build time -;; For updating tokens we use the `TokenResolver` crud methods from the processing result -;; The `TokenResolver` has detailed information for each tokens dependency graph +(defn- typography->value + "Converts a resolved typography composite, adjusting the fields the + text attributes expect. With `line-height-ratio` a line height in + pixels is converted relative to the font size." + [s line-height-ratio] + (let [value (symbol->value s)] + (if-not (map? value) + {:value value} + (let [value (cond-> value + (contains? value :font-family) (update :font-family font-family->vector) + (contains? value :font-weight) (update :font-weight words->string)) + line-height (when (and line-height-ratio (sym/token? s)) + (get (sym/value s) "line-height"))] + (if (some? line-height) + (let [{ratio :value error :error} (line-height->ratio line-height (:font-size value))] + (if error + {:error error} + {:value (assoc value :line-height ratio)})) + {:value value}))))) -(defn create-token-builder - "Collects resolved tokens during build time into a clojure structure. - Returns Tokenscript Symbols in `:resolved-value` key." - [tokens] - (let [output (volatile! tokens) +(defn- convert-value + "Converts the resolved symbol of a token to the value shape expected by + its type. Returns `{:value v}` or `{:error e}`. Options: - ;; When a token is resolved (No parsing / reference errors) we assing `:resolved-value` for the original token - on-resolve - (fn [^js/String token-name ^js/Symbol resolved-symbol] - (vswap! output assoc-in [token-name :resolved-value] resolved-symbol)) + :line-height-ratio convert typography line heights in pixels to a + ratio of the font size (default true)" + ([type s] (convert-value type s nil)) + ([type s {:keys [line-height-ratio] :or {line-height-ratio true}}] + (case type + :typography (typography->value s line-height-ratio) + :font-family {:value (font-family->vector (symbol->value s))} + :font-weight {:value (words->string (symbol->value s))} + {:value (symbol->value s)}))) - ;; When a token contains any errors we assing `:errors` for the original token - on-error - (fn [^js/String token-name ^js/Error _error ^js/String _original-value] - (let [value (get tokens token-name) - default-error [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]] - (vswap! output assoc-in [token-name :errors] default-error))) +(defn- convert-token-value + "`convert-value` for a token: a failing conversion (e.g. a color that + cannot be written as hex) is an error of that token, not of the whole + resolution." + [{:keys [type value]} s] + (try + (convert-value type s) + (catch :default _ + {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)}))) - ;; Extract the atom value - get-result - (fn [] @output)] - #js {:onResolve on-resolve - :onError on-error - :getResult get-result})) +(defn plugin-value + "Resolved value of a token as exposed by the plugin API: like + `:resolved-value` but with typography line heights left as resolved." + [{:keys [type resolved-symbol]}] + (when (some? resolved-symbol) + (:value (convert-value type resolved-symbol {:line-height-ratio false})))) + +(defn- validate-value + "Returns a Penpot error for the resolved `value` of `token`, or nil when + it is acceptable. The interpreter validates the kind of value of each + type; these are the Penpot specific limits on top." + [{:keys [type]} value] + (if (and (number? value) (cto/number-out-of-bounds? value)) + (wte/error-with-value :error.token/number-too-large value) + (case type + :text-decoration + (when-not (and (string? value) (cto/valid-text-decoration value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value)) + + :font-weight + (when-not (cto/valid-font-weight-variant (str value)) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value)) + + :font-family + (when-not (or (string? value) (and (sequential? value) (every? string? value))) + (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value)) + + nil))) + +;; Errors --------------------------------------------------------------------- + +(defn- missing-reference-of + "Missing token name behind an issue, if the root cause is a missing + token." + [{:keys [code data]}] + (case code + :processor/token-not-found (:token-name data) + :interpreter/unknown-reference (:reference data) + :processor/dependency-error (let [cause (:cause data)] + (when (= :processor/token-not-found (:code cause)) + (get-in cause [:data :token-name]))) + nil)) + +(defn- circular? + [{:keys [code data]}] + (or (= :processor/circular-dependency code) + (= :processor/circular-dependency (get-in data [:cause :code])))) + +(defn- issue->error + "Maps a tokenscript issue to a Penpot token error. The interpreter only + reports codes and data; the messages are Penpot's. Codes without a + message of their own are an invalid value." + [{:keys [code data] :as issue} value] + (cond + (circular? issue) + (wte/error-with-value :error.token/circular-reference nil) + + (missing-reference-of issue) + (wte/error-with-value :error.style-dictionary/missing-reference [(missing-reference-of issue)]) + + :else + (case code + (:operations/division-by-zero :functions/division-by-zero) + (wte/error-with-value :error.token/division-by-zero nil) + + :operations/cannot-mix-units + (wte/error-with-value :error.token/incompatible-units (:units data)) + + :operations/cannot-compare-units + (wte/error-with-value :error.token/incompatible-units [(:unit-a data) (:unit-b data)]) + + :interpreter/unknown-function + (wte/error-with-value :error.token/unknown-function (:function-name data)) + + (:functions/argument-out-of-range :functions/invalid-base) + (wte/error-with-value :error.token/function-argument-out-of-range (:function-name data)) + + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- validation-error + "Maps a failed token type validation of the interpreter (an issue with + an `INVALID__` code) to the Penpot error of the token's + type." + [{:keys [code]} {:keys [type value]}] + (case type + :opacity (wte/error-with-value :error.style-dictionary/invalid-token-value-opacity value) + :stroke-width (wte/error-with-value :error.style-dictionary/invalid-token-value-stroke-width value) + :text-case (wte/error-with-value :error.style-dictionary/invalid-token-value-text-case value) + :text-decoration (wte/error-with-value :error.style-dictionary/invalid-token-value-text-decoration value) + :font-weight (wte/error-with-value :error.style-dictionary/invalid-token-value-font-weight value) + :font-family (wte/error-with-value :error.style-dictionary/invalid-token-value-font-family value) + :typography (wte/error-with-value :error.style-dictionary/invalid-token-value-typography value) + :shadow (wte/error-with-value :error.style-dictionary/invalid-token-value-shadow value) + (if (= "INVALID_NUMBER_UNIT" code) + (wte/error-with-value :error.style-dictionary/value-with-units value) + (wte/error-with-value :error.style-dictionary/invalid-token-value value)))) + +(defn- token-errors + [issues token] + (->> issues + (keep (fn [issue] + (cond + (tse/error-map? issue) (issue->error issue (:value token)) + (= :tokenscript/issue (:type issue)) (validation-error issue token)))) + (seq))) + +;; Resolution ----------------------------------------------------------------- (defn- quote-font-family - "Quotes a font family name so tokenscript reads it as one string instead of - parsing its words (`Red` would become a color, `2P` a number and a word). - Entries with a token reference or already quoted are kept as they are." + "Quotes a font family name so tokenscript reads it as one string instead + of parsing its words (`Red` would become a color, `2P` a number and a + word). Entries with a token reference or already quoted are kept." [family] (cond (or (not (string? family)) @@ -183,49 +272,86 @@ (mapv quote-font-family value) value)) -(defn- token-value->tokenscript-value - [type value] - (case type - :font-family (quote-font-families value) - :typography (cond-> value - (map? value) (d/update-when :font-family quote-font-families)) - value)) - -(defn clj->token->tokenscript-token - "Convert penpot token into a format that tokenscript can handle." +(defn- token->input + "Token data for the interpreter. Types are passed by their DTCG names, + which the interpreter's bundled token types validate." [{:keys [type value]}] - #js {"$type" (name type) - "$value" (clj->js (token-value->tokenscript-value type value))}) + (cond-> {:value (case type + :font-family (quote-font-families value) + :typography (cond-> value + (map? value) (d/update-when :font-family quote-font-families)) + value)} + (some? type) (assoc :type (get cto/token-type->dtcg-token-type type (name type))))) -(defn clj-tokens->tokenscript-tokens - "Convert penpot map of tokens into tokenscript map structure. - tokenscript accepts a map of [token-name {\"$type\": string, \"$value\": any}]" +(defn- empty-value? + [token] + (nil? (:value token))) + +(defn- resolved-token + "Adds the result of the interpreter to `token`: `:resolved-value` and + `:resolved-symbol` for the resolved symbol `s`, or `:errors` for its + `issues`, a failing conversion or a value Penpot doesn't accept." + [token issues s] + (if-let [errors (token-errors issues token)] + (assoc token :errors (vec errors)) + (let [{:keys [value error]} (convert-token-value token s)] + (if-let [error (or error (validate-value token value))] + (assoc token :errors [error]) + (assoc token + :resolved-value value + :resolved-symbol s))))) + +(defn- resolve-tokens* [tokens] - (let [token-map (js/Map.)] - (doseq [[k token] tokens] - (.set token-map k (clj->token->tokenscript-token token))) - token-map)) + (let [tpoint (ct/tpoint-ms) + inputs (into {} + (comp (remove (fn [[_ token]] (empty-value? token))) + (map (fn [[name token]] [name (token->input token)]))) + tokens) + result (tsc/build inputs) + resolved (:tokens result) + issues (:issues result) + output (reduce-kv + (fn [acc name token] + (assoc acc name + (if (empty-value? token) + (assoc token :errors [(wte/get-error-code :error.token/empty-input)]) + (resolved-token token (get issues name) (get resolved name))))) + {} + tokens)] + (l/dbg :hint "tokenscript/resolve-tokens" :elapsed (tpoint)) + output)) -(defn process-tokens - "Builds tokens using `tokenscript`." +;; Several components resolve the same token maps on every change (the +;; sidebar, the forms and the propagation each compute the active tokens +;; separately), so the results of the last few maps are kept. Entries are +;; matched by identity first and by equality second: comparing two maps +;; of a few hundred tokens is far cheaper than resolving them again. +(def ^:private cache-size 8) + +(defonce ^:private results-cache (volatile! [])) + +(defn- cached-result [tokens] - (let [input (clj-tokens->tokenscript-tokens tokens) - result (processTokens input #js {:config config - :builder (create-token-builder tokens)})] - result)) + (let [entries @results-cache] + (or (some (fn [[t result]] (when (identical? t tokens) result)) entries) + (some (fn [[t result]] (when (= t tokens) result)) entries)))) -(defn update-token +(defn- remember-result + [tokens result] + (vswap! results-cache (fn [entries] (vec (take cache-size (cons [tokens result] entries)))))) + +(defn resolve-tokens + "Resolves `tokens` (a map of token name to token). Returns the tokens + with `:resolved-value` or `:errors` added." + [tokens] + (or (cached-result tokens) + (let [result (resolve-tokens* tokens)] + (remember-result tokens result) + result))) + +(defn resolve-token + "Resolves `token` against `tokens` (the token replaces any token with + the same name). Returns the resolved token." [tokens token] - (let [result (process-tokens tokens) - resolver (.-resolver result)] - (.updateToken resolver #js {:tokenPath (:name token) - :tokenData (clj->token->tokenscript-token token)}))) - -;; Main ------------------------------------------------------------------------ - -(defn resolve-tokens [tokens] - (let [tpoint (ct/tpoint-ms) - result (process-tokens tokens) - elapsed (tpoint)] - (l/dbg :hint "tokenscript/resolve-tokens" :elapsed elapsed) - (.-output result))) + (get (resolve-tokens (assoc tokens (:name token) token)) (:name token))) diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 6b667e3631..3ab4427ce6 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -735,9 +735,6 @@ any-variant? (->> shapes vals (some ctk/is-variant?) boolean) resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value) tokenized-attributes (cfo/attributes-map attributes token) type (:type token)] (rx/concat diff --git a/frontend/src/app/main/data/workspace/tokens/color.cljs b/frontend/src/app/main/data/workspace/tokens/color.cljs index a713e76d0f..7112ca8255 100644 --- a/frontend/src/app/main/data/workspace/tokens/color.cljs +++ b/frontend/src/app/main/data/workspace/tokens/color.cljs @@ -7,9 +7,7 @@ (ns app.main.data.workspace.tokens.color (:require [app.common.files.tokens :as cfo] - [app.config :as cf] - [app.main.data.tinycolor :as tinycolor] - [app.main.data.tokenscript :as ts])) + [app.main.data.tinycolor :as tinycolor])) (defn color-bullet-color [token-color-value] (when-let [tc (tinycolor/valid-color token-color-value)] @@ -19,8 +17,5 @@ (tinycolor/->hex-string tc)))) (defn resolved-token-bullet-color [{:keys [resolved-value] :as token}] - (if (contains? cf/flags :tokenscript) - (when (and resolved-value (ts/color-symbol? resolved-value)) - (ts/color-symbol->penpot-color resolved-value)) - (when (and resolved-value (cfo/color-token? token)) - (color-bullet-color resolved-value)))) + (when (and resolved-value (cfo/color-token? token)) + (color-bullet-color resolved-value))) diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index bb4d06ded7..daa27111c8 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -57,6 +57,22 @@ {:error/code :error.token/number-too-large :error/fn #(str (tr "errors.tokens.number-too-large" %))} + :error.token/division-by-zero + {:error/code :error.token/division-by-zero + :error/fn #(tr "errors.tokens.division-by-zero")} + + :error.token/incompatible-units + {:error/code :error.token/incompatible-units + :error/fn #(tr "errors.tokens.incompatible-units" (str/join ", " %))} + + :error.token/unknown-function + {:error/code :error.token/unknown-function + :error/fn #(tr "errors.tokens.unknown-function" %)} + + :error.token/function-argument-out-of-range + {:error/code :error.token/function-argument-out-of-range + :error/fn #(tr "errors.tokens.function-argument-out-of-range" %)} + ;; Surfaced when a token name conflicts with a token-group prefix ;; across the active sets (e.g. one set defines `a`, another set ;; defines `a.b`). DTCG/StyleDictionary cannot represent both as diff --git a/frontend/src/app/main/data/workspace/tokens/format.cljs b/frontend/src/app/main/data/workspace/tokens/format.cljs index ada74c1df8..759399c3d1 100644 --- a/frontend/src/app/main/data/workspace/tokens/format.cljs +++ b/frontend/src/app/main/data/workspace/tokens/format.cljs @@ -1,6 +1,11 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + (ns app.main.data.workspace.tokens.format (:require - [app.main.data.tokenscript :as ts] [cuerdas.core :as str])) (def category-dictionary @@ -35,39 +40,10 @@ (str/join "\n") (str "\n"))) -(defn- format-structured-token - "Formats tokenscript Token" - [token-symbol] - (->> (.-value token-symbol) - (.entries) - (es6-iterator-seq) - (format-map-entries))) - -(defn format-tokenscript-symbol - [^js tokenscript-symbol] - (cond - (ts/rem-number-with-unit? tokenscript-symbol) - (str (ts/rem->px tokenscript-symbol) "px") - - (ts/color-symbol? tokenscript-symbol) - (ts/color-symbol->hex-string tokenscript-symbol) - - (ts/structured-record-token? tokenscript-symbol) - (format-structured-token tokenscript-symbol) - - (ts/structured-array-token? tokenscript-symbol) - (str/join "\n" (map format-tokenscript-symbol (.-value tokenscript-symbol))) - - :else - (.toString tokenscript-symbol))) - (defn format-token-value "Converts token value of any shape to a string." [token-value] (cond - (ts/tokenscript-symbol? token-value) - (format-tokenscript-symbol token-value) - (map? token-value) (format-map-entries token-value) diff --git a/frontend/src/app/main/data/workspace/tokens/propagation.cljs b/frontend/src/app/main/data/workspace/tokens/propagation.cljs index aa43061336..2027e5be42 100644 --- a/frontend/src/app/main/data/workspace/tokens/propagation.cljs +++ b/frontend/src/app/main/data/workspace/tokens/propagation.cljs @@ -6,7 +6,6 @@ (ns app.main.data.workspace.tokens.propagation (:require - [app.common.data :as d] [app.common.files.changes-builder :as pcb] [app.common.files.helpers :as cfh] [app.common.files.tokens :as cfo] @@ -217,9 +216,7 @@ (when (and tokens-lib tokens-status) (when-let [tokens-tree (cfo/get-tokens-in-active-sets tokens-status tokens-lib)] (->> (if (contains? cf/flags :tokenscript) - (rx/of (-> (ts/resolve-tokens tokens-tree) - (d/update-vals #(update % :resolved-value - (partial ts/resolved-value->penpot-unit (:type %)))))) + (rx/of (ts/resolve-tokens tokens-tree)) (sd/resolve-tokens tokens-tree)) (rx/mapcat (fn [sd-tokens] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs index 1e8d9a6198..477d7327ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs @@ -81,10 +81,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (first errors)}))))))))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index b3dbec0c94..a98e51ce74 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -58,10 +58,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs index 48d8586565..7833bd95ea 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs @@ -76,10 +76,7 @@ (sd/resolve-tokens-interactive tokens)) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/resolved-value->penpot-unit (:type token) resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs index 8264836980..b7dd512b7f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs @@ -144,16 +144,25 @@ (defn- resolve-value-tokenscript - [tokens prev-token value] - (let [result (ts/update-token tokens (assoc prev-token :value value)) - token-result (.-resolved result)] + [tokens prev-token token-name value] + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token + (assoc prev-token + :value value + :name (if (or (not valid-token-name?) (str/blank? token-name)) + "__PENPOT__TOKEN__NAME__PLACEHOLDER__" + token-name)) + + {:keys [errors resolved-value]} + ;; Remove previous token when renaming a token + (ts/resolve-token (dissoc tokens (:name prev-token)) token)] (rx/of - (cond - (ts/processor-error? token-result) {:error (wte/error-with-value :error.style-dictionary/missing-reference (some->> (.-dependencyChain token-result) - (seq) - (rest)))} - (instance? js/Error token-result) {:error (wte/error-with-value :error.style-dictionary/invalid-token-value value)} - :else {:value token-result})))) + (if (seq errors) + {:error (first errors)} + {:value resolved-value})))) (defn- resolve-value [tokens prev-token token-name value] @@ -178,10 +187,7 @@ (sd/resolve-tokens-interactive) (rx/mapcat (fn [resolved-tokens] - (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) - resolved-value (if (contains? cf/flags :tokenscript) - (ts/tokenscript-symbols->penpot-unit resolved-value) - resolved-value)] + (let [{:keys [errors resolved-value]} (get resolved-tokens (:name token))] (if resolved-value (rx/of {:value resolved-value}) (rx/of {:error (if errors diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs index 428d2fee31..717e37f831 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/validators.cljs @@ -44,10 +44,7 @@ (sd/resolve-tokens-interactive tokens')) (rx/mapcat (fn [resolved-tokens] - (let [resolved-token (cond-> (get resolved-tokens (:name token)) - (contains? cf/flags :tokenscript) - (update :resolved-value - (partial ts/resolved-value->penpot-unit (:type token))))] + (let [resolved-token (get resolved-tokens (:name token))] (cond (:resolved-value resolved-token) (rx/of resolved-token) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index cdb8d89284..d98c481028 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -111,7 +111,7 @@ (defn- generate-tooltip "Generates a tooltip for a given token" - [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set is-name-collision errors] + [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set error-message] (let [{:keys [name type resolved-value value]} token resolved-value-theme (:resolved-value theme-token) resolved-value (or resolved-value-theme resolved-value) @@ -141,8 +141,8 @@ ref-not-in-active-set (tr "workspace.tokens.ref-not-valid" name) - is-name-collision - (wte/resolve-error-message (first errors)) + error-message + error-message no-valid-value (tr "workspace.tokens.value-not-valid") @@ -237,9 +237,11 @@ is-viewer? (not can-edit) + tokenscript? (contains? cf/flags :tokenscript) + ref-not-in-active-set - (if (contains? cf/flags :tokenscript) - (seq (:errors resolved-token)) + (if tokenscript? + (wte/has-error-code? :error.style-dictionary/missing-reference errors) (and is-reference? (not (contains-reference-value? value active-theme-tokens)))) @@ -250,6 +252,11 @@ no-valid-value (seq errors) + ;; Tokenscript errors carry their own message, as in the edit form. + error-message + (when (and no-valid-value (or tokenscript? name-collision)) + (wte/resolve-error-message (first errors))) + errors? (or ref-not-in-active-set no-valid-value @@ -297,11 +304,11 @@ on-hover (mf/use-fn - (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set name-collision errors) + (mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set error-message) (fn [_] (let [theme-token (get active-theme-tokens name) title (generate-tooltip is-viewer? (first selected-shapes) theme-token token - half-applied? no-valid-value ref-not-in-active-set name-collision errors)] + half-applied? no-valid-value ref-not-in-active-set error-message)] (reset! tooltip-content* title)))) ;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped @@ -346,9 +353,8 @@ [:> icon* {:icon-id i/broken-link :class (stl/css :token-pill-icon) - :aria-label (if name-collision - (wte/resolve-error-message (first errors)) - (tr "workspace.tokens.missing-reference"))}] + :aria-label (or error-message + (tr "workspace.tokens.missing-reference"))}] color [:> swatch* {:background color diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index a558870f95..737099002e 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -232,14 +232,12 @@ k)) (defn- shadow-entry->js - "Converts one resolved shadow entry (a JS Map of field name -> tokenscript - symbol) into a plain JS object using the public member names and the - unit-converted values." - [^js m] + "Converts one resolved shadow entry (a map of field name -> value) into + a plain JS object using the public member names." + [m] (let [out #js {}] - (.forEach m (fn [sym k] - (obj/set! out (shadow-key->camel k) - (ts/tokenscript-symbols->penpot-unit sym)))) + (doseq [[k v] m] + (obj/set! out (shadow-key->camel (name k)) (clj->js v))) out)) (defn- shadow-resolved-value->js @@ -249,38 +247,29 @@ (when (some? entries) (into-array (map shadow-entry->js entries)))) -(defn- font-families-resolved-value->js - "Converts a resolved fontFamilies value (a tokenscript list symbol) into the - documented `string[]` shape." - [resolved-value] - (some-> (ts/font-family-symbols->penpot-unit resolved-value) - (clj->js))) - (defn- get-resolved-value [token tokens-tree] (let [resolved-tokens (ts/resolve-tokens tokens-tree) - resolved-value (dm/get-in resolved-tokens [(:name token) :resolved-value])] + resolved-token (get resolved-tokens (:name token)) + resolved-value (:resolved-value resolved-token)] (cond (= :font-family (:type token)) - ;; A fontFamilies token resolves to a list of families; expose it as the - ;; documented `string[]` rather than the raw tokenscript list symbol. - (font-families-resolved-value->js resolved-value) + ;; A fontFamilies token resolves to a vector of families; expose it as + ;; the documented `string[]`. + (some-> resolved-value clj->js) (= :typography (:type token)) ;; A typography token resolves to a composite; expose it as the documented - ;; `TokenTypographyValue[]` rather than the raw tokenscript structure. - (typography-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + ;; `TokenTypographyValue[]` with the line height as resolved. + (typography-resolved-value->js (ts/plugin-value resolved-token)) (= :shadow (:type token)) - ;; A shadow token resolves to a list of composites whose entries the - ;; tokenscript unit conversion leaves as raw symbols; expose them as the + ;; A shadow token resolves to a list of composites; expose them as the ;; documented `TokenShadowValue[]`. - (shadow-resolved-value->js - (ts/tokenscript-symbols->penpot-unit resolved-value)) + (shadow-resolved-value->js resolved-value) :else - (ts/tokenscript-symbols->penpot-unit resolved-value)))) + resolved-value))) (defn token-proxy? [p] (obj/type-of? p "TokenProxy")) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 28a0028593..5dd9553f7d 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -110,6 +110,7 @@ [frontend-tests.tokens.style-dictionary-test] [frontend-tests.tokens.token-errors-test] [frontend-tests.tokens.token-path-test] + [frontend-tests.tokens.tokenscript-test] [frontend-tests.tokens.workspace-tokens-remap-test] [frontend-tests.ui.check-updates-test] [frontend-tests.ui.color-token-position-test] @@ -269,6 +270,7 @@ 'frontend-tests.tokens.style-dictionary-test 'frontend-tests.tokens.token-errors-test 'frontend-tests.tokens.token-path-test + 'frontend-tests.tokens.tokenscript-test 'frontend-tests.tokens.logic.tokens-status-test 'frontend-tests.tokens.workspace-tokens-remap-test 'frontend-tests.ui.check-updates-test diff --git a/frontend/test/frontend_tests/tokens/tokenscript_test.cljs b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs new file mode 100644 index 0000000000..f6c1b8e01d --- /dev/null +++ b/frontend/test/frontend_tests/tokens/tokenscript_test.cljs @@ -0,0 +1,184 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.tokens.tokenscript-test + (:require + [app.common.test-helpers.ids-map :as cthi] + [app.common.types.tokens-lib :as ctob] + [app.main.data.tokenscript :as ts] + [cljs.test :as t :include-macros true])) + +(defn- tokens-map + "Tokens map of a library with one set holding `tokens`." + [& tokens] + (let [lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id (cthi/new-id! :core-set) :name "core")))] + (-> (reduce (fn [lib token] (ctob/add-token lib (cthi/id :core-set) (ctob/make-token token))) lib tokens) + (ctob/get-all-tokens-map)))) + +(t/deftest resolve-tokens-test + (t/testing "resolves references and converts units to pixels" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "borderRadius.sm" :value "12px" :type :border-radius} + {:name "borderRadius.md-with-dashes" :value "{borderRadius.sm} * 2" :type :border-radius} + {:name "borderRadius.rem" :value "2rem" :type :border-radius} + {:name "opacity.half" :value "50%" :type :opacity} + {:name "borderRadius.large" :value "123456789012345" :type :border-radius} + {:name "borderRadius.largePx" :value "123456789012345px" :type :border-radius} + {:name "borderRadius.largeFn" :value "{borderRadius.sm} * 200000000" :type :border-radius}))] + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))) + (t/is (= 24 (get-in resolved ["borderRadius.md-with-dashes" :resolved-value]))) + (t/is (= 32 (get-in resolved ["borderRadius.rem" :resolved-value]))) + (t/is (= 0.5 (get-in resolved ["opacity.half" :resolved-value]))) + (doseq [name ["borderRadius.large" "borderRadius.largePx" "borderRadius.largeFn"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.token/number-too-large (get-in resolved [name :errors 0 :error/code]))))))) + +(t/deftest resolve-tokens-colors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "color.red" :value "#ff0000" :type :color} + {:name "color.alias" :value "{color.red}" :type :color} + {:name "color.alpha" :value "#ff000080" :type :color} + {:name "color.named" :value "red" :type :color} + {:name "color.light" :value "lighten({color.red}, 0.2)" :type :color}))] + (t/is (= "#ff0000" (get-in resolved ["color.alias" :resolved-value]))) + (t/is (= "#ff000080" (get-in resolved ["color.alpha" :resolved-value])) "alpha is kept as hex8") + (t/is (= "#FF0000" (get-in resolved ["color.named" :resolved-value])) "css names resolve") + (t/is (string? (get-in resolved ["color.light" :resolved-value])) "color functions resolve to hex"))) + +(t/deftest resolve-tokens-errors-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "{missing}" :type :border-radius} + {:name "b" :value "{a} + 1" :type :border-radius} + {:name "x" :value "{y}" :type :border-radius} + {:name "y" :value "{x}" :type :border-radius} + {:name "z" :value "{x} + 1" :type :border-radius} + {:name "bad" :value "1 ´ 2" :type :border-radius}))] + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["a" :errors 0 :error/code]))) + (t/is (= ["missing"] (get-in resolved ["a" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/missing-reference (get-in resolved ["b" :errors 0 :error/code])) + "dependents report the missing token") + (t/is (= ["missing"] (get-in resolved ["b" :errors 0 :error/value]))) + (t/is (= :error.token/circular-reference (get-in resolved ["x" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["y" :errors 0 :error/code]))) + (t/is (= :error.token/circular-reference (get-in resolved ["z" :errors 0 :error/code])) + "dependents of a cycle report the cycle") + (t/is (= :error.style-dictionary/invalid-token-value (get-in resolved ["bad" :errors 0 :error/code]))))) + +(t/deftest resolve-tokens-error-codes-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "zero" :value "1 / 0" :type :number} + {:name "mod-zero" :value "mod(4, 0)" :type :number} + {:name "units" :value "1px * 2em" :type :sizing} + {:name "fn" :value "foo(1)" :type :number} + {:name "ln" :value "ln(0)" :type :number} + {:name "ref" :value "{zero} + 1" :type :number})) + error (fn [name] (select-keys (get-in resolved [name :errors 0]) [:error/code :error/value]))] + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "zero"))) + (t/is (= {:error/code :error.token/division-by-zero :error/value nil} (error "mod-zero"))) + (t/is (= {:error/code :error.token/incompatible-units :error/value ["px" "em"]} (error "units"))) + (t/is (= {:error/code :error.token/unknown-function :error/value "foo"} (error "fn"))) + (t/is (= {:error/code :error.token/function-argument-out-of-range :error/value "ln"} (error "ln"))) + (t/is (= {:error/code :error.style-dictionary/invalid-token-value :error/value "{zero} + 1"} (error "ref")) + "a token failing through its reference is an invalid value"))) + +;; A token named "a" and a token named "a.b" can coexist: `{a}` resolves +;; to the token, `{a.b}` to the nested one (#9584). +(t/deftest resolve-tokens-name-collision-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "a" :value "8px" :type :border-radius} + {:name "a.b" :value "12px" :type :border-radius} + {:name "c" :value "{a} + {a.b}" :type :border-radius}))] + (t/is (= 8 (get-in resolved ["a" :resolved-value]))) + (t/is (= 12 (get-in resolved ["a.b" :resolved-value]))) + (t/is (= 20 (get-in resolved ["c" :resolved-value]))))) + +(t/deftest resolve-tokens-composites-test + (t/testing "typography and shadow tokens resolve field by field" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size" :value "16" :type :font-size} + {:name "typo" :type :typography + :value {:font-family ["Roboto" "Arial"] + :font-size "{size} * 2" + :font-weight "700 Italic" + :line-height "48px"}} + {:name "shadow" :type :shadow + :value [{:offset-x "2" :offset-y "{size}" :blur "4" :spread "0" + :color "#000" :inset false}]}))] + (t/is (= {:font-family ["Roboto" "Arial"] :font-size 32 :font-weight "700 Italic" :line-height 1.5} + (get-in resolved ["typo" :resolved-value])) + "line heights in pixels become a ratio of the font size") + (t/is (= [{:offset-x 2 :offset-y 16 :blur 4 :spread 0 :color "#000" :inset false}] + (get-in resolved ["shadow" :resolved-value]))))) + (t/testing "font families referencing a font family token are flattened" + (let [resolved (ts/resolve-tokens + (tokens-map {:name "families" :value ["Arial" "sans-serif"] :type :font-family} + {:name "single" :value "Roboto" :type :font-family} + {:name "typo" :value {:font-family ["{families}"]} :type :typography}))] + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["families" :resolved-value]))) + (t/is (= ["Roboto"] (get-in resolved ["single" :resolved-value]))) + (t/is (= ["Arial" "sans-serif"] (get-in resolved ["typo" :resolved-value :font-family]))))) + (t/testing "a typography token with an array value is invalid" + (let [resolved (ts/resolve-tokens (tokens-map {:name "typography.bad" :value ["Roboto"] :type :typography}))] + (t/is (nil? (get-in resolved ["typography.bad" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-typography + (get-in resolved ["typography.bad" :errors 0 :error/code])))))) + +(t/deftest resolve-tokens-value-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "opacity.bad" :value "2" :type :opacity} + {:name "stroke.bad" :value "-1" :type :stroke-width} + {:name "case.ok" :value "uppercase" :type :text-case} + {:name "case.bad" :value "wat" :type :text-case} + {:name "weight.ok" :value "bold" :type :font-weight} + {:name "weight.bad" :value "heavyish" :type :font-weight}))] + (t/is (= :error.style-dictionary/invalid-token-value-opacity (get-in resolved ["opacity.bad" :errors 0 :error/code]))) + (t/is (= :error.style-dictionary/invalid-token-value-stroke-width (get-in resolved ["stroke.bad" :errors 0 :error/code]))) + (t/is (= "uppercase" (get-in resolved ["case.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-text-case (get-in resolved ["case.bad" :errors 0 :error/code]))) + (t/is (= "bold" (get-in resolved ["weight.ok" :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value-font-weight (get-in resolved ["weight.bad" :errors 0 :error/code]))))) + +;; The interpreter validates the kind of value of each token type: `1foo` +;; and `1 2` are lists, not numbers. +(t/deftest resolve-tokens-type-validation-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "size.glued" :value "1foo" :type :dimensions} + {:name "size.list" :value "1 2" :type :sizing} + {:name "size.ok" :value "1rem" :type :sizing} + {:name "number.glued" :value "1foo" :type :number} + {:name "number.unit" :value "10px" :type :number} + {:name "number.ok" :value "1.5" :type :number} + {:name "opacity.percent" :value "150%" :type :opacity} + {:name "typography.bad" :value {:font-size "1foo" :line-height "1.5"} :type :typography} + {:name "shadow.bad" :value "8" :type :shadow})) + code (fn [name] (get-in resolved [name :errors 0 :error/code]))] + (doseq [name ["size.glued" "size.list" "number.glued"]] + (t/is (nil? (get-in resolved [name :resolved-value]))) + (t/is (= :error.style-dictionary/invalid-token-value (code name)))) + (t/is (= "1foo" (get-in resolved ["size.glued" :errors 0 :error/value]))) + (t/is (= :error.style-dictionary/value-with-units (code "number.unit"))) + (t/is (= :error.style-dictionary/invalid-token-value-opacity (code "opacity.percent"))) + (t/is (= :error.style-dictionary/invalid-token-value-typography (code "typography.bad"))) + (t/is (= :error.style-dictionary/invalid-token-value-shadow (code "shadow.bad"))) + (t/is (= 16 (get-in resolved ["size.ok" :resolved-value]))) + (t/is (= 1.5 (get-in resolved ["number.ok" :resolved-value]))))) + +;; A token with a `nil` value (e.g. a composite typography token saved via +;; the workspace form with no fields filled in) is tagged with an empty +;; input error and does not affect the other tokens. +(t/deftest resolve-tokens-nil-value-test + (let [resolved (ts/resolve-tokens + (tokens-map {:name "typography.empty" :value nil :type :typography} + {:name "borderRadius.sm" :value "12px" :type :border-radius}))] + (t/is (nil? (get-in resolved ["typography.empty" :resolved-value]))) + (t/is (= :error.token/empty-input (get-in resolved ["typography.empty" :errors 0 :error/code]))) + (t/is (= 12 (get-in resolved ["borderRadius.sm" :resolved-value]))))) + +(t/deftest resolve-token-test + (let [tokens (tokens-map {:name "a" :value "8px" :type :border-radius}) + resolved (ts/resolve-token tokens {:name "b" :value "{a} * 2" :type :border-radius})] + (t/is (= 16 (:resolved-value resolved))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 040339f8ef..5c22390bbd 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1805,6 +1805,10 @@ msgstr "Token has circular reference" msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Invalid token value: division by zero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "This field cannot be empty" @@ -1817,10 +1821,18 @@ msgstr "Token value cannot be empty" msgid "errors.tokens.error-parse" msgstr "Import Error: Could not parse JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Invalid token value: the argument of %s() is out of range" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Import Error:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Invalid token value: the units %s cannot be combined" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Invalid color value: %s" @@ -1914,6 +1926,10 @@ msgstr "Shadow spread must be greater than or equal to 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width must be greater than or equal to 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Invalid token value: unknown function %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Invalid value: % is not allowed." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index e369ed0b12..4900d82796 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1837,6 +1837,10 @@ msgstr "" "El Line Height depende del Font Size. Añade un Font Size para obtener el " "valor computado." +#: src/app/main/data/workspace/tokens/errors.cljs:62 +msgid "errors.tokens.division-by-zero" +msgstr "Valor de token no válido: división por cero" + #: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:350, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:436 msgid "errors.tokens.empty-field" msgstr "Este campo no puede estar vacío" @@ -1849,10 +1853,18 @@ msgstr "El valor del token no puede estar vacío" msgid "errors.tokens.error-parse" msgstr "Error al importar: No se pudo procesar el JSON." +#: src/app/main/data/workspace/tokens/errors.cljs:74 +msgid "errors.tokens.function-argument-out-of-range" +msgstr "Valor de token no válido: el argumento de %s() está fuera de rango" + #: src/app/main/data/workspace/tokens/errors.cljs:33, src/app/main/data/workspace/tokens/errors.cljs:38 msgid "errors.tokens.import-error" msgstr "Error al importar:" +#: src/app/main/data/workspace/tokens/errors.cljs:66 +msgid "errors.tokens.incompatible-units" +msgstr "Valor de token no válido: las unidades %s no se pueden combinar" + #: src/app/main/data/workspace/tokens/errors.cljs:54 msgid "errors.tokens.invalid-color" msgstr "Valor de color inválido: %s" @@ -1947,6 +1959,10 @@ msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." msgid "errors.tokens.stroke-width-range" msgstr "Stroke width debe ser mayor o igual a 0." +#: src/app/main/data/workspace/tokens/errors.cljs:70 +msgid "errors.tokens.unknown-function" +msgstr "Valor de token no válido: función desconocida %s" + #: src/app/main/data/workspace/tokens/errors.cljs:84, ../common/src/app/common/files/tokens.cljc:52 msgid "errors.tokens.value-with-percent" msgstr "Valor no válido: no se permite %."