Skip to content

Allow users to customize styles via CSS variables #438

@jpmckinney

Description

@jpmckinney

I suspect that any reuser will want to change the look-and-feel of the platform, at least via CSS.

I had Claude prepare this prompt, but I don't think I'll have time to review and iterate on it, so sharing here.

Details

Refactor: replace Style Dictionary with shadcn-canonical CSS-var theming

Why

The current design-tokens pipeline (Style Dictionary → generated _variables.css + Tailwind color.js) is over-provisioned and harder to override per deployment than it needs to be. Refactoring to a CSS-variable-first setup makes per-deployment color overrides trivial AND aligns the codebase with the dominant Next.js + Tailwind v3 pattern.

This plan also folds in a configurable-font change (branding ships a next/font instance), since both touch layout.tsx and the DeploymentConfig contract.

Current state (audit summary)

  • config/tokens/tokens.json declares ~274 color tokens. Only ~13 unique base-color Tailwind classes are referenced anywhere in the app (bg-baseIndigoSolid1, border-baseGraySlateSolid4, text-baseGraySlateSolid11, etc.). ≥95% of generated tokens are dead weight.
  • Zero usages of Tailwind opacity utilities with design tokens (no bg-base…/50 etc.). So the rgb-channel form for CSS vars is not required.
  • tailwind.config.js has zero plugins and no theme.extend — it replaces Tailwind's defaults wholesale with Style Dictionary outputs.
  • opub-ui consumes ~13 semantic CSS vars from styles/tokens/_variables.css (--text-default, --text-interactive, --border-highlight-default, --icon-highlight, --background-solid-dark, --text-onbg-default, plus space/radius/font-size/z-index/outline). No collisions; opub-ui is a pure consumer.
  • Direct var(--…) consumption in app code is essentially zero (2 sites in globals.css).
  • Fonts: next/font/google Inter loaded statically in app/[locale]/layout.tsx:18.

Target state

Theming source of truth is CSS variables in a single hand-authored file. Tailwind references those CSS vars. Branding overrides = inject CSS vars + provide a next/font instance.

styles/theme.css           ← :root CSS vars (the only source of truth)
tailwind.config.js         ← references the CSS vars in theme.extend
app/[locale]/layout.tsx    ← injects branding cssVars; uses branding font

No Style Dictionary, no generated JS color file, no build:tokens script.

Steps

1. Inventory actually-used Tailwind token classes

Grep the app for all token-derived class usage and build the canonical list (~13 names per audit). Capture both the class name AND the hex value it currently resolves to (from styles/tokens/tailwind/color.js).

grep -roE '(bg|text|border|fill|stroke|outline|ring)-base[A-Z][A-Za-z0-9]+' \
  app components | sort -u

Add any semantic-token class usage (e.g. bg-backgroundSolidDark, text-textDefault) found in the same grep.

2. Author styles/theme.css

New file. Lift only the CSS vars actually referenced (by Tailwind classes, by opub-ui, and by globals.css). Use the same semantic names already in _variables.css so opub-ui keeps working. Approx size: ~50 vars (down from 112).

Keep the existing format (hex values, plain var(--name)); no need to refactor to rgb channels since no opacity utilities use these tokens.

3. Rewrite tailwind.config.js

Replace the imports from styles/tokens/tailwind/* with hand-written theme.extend.colors (and any other categories) referencing CSS vars:

theme: {
  extend: {
    colors: {
      textDefault: 'var(--text-default)',
      backgroundSolidDark: 'var(--background-solid-dark)',
      iconHighlight: 'var(--icon-highlight)',
      // ... only the names actually used
    },
    fontFamily: {
      sans: 'var(--font-family-primary)',
    },
  },
},

Drop the space, borderRadius, borderWidth, fontSize, lineHeight, boxShadow, zIndex, transitionTimingFunction, transitionDuration, fontWeight overrides unless they're actually customised vs. Tailwind defaults — for any that are, keep them as var(--…) references and add the corresponding entries to theme.css.

Keep corePlugins.preflight = false (still required to avoid clashing with opub-ui's reset).

4. Update styles/globals.css

Replace @import './tokens/_variables.css'; with @import './theme.css';. Leave the opub-ui and nprogress imports as-is.

5. Delete obsolete machinery

  • config/tokens/ (entire directory: tokens.json, sd-config.js, generate.mjs, copy.js, anything else)
  • styles/tokens/ (entire directory: _variables.css, tailwind/*)
  • package.json: drop the build:tokens and postbuild:tokens scripts; drop the style-dictionary and fs-extra devDependencies if they're only used by the tokens pipeline.

6. Extend the branding contract

In ids-drr-branding-types/src/index.ts, add to DeploymentConfig:

import type { NextFont } from 'next/font';

export type DeploymentConfig = {
  // ...existing fields
  // Per-deployment font (e.g. a `next/font/google` or `/local` instance).
  // Falls back to the frontend's default Inter when undefined.
  font?: NextFont;
  // CSS variable overrides applied at runtime to <html style>. Keys match
  // the names in styles/theme.css (e.g. "--text-default").
  cssVars?: Record<string, string>;
};

7. Update branding implementations

  • branding-stub/src/index.ts — no changes needed (defaults to undefined, which is fine).
  • ids-drr-india-branding/src/index.ts and config.ts — declare the deployment's font (pick a next/font/google family) and any CSS-var overrides.

8. Wire through config/site.ts

Add re-exports:

export const font = config.font;
export const cssVars: Record<string, string> = config.cssVars ?? {};

9. Apply in app/[locale]/layout.tsx

import { cssVars, font } from '@/config/site';

const fallbackFont = FontSans({ subsets: ['latin'], display: 'swap' });
const activeFont = font ?? fallbackFont;
<html lang={locale} style={cssVars as CSSProperties}>
  ...
  <body className={activeFont.className}>

10. Verify

  • npx tsc --noEmit → 0 errors.
  • npm test → 143/143 pass.
  • npm run dev against the stub: the home, analytics, and datasets pages look identical to before the refactor (regression check).
  • npm run dev against the India branding (with a font override and at least one cssVars override) → font and the chosen accent colour change as expected.
  • npm run build succeeds without build:tokens (confirm script deletion didn't break the build chain).

Files touched

  • New: styles/theme.css
  • Modified: tailwind.config.js, styles/globals.css, app/[locale]/layout.tsx, config/site.ts, branding-stub/src/index.ts, package.json
  • Modified (separate repos): ids-drr-branding-types/src/index.ts, ids-drr-india-branding/src/{index.ts,config.ts}
  • Deleted: config/tokens/**, styles/tokens/**

Effort & risks

Effort: ~1 day. Dominated by inventorying actually-used tokens, hand-authoring theme.css and tailwind.config.js, and visual smoke-testing.

Risks:

  • A token in use via runtime-computed classnames may slip past the static grep. Mitigation: keep every var that opub-ui's CSS references (per audit, ~13 semantic vars), then expand if regressions appear.
  • 84 baseIndigoAlpha1..12-style alpha tokens exist as separate hex values. None used per the audit, but spot-check before deleting.
  • next/font requires statically-known font choices, which is fine because branding is a static TS module — but be aware that the font instance must be resolvable at module load time, not lazily.

Trade-off note

If the refactor scope feels too large for the current cycle, the cheap version is:

  • Add font?: NextFont and cssVars?: Record<string, string> to DeploymentConfig.
  • Inject cssVars and the font in layout.tsx.
  • Leave Style Dictionary in place.

That gets per-deployment fonts and a few-color override in ~half a day, at the cost of keeping the over-provisioned tokens pipeline.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions