Skip to content

Latest commit

 

History

History
606 lines (481 loc) · 31.1 KB

File metadata and controls

606 lines (481 loc) · 31.1 KB

Architecture: Gitea GitHub Theme

Project Overview

Gitea GitHub Theme is a build-time CSS generation framework that produces static .css theme files for the Gitea self-hosted code platform. The project takes design tokens and color definitions as input, transforms them through a layered pipeline, and outputs 30+ minified CSS files covering 7 theme series (default, colorblind, tritanopia, pink, gitea, catppuccin, high-contrast).

Core principle: Full type safety and CSS-in-JS developer experience at build time, pure static CSS delivered to the browser with zero runtime overhead.


Architecture Layers

┌──────────────────────────────────────────────────────────────┐
│                  Theme Configuration Layer                     │
│  theme.config.ts: declares theme series, color types,         │
│  display names, accessibility metadata                        │
└──────────────────────────┬───────────────────────────────────┘
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                  Color Definition Layer                        │
│  ┌──────────┐  ┌──────────────┐  ┌───────────────────┐      │
│  │ primer/  │  │  themes/     │  │ themes/catppuccin  │      │
│  │ (auto-   │  │  github.ts   │  │ .ts               │      │
│  │ generated│  │  gitea*.ts   │  │                    │      │
│  │ Primer   │  │              │  │                    │      │
│  │ tokens)  │  │              │  │                    │      │
│  └────┬─────┘  └──────┬───────┘  └─────────┬─────────┘      │
│       │               │                    │                 │
│  primer2GitHubColor   defineTheme   catppuccin2ThemeColor    │
│       │               │                    │                 │
│       ▼               ▼                    ▼                 │
│  GitHubColor      Theme             ThemeColor               │
│                   (colorType:                                │
│                    "github")                                 │
└──────────────────────────┬───────────────────────────────────┘
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                  Color Transformation Layer                    │
│  src/palette/                                                │
│  ┌────────────┐  ┌────────────┐  ┌──────────────────┐      │
│  │ github.ts  │  │ theme.ts   │  │ gitea.ts         │      │
│  │ GitHubColor│  │ ThemeColor │  │ GiteaColor       │      │
│  │    →       │  │    →       │  │    →             │      │
│  │ ThemeColor │  │ ThemeVars  │  │ ThemeVars        │      │
│  └────────────┘  └────────────┘  └──────────────────┘      │
│                                                              │
│  Unified output: ThemeVars (flat key-value map of CSS vars)   │
└──────────────────────────┬───────────────────────────────────┘
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                  Core Framework Layer                          │
│  src/core/                                                   │
│  ┌──────────────┐  ┌──────────┐  ┌───────────────────┐      │
│  │ theme.ts     │  │ css.ts   │  │ chroma.ts         │      │
│  │ createTheme()│  │ css()    │  │ codemirror.ts     │      │
│  │ defineTheme()│  │cssCombine│  │ meta.ts           │      │
│  └──────┬───────┘  │cssStyle()│  └───────────────────┘      │
│         │          └────┬─────┘                              │
│         │               │                                    │
│  vanilla-extract adapter (src/vanilla-extract.ts)             │
│  ┌──────────────────────────────────────────────────┐       │
│  │  setAdapter() → intercepts appendCss,            │       │
│  │  registerClassName, registerComposition          │       │
│  │  → serializes all CSS to strings at build time   │       │
│  └──────────────────────────────────────────────────┘       │
└──────────────────────────┬───────────────────────────────────┘
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                  Style Override Layer                          │
│  styles/                                                     │
│  ┌──────────┐  ┌──────────────┐  ┌──────────────────┐      │
│  │ public/  │  │ components/  │  │ templates/       │      │
│  │ (base    │  │ (page-level  │  │ (template-       │      │
│  │  elements│  │  styles)     │  │  specific styles) │      │
│  └──────────┘  └──────────────┘  └──────────────────┘      │
│  common/ (reusable CSS fragments: buttons, labels, anims)    │
│                                                              │
│  Each file uses css`...` template literals                   │
│  → composed with cssCombine() at export boundary             │
└──────────────────────────┬───────────────────────────────────┘
                           │
┌──────────────────────────▼───────────────────────────────────┐
│                  Build Pipeline                                │
│  src/vite-plugin/index.ts (Vite plugin)                      │
│                                                              │
│  config() hook:                                              │
│    1. Dynamically import theme.config.ts                      │
│    2. Resolve all theme entries + generate auto themes        │
│                                                              │
│  generateBundle() hook:                                      │
│    1. Import styles/ → CSS string (public + components)      │
│    2. For each theme entry:                                  │
│       a. compileCSS(createThemeMetaInfo + createTheme)       │
│       b. lightningcss.transform() → minify                   │
│       c. Append style overrides (non-auto themes only)       │
│       d. emitFile → output .css asset                        │
│    3. For auto themes: generate @import declarations         │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
                    dist/theme-github-*.css

Core Design Patterns

1. Vanilla-Extract Adapter Hijacking

Problem solved: Vanilla-extract is a zero-runtime CSS-in-JS library that generates static CSS files at build time via its Vite/esbuild plugin. However, Gitea themes need single-file CSS output and programmatic control over the theme generation process (one output file per theme, auto theme generation, etc.).

Solution: Bypass vanilla-extract's default plugin and use its low-level adapter API directly.

In src/vanilla-extract.ts:

export function compileCSS(compileFn: () => void): string {
  // 1. Install custom adapter to capture CSS objects into arrays
  setAdapter({
    appendCss: css => capturedCssObjs.push(css),
    registerClassName: className => localClassNames.add(className),
    registerComposition: composition => composedClassLists.push(composition),
    // ... other callbacks implemented as no-ops
  });

  // 2. Set file scope for debugging
  setFileScope(getCallerInfo(1));

  // 3. Execute the caller's vanilla-extract API calls
  compileFn();

  // 4. Tear down adapter
  endFileScope();
  removeAdapter();

  // 5. Serialize captured CSS objects to strings
  return transformCss({
    localClassNames: Array.from(localClassNames),
    composedClassLists,
    cssObjs: capturedCssObjs,
  }).join("\n");
}

How it works:

  1. compileCSS wraps a synchronous function that calls vanilla-extract APIs (createGlobalTheme, globalStyle, globalKeyframes, etc.)
  2. The custom adapter intercepts all CSS generation calls, capturing them into in-memory arrays
  3. After the function executes, transformCss() serializes the captured objects into CSS strings
  4. The adapter is torn down, leaving no global side effects

Safety guarantee: Each compileCSS call is fully self-contained: install → capture → serialize → teardown. No state leaks between calls.

Key insight: Vanilla-extract's createGlobalThemeContract + createGlobalTheme fit this pattern perfectly—they only produce CSS during createGlobalTheme calls, which happen exactly within our capture scope.

2. Three Color Input Pipelines

Problem solved: Different color theme sources have different data structures. Primer design tokens (from GitHub) have hundreds of nested keys. Catppuccin has its own palette structure. Gitea's native format uses a flat structure. All three need to be supported.

Solution: Three conversion paths, all converging on ThemeVars:

Primer tokens ──→ GitHubColor ──→ ThemeColor ──→ ThemeVars
Catppuccin flavor ──────────────→ ThemeColor ──→ ThemeVars
GiteaColor ────────────────────────────────────→ ThemeVars

Each path has different complexity requirements:

Path Use case Complexity Examples
github Full Primer token compliance High (hundreds of mapped keys) Default, colorblind, high-contrast
theme 15 base colors, custom theme Low (auto-derives 200+ CSS vars) Catppuccin, community themes
gitea Gitea native color format Medium (direct mapping) Gitea-compatible themes

Design rationale: The theme path is the "onboarding path"—contributors only need to define 15 semantic colors (red, orange, yellow, …, white) plus primary and secondary. The framework automatically computes:

  • 7-level lightness variants (dark-1 through dark-7, light-1 through light-7)
  • 9-level alpha variants (alpha-10 through alpha-90)
  • Badge colors and their hover states
  • Message colors (error/success/warning/info)
  • ANSI console colors
  • Direction-aware (scale direction reversed for dark/light themes)

Implemented in theme2ThemeVars() in src/palette/theme.ts.

3. CSS Variable Naming Convention

Problem solved: Gitea expects specific CSS variable naming patterns (--color-primary-dark-1, --color-red-badge-bg, etc.). We need type-safe access in TypeScript (themeVars.color.primary.dark.num1).

Solution: Use vanilla-extract's createGlobalThemeContract with a custom varMapper.

In src/types/vars.ts:

function varMapper(prefix: string | null = null) {
  return (value: string | null, path: string[]) => {
    if (value === null) {
      // Auto-generate name from path: ["color","primary","dark","num1"] → "color-primary-dark-1"
      path = path.filter(item => item !== "self"); // remove "self" key
      path = path.map(item => item.replace(/^num/, "")); // strip "num" prefix
      value = path.join("-");
    }
    if (prefix) value = `${prefix}-${value}`;
    return value;
  };
}

This creates a bidirectional mapping:

TypeScript access CSS variable
themeVars.color.primary.self --color-primary
themeVars.color.primary.dark.num1 --color-primary-dark-1
themeVars.github.button.primary.bgColor.rest --github-button-primary-bgColor-rest
syntaxVars.keyword --color-syntax-keyword
customThemeVars.cloneMenuWidth --custom-clone-menu-width

4. Build-Time CSS Syntax Validation

Problem solved: CSS in template literals can contain syntax errors that only surface at browser runtime.

Solution: The css() template tag validates CSS syntax at build time using lightningcss.

In src/core/css.ts:

export function css(strings: TemplateStringsArray, ...values: CSSInterpolation[]): CSSString {
  const result = strings.reduce((acc, s, i) => acc + s + String(values[i] ?? ""), "");
  // Build-time validation: lightningcss parses and reports syntax errors
  transform({ filename: getCallerInfo(1), code: Buffer.from(result) });
  return result as CSSString;
}

If a developer writes invalid CSS, the build fails immediately with file:line info pointing to the problematic template literal. This catches errors that would otherwise only be discovered through browser DevTools.

Trade-off: Each css template call invokes transform() once. For ~60 style files, this adds ~200ms to the initial build (one-time cost, subsequent builds benefit from Bun's module cache).


Vite Build Pipeline

The build is orchestrated by a custom Vite plugin in src/vite-plugin/index.ts:

Phase 1: Configuration Resolution (config hook)

  1. Dynamically import theme.config.ts via Bun native TypeScript + package exports self-reference (@lutinglt/gitea-github-theme/theme.config.ts)
  2. Iterate over each theme series, generating entries for each theme variant (dark, light, soft-dark, frappe, macchiato, etc.)
  3. When a series has both dark and light variants, auto-generate an auto theme entry
  4. Provide a virtual module entry (Vite needs at least one input to trigger generateBundle)

Phase 2: CSS Generation (generateBundle hook)

For each non-auto theme:

  1. Call compileCSS() with a function that:
    • Creates Gitea theme meta info (createThemeMetaInfo) — sets --theme-color-scheme, --theme-display-name, --theme-colorblind-type
    • Creates theme CSS variables (createTheme) — generates all --color-* and --github-* variables, global styles, keyframe animations, code highlighting
  2. Minify captured CSS via lightningcss.transform()
  3. Append public style overrides (styles/)

For each auto theme:

  1. Generate @import declarations with prefers-color-scheme media queries referencing the corresponding dark/light theme files
  2. Include theme meta info so Gitea recognizes it as an auto color theme

CSS Output Structure

theme-github-dark.css:
  ├── @keyframes (overlayAppear, overlayAppearDown, overlayAppearUp)
  ├── gitea-theme-meta-info { --theme-color-scheme: dark; ... }
  ├── :root { --theme-version: "1.26.2.260605"; --is-dark-theme: true; ... }
  ├── :root { --color-primary: #4493f8; --color-primary-contrast: ... }
  ├── ... (200+ CSS variable declarations)
  ├── .chroma { ... } (syntax highlighting)
  ├── .code-editor-container { ... } (CodeMirror highlighting)
  └── <public style overrides> (~60KB single-line minified)

theme-github-auto.css:
  ├── @import "./theme-github-dark.css" (prefers-color-scheme: dark);
  ├── @import "./theme-github-light.css" (prefers-color-scheme: light);
  └── gitea-theme-meta-info { --theme-color-scheme: auto; ... }

Color System Deep Dive

Color Scale Calculation

scaleColorLight() in src/functions/color.ts implements the Sass color.scale() algorithm:

darken:  newL = currentL × (1 + scale/100)         [scale < 0]
lighten: newL = currentL + (1 - currentL) × (scale/100)  [scale > 0]

This is proportional scaling, not linear offset. Its significance:

  • color.scale(#000000, $lightness: 50%) → 50% gray (not "50% darker than black", which is still black)
  • color.scale(#ffffff, $lightness: -50%) → 50% gray (not "50% lighter than white", which is still white)
  • The proportional approach ensures visually meaningful changes at both brightness extremes

ThemeColor → ThemeVars Derivation

theme2ThemeVars() in src/palette/theme.ts takes ThemeColor (15 base colors + metadata) and derives the full ThemeVars (200+ CSS variable values):

  1. Primary scale: 7 dark variants, 7 light variants, 9 alpha variants, hover/active states — all from a single hex color
  2. Secondary scale: 13 dark variants, 4 light variants, 9 alpha variants, button hover/active states
  3. Named colors (13 of 15 base colors): each generates self/light/dark-1/dark-2/badge-self/badge-bg/badge-hover-bg
  4. Semantic message colors: bg/border/text with rgba transparency and saturation adjustments
  5. ANSI colors: 16-color palette mapped from named colors
  6. Direction-aware: isDarkTheme flag reverses scale direction — "dark" variants in dark themes go toward lighter values (to maintain contrast)

Style Override Strategy

Selector Priority Hierarchy

Style overrides use Gitea's existing DOM selectors. The override strategy follows a priority ladder:

  1. Color variables — All colors reference CSS variables from the theme (never hardcoded hex values)
  2. Element-level overrides — Base elements like .ui.button, #navbar, input
  3. Component-level overrides — Page-specific areas: heatmap, issues, actions, repo pages
  4. !important escape hatch — Only used when Gitea sets colors via inline style attributes (e.g., heatmap contribution squares)

cssCombine() Explicit Composition

Styles are composed together via a cssCombine() call tree:

styles/index.ts
  ├── public/index.ts    (base elements, buttons, inputs, dropdowns, menus, tables)
  ├── components/index.ts (navbar, heatmap, issues, repo files, Actions, etc.)
  └── templates/index.ts  (template-specific styles)

Each leaf module exports default cssCombine(...), chaining CSS fragments together. This explicit composition makes the dependency graph visible and avoids the implicit ordering issues of CSS @import or @use.

cssStyle() Reusable Fragments

cssStyle() in src/core/css.ts enables extracting reusable CSS property sets while retaining vanilla-extract's type checking:

// In styles/common/button.ts:
export const primaryButtonStyle = cssStyle({
  color: themeVars.github.button.primary.fgColor.rest,
  backgroundColor: themeVars.github.button.primary.bgColor.rest,
  borderColor: themeVars.github.button.primary.borderColor.rest,
  boxShadow: themeVars.github.shadow.resting.small,
});

// In styles/public/button.ts:
const baseButton = css`
  .ui.primary.button {
    ${primaryButtonStyle}
  }
`;

This preserves type safety (vanilla-extract validates property names and value types) in a system that ultimately outputs static CSS strings.


Directory Structure

├── theme.config.ts         # Theme series declaration (framework entry point)
├── src/
│   ├── index.ts            # Public API exports
│   ├── core/               # Core framework
│   │   ├── theme.ts        # createTheme, defineTheme (color → CSS variables)
│   │   ├── css.ts          # css(), cssCombine(), cssStyle()
│   │   ├── chroma.ts       # Chroma (server-side) syntax highlighting
│   │   ├── codemirror.ts   # CodeMirror (client-side) syntax highlighting
│   │   ├── meta.ts         # Gitea theme meta info (color scheme, display name)
│   │   └── types.d.ts      # MapLeafNodes, CSSVarFunction, Tokens
│   ├── palette/            # Color conversion and transformation
│   │   ├── theme.ts        # ThemeColor → ThemeVars (simplified API)
│   │   ├── github.ts       # GitHubColor → ThemeColor (Primer path)
│   │   ├── gitea.ts        # GiteaColor → ThemeVars (native path)
│   │   ├── catppuccin.ts   # CatppuccinFlavor → ThemeColor
│   │   ├── primer.ts       # Primer tokens → GitHubColor / Chroma / CodeMirror
│   │   ├── display.ts      # Display color scale → GitHubColor overrides
│   │   └── catppuccinSyntax.ts  # Catppuccin syntax highlighting
│   ├── functions/          # Utility functions
│   │   ├── color.ts         # scaleColorLight (Sass color.scale) + rgba wrapper
│   │   └── var.ts          # extractVarName (CSS variable name extraction)
│   ├── types/              # Type definitions and CSS variable contracts
│   │   ├── color/          # Color structure definitions (primary, secondary, named, etc.)
│   │   └── vars.ts         # createGlobalThemeContract + varMapper
│   ├── vite-plugin/        # Build tooling
│   │   ├── index.ts        # Main Vite plugin
│   │   ├── themeConfig.ts  # defineThemeConfig, TypeScript types
│   │   └── utils.ts        # buildFullThemeName, buildFullDisplayName
│   ├── vanilla-extract.ts  # Adapter hijacking (compileCSS, captureStyle)
│   ├── selectors.ts        # Shared CSS selectors (root, chroma, codeMirror, emoji)
│   └── utils.ts            # deepOverride, getCallerInfo
├── styles/                 # CSS style overrides
│   ├── common/             # Reusable CSS fragments (buttons, labels, animations)
│   ├── public/             # Base elements (buttons, inputs, dropdowns, menus, tables)
│   ├── components/         # Page-specific styles (navbar, heatmap, repo, issues, Actions)
│   └── templates/          # Template-specific styles
├── themes/                 # Color theme definitions
│   ├── github.ts           # GitHub theme colors (from Primer tokens)
│   ├── giteaDark.ts        # Gitea dark theme colors (native format)
│   ├── giteaLight.ts       # Gitea light theme colors (native format)
│   └── catppuccin.ts       # Catppuccin theme colors
├── primer/                 # Auto-generated Primer design tokens (from @primer/primitives)
├── scripts/                # Build and utility scripts
│   ├── buildPrimerTSToken.ts  # Generate primer/ TypeScript tokens from @primer/primitives
│   ├── syncTheme.ts        # Sync build artifacts to remote Gitea instance
│   ├── updateGitea.ts      # Update Gitea reference files
│   ├── translate.ts        # Generate localization/translation files
│   └── version.ts          # Version management
├── dist/                   # Build output (30+ CSS files)
└── templates/              # Optional Gitea Go template overrides

Key Design Decisions & Trade-offs

Decision 1: Vanilla-Extract for CSS authoring only, not runtime

Choice: Use vanilla-extract's createGlobalTheme/createGlobalThemeContract for type-safe CSS variable generation, but serialize to static CSS strings at build time via adapter hijacking.

Rejected: Using vanilla-extract's built-in Vite plugin (outputs per-module CSS files, unsuitable for single-file theme output).

Trade-off: Adapter API is undocumented and may change between vanilla-extract versions. Mitigated by locking @vanilla-extract/css version and using snapshot tests to detect CSS output changes.

Decision 2: Single-file CSS output

Choice: Each theme is a single CSS file containing all variables and all style overrides.

Rejected: Splitting into multiple CSS files via @import chains (Gitea's theme system loads one CSS file per theme).

Trade-off: Style overrides are duplicated across all theme files (~60KB × 30 themes ≈ 1.8MB redundancy). This is a Gitea constraint, not a design choice. The alternative (separate common.css) would require Gitea to support multi-file theme loading.

Decision 3: LightningCSS for both syntax validation and output minification

Choice: Use LightningCSS for build-time CSS syntax validation (in css() template tag) and output minification (in Vite plugin).

Rejected: PostCSS (slower), esbuild CSS (less feature-complete), no validation at all.

Trade-off: LightningCSS is lenient—it recovers from many syntax errors rather than throwing. This means validation in css() catches severe errors but may silently accept certain edge cases. Build output snapshot tests partially compensate for this.

Decision 4: Bun-only runtime

Choice: Require Bun ≥ 1.3.14 for development and building.

Rejected: Cross-runtime support (Node + Bun).

Trade-off: Limits contributor environments. Benefits: native TypeScript execution (no ts-node/esbuild overhead), consistent stack trace format (for debugging utilities), faster builds (Bun native bundler).

Decision 5: Package self-referencing for internal imports

Choice: Use @lutinglt/gitea-github-theme/core style imports even within the same package, via exports mappings in package.json.

Rejected: Relative path imports everywhere.

Trade-off: Requires paths mappings in tsconfig.json for IDE support. Benefits: import paths are consistent whether used as a dependency or developed locally; no path confusion between src/, styles/, and themes/ directories.


Adding a New Theme

Quick Path (15 color definitions)

  1. Create themes/my-theme.ts:
import { defineTheme } from "@lutinglt/gitea-github-theme/core";
import type { ThemeColor, Syntax } from "@lutinglt/gitea-github-theme/core";

const themeColor: ThemeColor = {
  isDarkTheme: true,
  primary: "#8b5cf6", // accent color (purple)
  primaryContrast: "#ffffff",
  secondary: "#27272a", // border color
  base: {
    red: "#ef4444",
    orange: "#f97316",
    yellow: "#eab308",
    olive: "#84cc16",
    green: "#22c55e",
    teal: "#14b8a6",
    cyan: "#06b6d4",
    blue: "#3b82f6",
    violet: "#8b5cf6",
    purple: "#a855f7",
    pink: "#ec4899",
    brown: "#a16207",
    black: "#18181b",
    grey: "#71717a",
    gold: "#eab308",
    white: "#fafafa",
  },
  console: {
    /* Actions log colors */
  },
  diff: {
    /* Code diff colors */
  },
  other: {
    /* body, text, input, shadow, etc. */
  },
  github: {
    /* GitHub-specific variables */
  },
};

const syntaxColor: Syntax = {
  /* Code highlighting colors */
};

export default defineTheme({ colorType: "theme", themeColor, syntaxColor });
  1. Register in theme.config.ts:
{
  themeSeriesName: "my-theme",
  themes: {
    dark: { theme: myDarkTheme },
    // Optional: add light variant for auto theme generation
    light: { theme: myLightTheme },
  },
}
  1. Run bun bundle — the theme CSS will appear in dist/.

Full Path (Primer tokens)

For pixel-perfect GitHub color matching, use the github path with direct Primer design token mapping. See themes/github.ts and src/palette/github.ts for the full mapping reference.


Testing Strategy

Layer Test type Location What it verifies
Utility Unit tests src/utils.test.ts deepOverride behavior, getCallerInfo stack parsing
Functions Unit tests src/functions/*.test.ts scaleColorLight numeric computation, extractVarName parsing
Core Unit tests src/core/css.test.ts css() validation, cssCombine() composition, cssStyle() serialization
Palette Snapshot tests src/palette/*.test.ts Full color conversion chains (Primer → ThemeColor → ThemeVars, Catppuccin → ThemeColor → ThemeVars, etc.)
Palette Structure tests src/palette/github.test.ts Color value validity (all leaf nodes are valid CSS color strings), required keys exist
Build output Integration src/build-output.test.ts Expected files exist, CSS content validity (vars present, no debug residue, hex lowercase, color-scheme match), file size limits

Related Documents