Namespacing tokens prevents collisions with external systems

Introduction

Tokens or generally CSS variables from external systems can silently override your app tokens. Here’s’ the short story of how to keep your tokens safe. Astro is a great blog engine which enables you to use Vue.js or React components seamlessly. I’ve built a React component and wanted to use Tailwind for it. It turned out that my --color-red-300 has been replaced with the value from Tailwind --color-red-300.

// Shortened for simplicity
---
import AComponent from "@components/AComponent/AComponent";
---

<div class="flex flex-col">
    <AComponent client:load />
</div>

<AComponent /> is importing Tailwind stylesheets:

/* src/components/AComponent/AComponent.css */
@import "tailwindcss/theme.css";
@import "tailwindcss/utilities.css";

It was barely noticeable, but a single @import "tailwindcss/theme.css"; did that:

This was original colour
This was original colour
…and this what happened after importing Tailwind CSS
…and this what happened after importing Tailwind CSS

It happened because Tailwind theme.css defines

--color-red-300: oklch(80.8% 0.114 19.571) whereas app token was: --color-pomegranate-300: oklch(0.56 0.19 25.68);

/* packages/tailwindcss/theme.css */

:root {
  --color-red-50: oklch(97.1% 0.013 17.38);
  --color-red-100: oklch(93.6% 0.032 17.717);
  --color-red-200: oklch(88.5% 0.062 18.334);
  --color-red-300: oklch(80.8% 0.114 19.571);
  --color-red-400: oklch(70.4% 0.191 22.216);
  --color-red-500: oklch(63.7% 0.237 25.331);
  --color-red-600: oklch(57.7% 0.245 27.325);
  --color-red-700: oklch(50.5% 0.213 27.518);
  --color-red-800: oklch(44.4% 0.177 26.899);
  --color-red-900: oklch(39.6% 0.141 25.723);
  --color-red-950: oklch(25.8% 0.092 26.042);
}

As it silently replaces values, it could theoretically replace more without me noticing.

Namespace-first attempt

It just failed.

I asked Claude. I wanted to add just a prefix, but the way Claude conducted that disappointed me as it was trying to inject into the Astro hook and via PostCSS replace app tokens by adding --ryrych- prefix. As it took forever I quit.

Follow your intuition or experience first, instead of lazily following hallucinations. Yeah, facepalm, it happens to me sometimes.

Namespace-second attempt

Having a sub-package is a good idea. Until you need to publish it, just have it locally. And this is what I wanted. Sorry Claude for suggesting, I knew it better. Just do it.

src/packages/design-tokens/build.mjs was a good approach. Just moving all variables defined in :root {…} into a package:

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import postcss from "postcss";

const packageDir = path.dirname(fileURLToPath(import.meta.url));
const srcDir = path.join(packageDir, "src");
const outFile = path.join(packageDir, "dist", "index.css");
const prefix = process.env.TOKENS_PREFIX || "ryrych";

function prefixDecl(decl) {
  decl.prop = `--${prefix}-${decl.prop.slice(2)}`;
  decl.value = decl.value.replaceAll("var(--", `var(--${prefix}-`);
}

const sourceFiles = fs
  .readdirSync(srcDir)
  .filter((file) => file.endsWith(".css"))
  .sort();

const built = sourceFiles
  .map((file) => {
    const sourcePath = path.join(srcDir, file);
    const root = postcss.parse(fs.readFileSync(sourcePath, "utf8"), {
      from: sourcePath,
    });
    root.walkRules(":root", (rule) => rule.walkDecls(/^--/, prefixDecl));
    return `/* ${file} */\n${root.toString().trim()}`;
  })
  .join("\n\n");

fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(
  outFile,
  `/**\n * AUTO-GENERATED by \`npm run build\` — do not edit directly.\n * Edit the source files in ./src instead.\n */\n\n${built}\n`,
);

console.log(
  `built ${path.relative(process.cwd(), outFile)} (prefix: ${prefix})`,
);

It worked.

Namespace-third attempt

It kind of made me dissatisfied. I was writing about Style Dictionary (in Polish) and wanted to use it. It is opinionated stuff and the migration went smoothly:

import path from "node:path";
import { fileURLToPath } from "node:url";
import StyleDictionary from "style-dictionary";

const packageDir = path.dirname(fileURLToPath(import.meta.url));
const buildPath = `${path.join(packageDir, "dist")}/`; // must end with a trailing slash
const prefix = process.env.TOKENS_PREFIX || "ryrych";

