README

Operate’s design system

Installation

Note

  • @o/pipeline is currently only available for use within the operate repo.
  1. Add the package to your app or package in your package.json file, installing any necessary peerDependencies:

     "dependencies": {
       "@o/pipeline": "workspace:*",
     }
  2. Add the ESLint config (optional, but recommended)

    Spread @o/pipeline/eslint/config in your app’s eslint.config.mjs after your base config:

    import pipelineConfig from "@o/pipeline/eslint/config";
    
    /** @type {import("eslint").Linter.Config[]} */
    export default [
      // ...baseConfig,
      ...pipelineConfig,
    ];

    This discourages use of className and style on Pipeline components unless you add a comment explaining why.

  3. Add the following to your project’s .gitignore:

    Note

    This should be taken care of already in the monorepo’s .gitignore.

    /public/.pipeline
  4. Add a prepare script in your project’s package.json:

    {
      "scripts": {
        "prepare": "pipeline bootstrap"
      }
    }

    This script copies @o/pipeline’s static assets to your project under /public/.pipeline.

    Note

    In the operate monorepo, consumer packages wrap this with Turbo for caching: see other apps for the preparebootstrapbootstrap:pipeline pattern. @o/pipeline itself runs turbo run build on prepare (generated dist/ output, not the pipeline bootstrap CLI).

  5. Ensure /public/.pipeline are aggressively cached

    {
      "headers": [
        {
          "source": "/.pipeline/(.*)",
          "headers": [
            {
              "key": "Cache-Control",
              "value": "public, max-age=31536000, immutable"
            }
          ]
        }
      ]
    }
  6. Follow TailwindCSS’ official installation steps

  7. Inside your main .css entry point (i.e. where you’re using tailwindcss) import the Pipeline theme and declare as an external source:

       @import "tailwindcss";
    ++ @import "@o/pipeline/theme.css";
    ++ @source "../node_modules/@o/pipeline";
  8. In your root layout, import your main .css entry point (in this example, globals.css), then render Root as the direct child of <body> and place Provider inside it.

    Root establishes a full-size, isolated stacking context for popovers, tooltips, and toasts. It must be the immediate child of <body>: wrapping it in another element (or omitting it) breaks layering and iOS 26 Safari positioning.

    Provider supplies the tooltip, motion, and fireworks context to any Pipeline component rendered underneath it.

    For example, in a Next.js app:

    import { Root } from "@o/pipeline/experimental/core/root";
    import { Provider } from "@o/pipeline/experimental/core/provider";
    import "../styles/globals.css";
    
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <html lang="en">
          <body>
            <Root>
              <Provider>{children}</Provider>
            </Root>
          </body>
        </html>
      );
    }
  9. Unless overriding fontFamily.sans, initialize the fonts.

    Note

    • Next.js localFont config must be string literals (so unfortunately we can’t DRY this up into a shared config)
    • If you aren’t likely to need the italic variant, you can skip it entirely
    • If your font’s aren’t visible above the fold, you can skip the preload option altogether

    For example, in your Next.js root layout.tsx:

       // layout.tsx
       import { Root } from "@o/pipeline/experimental/core/root";
       import { Provider } from "@o/pipeline/experimental/core/provider";
    ++ import { cx } from "@o/pipeline/cva";
    ++ import localFont from "next/font/local";
    
    ++ const muoto = localFont({
    ++   variable: "--font-muoto",
    ++   display: "swap",
    ++   preload: true,
    ++   src: [
    ++     {
    ++       style: "normal",
    ++       weight: "100 900",
    ++       path: "../node_modules/@o/pipeline/src/fonts/    ++ 205TF-Muoto-Variable.woff2",
    ++     },
    ++     {
    ++       style: "italic",
    ++       weight: "100 900",
    ++       path: "../node_modules/@o/pipeline/src/fonts/    ++ 205TF-Muoto-VariableItalic.woff2",
    ++     },
    ++   ],
    ++ });
    
    ++ const geistMono = localFont({
    ++   variable: "--font-geist-mono",
    ++   display: "swap",
    ++   preload: false,
    ++   src: [
    ++     {
    ++       style: "normal",
    ++       weight: "100 900",
    ++       path: "../node_modules/@o/pipeline/src/fonts/    ++ GeistMono-Variable.woff2",
    ++     },
    ++     {
    ++       style: "italic",
    ++       weight: "100 900",
    ++       path: "../node_modules/@o/pipeline/src/fonts/    ++ GeistMono-VariableItalic.woff2",
    ++     },
    ++   ],
    ++ });
    
       export default function RootLayout({
         children,
       }: {
         children: React.ReactNode;
       }) {
         return (
    --     <html lang="en">
    ++     <html lang="en" className={cx(muoto.variable, geistMono.variable)}>
             <body>
               <Root>
                 <Provider>{children}</Provider>
               </Root>
             </body>
           </html>
         );
       }
  10. Set up a framework-appropriate theme provider to handle switching between .light and .dark classes, as well as ensuring theme-color matches the computed style of theme.properties.colors.canvas (from @o/pipeline/theme) on change.

    Ideally, this should respect the user’s system preferences by default, and allow for manual overrides.

  11. Add the Toaster component to your app, ensuring the theme is specified based on your theme provider

    e.g. For Next.js, you may need to create a separate component that references the useTheme hook from your theme provider

    // components/ThemedToaster.tsx
    "use client";
    
    import { Toaster } from "@o/pipeline/experimental/components/toast";
    import { useTheme } from "next-themes";
    import React from "react";
    
    export function ThemedToaster() {
      const { resolvedTheme } = useTheme();
      return (
        <Toaster
          theme={
            resolvedTheme as unknown as React.ComponentProps<
              typeof Toaster
            >["theme"]
          }
        />
      );
    }
       // layout.tsx
       import { Root } from "@o/pipeline/experimental/core/root";
       import { Provider } from "@o/pipeline/experimental/core/provider";
       import localFont from "next/font/local";
    ++ import { ThemedToaster } from "./components/ThemedToaster";
    
       const muoto = localFont({
         src: "../node_modules/@o/pipeline/src/fonts/muoto-regular.woff2",
         variable: "--font-muoto",
       });
    
       export default function RootLayout({
         children,
       }: {
         children: React.ReactNode;
       }) {
         return (
           <html lang="en" className={muoto.variable}>
             <body>
               <Root>
                 <Provider>
                   {children}
    ++             <ThemedToaster />
                 </Provider>
               </Root>
             </body>
           </html>
         );
       }

