iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎨

Unifying Web, Mobile, and Email Color Themes into a Single Package in a Personal Monorepo Project

に公開

Introduction

I am developing a diary application called Storyie as a personal project. The tech stack is a pnpm monorepo consisting of Next.js (Web) and Expo (Mobile).

As development progressed, color definitions started to scatter everywhere. I found myself using slightly different shades of "ochre" in Tailwind CSS for the Web, StyleSheet for Expo, highlights for the Lexical editor, and email templates, leading to frequent moments of "Wait, where was this color defined?"

To solve this, I created an internal package called @storyie/theme, which centralizes everything from the color palette and semantic tokens to CSS variable generation. In this article, I will introduce its design and how it is utilized across different platforms.

🗂️ Package Structure

packages/theme/src/
├── index.ts        # Entry point (re-export)
├── palette.ts      # Primitive color values
├── tokens.ts       # Semantic tokens (light / dark)
├── css.ts          # CSS variable generation utility
├── lexical.ts      # Colors exclusive to Lexical editor
├── email.ts        # Colors exclusive to email templates
└── types.ts        # Type definitions

The roles are clearly separated:

  • 📦 palette — Raw color values like ochre[50] to ochre[950]. The "source of truth" for brand colors.
  • 🏷️ tokens — Meaningful names like background, primaryButton, and border. Includes both light and dark modes.
  • 🌐 css — Automatically generates CSS custom properties from tokens.
  • ✏️ lexical — Used for code highlighting and blockquotes within the editor.
  • 📧 email — A separate system designed with email client compatibility in mind.

🎨 Two-Layer Color Design

The key to this design is splitting color management into two layers: "Palette" and "Tokens."

The palette layer contains only pure color values: the ochre scale, grays, status colors, etc. There is no semantics here; the palette itself doesn't know what ochre[600] is used for.

The token layer references the palette and assigns meanings, such as "this color is for the button background" or "this color is for borders." It also handles dark mode implementation.

// tokens.ts (excerpt)
export const tokens = {
  light: {
    background: palette.ochre[50],
    primaryButton: palette.ochre[600],
    border: palette.ochre[200],
    // ...
  },
  dark: {
    background: palette.ochre[950],
    primaryButton: palette.ochre[300],
    border: palette.ochre[800],
    // ...
  },
} as const;

If I want to change the brand color, I only need to update the palette. The structure of the tokens remains untouched, which has been incredibly helpful.

🌐 How to Use on the Web (Next.js)

Since the Web side uses Tailwind CSS, I connect the colors via CSS custom properties.

@storyie/theme/css contains a function called generateFullCss(), which outputs CSS for :root and .dark selectors based on the tokens. I have a setup where a script runs before the build to generate theme-vars.css.

pnpm generate:theme
↓
packages/theme tokens → CSS variables → app/theme-vars.css
↓
Reference var(--primary-button) etc. in Tailwind configuration

While you can import tokens directly at runtime, using CSS variables makes it easy to use the same colors in both Server Components and Client Components.

📱 How to Use in Expo (React Native)

Since React Native does not support CSS variables, I import the tokens directly and use them as is.

// apps/expo/constants/Colors.ts
import { getLexicalColors, tokens } from "@storyie/theme";

const Colors = {
  light: {
    ...tokens.light,
    lexical: getLexicalColors("light"),
  },
  dark: {
    ...tokens.dark,
    lexical: getLexicalColors("dark"),
  },
};

Code on the Expo side is required to reference only this Colors object. Individual components never touch the palette directly.

I also merge colors for the Lexical editor (which is displayed as a Web View via use dom) here, ensuring consistency between the editor's appearance and the main app's theme.

✏️ Colors Exclusive to the Lexical Editor

Lexical code highlighting requires fine-grained color specification, which semantic tokens alone cannot cover. getLexicalColors() returns all necessary colors for blockquotes, headings, hashtags, links, and code highlighting for each scheme.

// Usage
const colors = getLexicalColors("dark");
// colors.codeHighlight.function → "#ff9999"
// colors.link → palette.blue[400]

One thing I was particular about here is adjusting the highlight colors for dark mode. Since code highlights intended for light mode can become unreadable in dark mode, I have manually tuned the brightness for each color.

📧 A Separate Color System for Emails

Email templates have their own unique palette for two reasons:

  1. Unstable Dark Mode support in email clients — It is safer to stick to a fixed light theme.
  2. Need to differentiate brand colors from CTA colors — Inside the app, I use ochre (warmth), but for email CTAs, I use indigo (to stand out).
// email.ts (excerpt)
export const emailTokens = {
  text: {
    heading: emailPalette.gray[800],
    body: emailPalette.gray[700],
  },
  button: {
    primary: {
      background: emailPalette.primary[600], // indigo
      text: emailPalette.neutral.white,
    },
  },
};

By keeping email colors as a separate system, theme changes in the app don't affect emails (and vice versa). This separation is intentional.

📦 exports Configuration

I use the exports field in package.json to divide entry points by purpose.

{
  "exports": {
    ".": { "default": "./dist/index.js" },
    "./css": { "default": "./dist/css.js" },
    "./lexical": { "default": "./dist/lexical.js" }
  }
}

@storyie/theme provides tokens and the palette, @storyie/theme/css handles CSS generation, and @storyie/theme/lexical provides editor-specific colors. This avoids unnecessary loading of CSS generation code during Web builds.

💡 Reflections

  • No more confusion about "where to define colors." Just opening palette.ts lets me see all available colors.
  • Dark mode is easier to implement. Filling in the light/dark tokens reflects changes across all platforms.
  • Separating email colors was the right call. It is easier to maintain if you accept that emails are a "different world."
  • I thought a two-layer system (palette and tokens) might be overkill at first, but it saved me when I tweaked the brand colors and didn't have to touch the tokens themselves.
  • Centralizing colors is worth the effort, even for personal projects. "Where was that color again?" happens even to a solo developer.

Conclusion

It might sound grand to call it a "design system," but all I really did was gather color definitions into one file and write connector code for each platform. Being able to use them in a type-safe manner as an internal package in a monorepo is also a huge plus.

Storyie is currently available on the Web and as an Android beta. Feel free to check it out!

Discussion