const sd = new StyleDictionary({
  source: [
    path.join(packageDir, "tokens/colors.json"),
    path.join(packageDir, "tokens/theme.json"),
    path.join(packageDir, "tokens/typography.json"),
  ],
  log: { verbosity: "silent" },
  platforms: {
    css: {
      transformGroup: "css",
      prefix,
      buildPath,
      files: [
        {
          destination: "index.css",
          format: "css/variables",
          options: {
            outputReferences: true,
            fileHeader: () => [
              "AUTO-GENERATED by `npm run build` — do not edit directly.",
              "Edit the source files in ./tokens instead.",
            ],
          },
        },
      ],
    },
  },
});

await sd.buildAllPlatforms();

const outFile = path.join(buildPath, "index.css");
console.log(
  `built ${path.relative(process.cwd(), outFile)} (prefix: ${prefix})`,
);

I used workspaces:

{
  "workspaces": ["packages/*"]
}

Tokens are build with npm run build:tokens

{
  "scripts": {
    "build:tokens": "npm run build --prefix packages/design-tokens"
  }
}

Tokens defined this way:

// packages/design-tokens/tokens/colors.json
{
  "color": {
    "pomegranate": {
      "100": { "value": "oklch(0.85 0.19 25.68)" },
      "200": { "value": "oklch(0.7 0.19 25.68)" },
      "300": { "value": "oklch(0.56 0.19 25.68)" },
      "400": { "value": "oklch(0.37 0.19 25.68)" },
      "500": { "value": "oklch(0.19 0.19 25.68)" }
    },
    "lime": {
      "100": { "value": "oklch(0.92 0.26 142.5)" },
      "200": { "value": "oklch(0.84 0.26 142.5)" },
      "300": { "value": "oklch(0.76 0.26 142.5)" },
      "400": { "value": "oklch(0.5 0.26 142.5)" },
      "500": { "value": "oklch(0.25 0.26 142.5)" }
    },
    "black": { "value": "#000000" },
    "white": { "value": "#ffffff" },
    "accent": { "value": "#c2feff" },
    "red": {
      "100": { "value": "{color.pomegranate.100}" },
      "200": { "value": "{color.pomegranate.200}" },
      "300": { "value": "{color.pomegranate.300}" },
      "400": { "value": "{color.pomegranate.400}" },
      "500": { "value": "{color.pomegranate.500}" }
    }
  }
}

Lands in dist/index.css exactly the same way until I had problems with Tailwind.

/* packages/design-tokens/dist/index.css */
:root {
  --ryrych-color-pomegranate-100: oklch(0.85 0.19 25.68);
  --ryrych-color-pomegranate-200: oklch(0.7 0.19 25.68);
  --ryrych-color-pomegranate-300: oklch(0.56 0.19 25.68);
  --ryrych-color-pomegranate-400: oklch(0.37 0.19 25.68);
  --ryrych-color-pomegranate-500: oklch(0.19 0.19 25.68);
  --ryrych-color-lime-100: oklch(0.92 0.26 142.5);
  --ryrych-color-lime-200: oklch(0.84 0.26 142.5);
  --ryrych-color-lime-300: oklch(0.76 0.26 142.5);
  --ryrych-color-lime-400: oklch(0.5 0.26 142.5);
  --ryrych-color-lime-500: oklch(0.25 0.26 142.5);
  --ryrych-color-black: #000000;
  --ryrych-color-white: #ffffff;
  --ryrych-color-accent: #c2feff;
  --ryrych-background: #1e1e2e;
  --ryrych-background-light: #fcebf3;
  --ryrych-text: #eff1f5;
  --ryrych-text-light: #1e1e2e;
  --ryrych-text-link: #78c2ad;
  --ryrych-text-link-light: #375a7f;
  --ryrych-underline: #375a7f;
  --ryrych-border-radius-full: calc(infinity * 1px);
  --ryrych-header: "Lato", sans-serif;
  --ryrych-body: "Fira Code", monospace;
  --ryrych-color-red-100: var(--ryrych-color-pomegranate-100);
  --ryrych-color-red-200: var(--ryrych-color-pomegranate-200);
  --ryrych-color-red-300: var(--ryrych-color-pomegranate-300);
  --ryrych-color-red-400: var(--ryrych-color-pomegranate-400);
  --ryrych-color-red-500: var(--ryrych-color-pomegranate-500);
}

Summary

I like to have control over naming conventions. When you are integrating external packages like Tailwind you never know what external code might silently break. Adding a namespace to your tokens can minimize problems. A few weeks ago a GitHub. workflow responsible for screenshot testing was hanging. It was a GitHub. issue, effectively preventing me from creating a new releases. I temporarily removed it. I think it’s time to bring screenshot testing with Playwright back. My eyes might not be enough to spot silent bugs.