Architecture

Theme

Tailwind v4 introduced a new CSS-driven approach to theming, marking their previous JS-based configuration as a “legacy” system. Additionally, Tailwind’s JS-based plugin system doesn’t (yet) provide any way to inject styles into the @theme layer.

While Tailwind do provide documentation on how to resolve theme values, it’s approach comes with the following caveats:

  1. It’s browser-only
  2. No type safety
  3. No way to map over all color values (e.g. for something like a “color picker”).

To mitigate this, Pipeline stores its color palette and text scale in src/theme/theme.ts: a unified map behind a createTheme({ root: { colors, text }, dark: { colors } }) call, where dark holds sparse overrides that fall back to root.

Upon pnpm i, this gets compiled into dist/styles/theme.css, allowing for full compatibility with Tailwind (including IntelliSense).

Additionally, Pipeline surfaces these tokens as a theme namespace at @o/pipeline/theme, allowing them to be consumed as:

import { theme } from "@o/pipeline/theme";
  1. Raw root (light) and dark values: dark is pre-merged, so paths without an explicit dark override fall back to the root value (text has no dark values yet)

    theme.root.colors.gray[500]; // "oklch(…)"
    theme.dark.colors.gray[500]; // "oklch(…)"
    theme.dark.colors.cornflower; // ≡ theme.root.colors.cornflower (fallback)
    theme.root.text.base; // { fontSize: 14, lineHeight: 20 }
    theme.root.text.base.lineHeight; // 20
  2. CSS variables

    theme.vars.colors.gray[500]; // "var(--color-gray-500)"
  3. CSS properties

    theme.properties.colors.gray[500]; // "--color-gray-500"

Editing the theme

To rebuild dist/styles/theme.css after changing theme/theme.ts, run pnpm build (or pnpm i).

Components

components/
├── experimental/
│   └── <component-name>/
├── legacy/
│   └── <component-name>/
├── <component-name>/
  • Each top-level components folder specifies a stable, team QA-tested, production-ready component.
  • Components nested within experimental/components/ should be considered as early explorations or prototypes. These components should be used with caution.
  • Components nested within legacy/components/ are deprecated for gradual phase-out. Avoid using them for new features.

Important

Build component primitives on Base UI (@base-ui/react), never Radix. Radix is being removed (see OP-438): do not install or reintroduce @radix-ui/*, cmdk, or vaul. If a primitive isn’t on Base UI yet, build it there or raise it rather than reaching for Radix.

Assets

Suffix all assets with a version number to avoid caching issues.

Documentation

@o/pipeline’s documentation is surfaced at design.operate.so

We use a bespoke Astro-based site to surface each component’s README.mdx file as its own page

Each page should aim to provide a minimum of:

  1. Installation instructions
  2. Use examples (or “stories”)
  3. Design or implementation notes

Writing Stories

All stories should be written in the relevant src/components/component/_stories.tsx file

Each story should be wrapped in the Story component to ensure style and behavior isolation

import { Story } from "@o/pipeline/dx/story";
import { Component } from "@o/pipeline/experimental/components/component";

export function Base() {
  return (
    <Story>
      <Component />
    </Story>
  );
}

Import and render stories in the component’s README.mdx file as follows…

---
title: Component
slug: components/component
status: undocumented
---

import * as Stories from "./_stories";

## Usage

<Stories.Base client:load />