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.
┌──────────────────────────────────────────────────────────────┐
│ 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
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.
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:
compileCSSwraps a synchronous function that calls vanilla-extract APIs (createGlobalTheme,globalStyle,globalKeyframes, etc.)- The custom adapter intercepts all CSS generation calls, capturing them into in-memory arrays
- After the function executes,
transformCss()serializes the captured objects into CSS strings - 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.
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.
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.
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 |
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).
The build is orchestrated by a custom Vite plugin in src/vite-plugin/index.ts:
- Dynamically import
theme.config.tsvia Bun native TypeScript + package exports self-reference (@lutinglt/gitea-github-theme/theme.config.ts) - Iterate over each theme series, generating entries for each theme variant (dark, light, soft-dark, frappe, macchiato, etc.)
- When a series has both dark and light variants, auto-generate an
autotheme entry - Provide a virtual module entry (Vite needs at least one input to trigger
generateBundle)
For each non-auto theme:
- 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
- Creates Gitea theme meta info (
- Minify captured CSS via
lightningcss.transform() - Append public style overrides (
styles/)
For each auto theme:
- Generate
@importdeclarations withprefers-color-schememedia queries referencing the corresponding dark/light theme files - Include theme meta info so Gitea recognizes it as an auto color theme
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; ... }
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
theme2ThemeVars() in src/palette/theme.ts takes ThemeColor (15 base colors + metadata) and
derives the full ThemeVars (200+ CSS variable values):
- Primary scale: 7 dark variants, 7 light variants, 9 alpha variants, hover/active states — all from a single hex color
- Secondary scale: 13 dark variants, 4 light variants, 9 alpha variants, button hover/active states
- Named colors (13 of 15 base colors): each generates self/light/dark-1/dark-2/badge-self/badge-bg/badge-hover-bg
- Semantic message colors: bg/border/text with rgba transparency and saturation adjustments
- ANSI colors: 16-color palette mapped from named colors
- Direction-aware:
isDarkThemeflag reverses scale direction — "dark" variants in dark themes go toward lighter values (to maintain contrast)
Style overrides use Gitea's existing DOM selectors. The override strategy follows a priority ladder:
- Color variables — All colors reference CSS variables from the theme (never hardcoded hex values)
- Element-level overrides — Base elements like
.ui.button,#navbar,input - Component-level overrides — Page-specific areas: heatmap, issues, actions, repo pages
!importantescape hatch — Only used when Gitea sets colors via inlinestyleattributes (e.g., heatmap contribution squares)
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() 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.
├── 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
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.
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.
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.
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).
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.
- 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 });- Register in
theme.config.ts:
{
themeSeriesName: "my-theme",
themes: {
dark: { theme: myDarkTheme },
// Optional: add light variant for auto theme generation
light: { theme: myLightTheme },
},
}- Run
bun bundle— the theme CSS will appear indist/.
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.
| 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 |
- CONTRIBUTING.md — Development environment setup and contribution guide
- README.md — User-facing installation and usage
- README_CN.md — Chinese user installation and usage
- primer/README.md — Auto-generated Primer token documentation
- CHANGELOG.md — Release history