| name | theme-system |
| description | Use when creating or modifying UI components, styling, visual elements, or icons in OpenChamber. All UI colors must use theme tokens - never hardcoded values or Tailwind color classes. All icons must use the shared Icon component from the SVG sprite system - never import from @remixicon/react directly. |
| license | MIT |
| compatibility | opencode |
Overview
OpenChamber uses a JSON-based theme system. Themes are defined in packages/ui/src/lib/theme/themes/. Users can also add custom themes via ~/.config/openchamber/themes/.
Core principle: UI colors must use theme tokens - never hardcoded hex colors or Tailwind color classes.
When to Use
- Creating or modifying UI components
- Working with colors, backgrounds, borders, or text
- Working with icons — adding, changing, or creating icon usages
Quick Decision Tree
- Code display? →
syntax.*
- Feedback/status? →
status.*
- Primary CTA? →
primary.*
- Interactive/clickable? →
interactive.*
- Background layer? →
surface.*
- Text? →
surface.foreground or surface.mutedForeground
Critical Rules
surface.elevated = inputs, cards, panels
interactive.hover = ONLY on clickable elements
interactive.selection = active/selected states (not primary!)
- Status colors = ONLY for actual feedback (errors, warnings, success)
- Input footers =
bg-transparent on elevated background
Button Rules (MANDATORY)
Use only the shared Button component from packages/ui/src/components/ui/button.tsx.
- Do not create wrapper button components (for example
ButtonLarge, ButtonSmall).
- Do not hardcode button height/padding classes when a
size variant exists.
- Use semantic button variants consistently; avoid ad-hoc one-off button styling.
Allowed Button Variants
| Variant | Use for | Token direction |
|---|
default | Primary action in a local section/dialog | primary.* |
outline | Secondary visible action | surface.elevated + interactive.* |
secondary | Soft secondary action | interactive.hover / interactive.active |
ghost | Low-emphasis row/toolbar action | transparent + interactive.hover |
destructive | Destructive actions (Delete, Revert all) | status.error* |
link | Rare inline text action only | text-link style |
Allowed Button Sizes
| Size | Use for |
|---|
xs | Dense controls in rows/lists |
sm | Default compact action buttons |
default | Standard form/page actions |
lg | Prominent large actions |
icon | Icon-only square button |
Button Selection Quick Guide
- Main CTA in section/dialog ->
default
- Side action next to CTA ->
outline
- Quiet auxiliary action ->
ghost
- Dangerous action ->
destructive
- Tiny row action -> keep same variant, set
size="xs"
Never Use
- Hardcoded hex colors (
#FF0000)
- Tailwind colors (
bg-white, text-blue-500, bg-gray-*)
- Deprecated:
bg-secondary, bg-muted
Usage
Via Hook
import { useThemeSystem } from '@/contexts/useThemeSystem';
const { currentTheme } = useThemeSystem();
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
Via CSS Variables
<div className="bg-[var(--surface-elevated)] hover:bg-[var(--interactive-hover)]">
Color Tokens
Surface Colors
| Token | Usage |
|---|
surface.background | Main app background |
surface.elevated | Inputs, cards, panels, popovers |
surface.muted | Secondary backgrounds, sidebars |
surface.foreground | Primary text |
surface.mutedForeground | Secondary text, hints |
surface.subtle | Subtle dividers |
Interactive Colors
| Token | Usage |
|---|
interactive.border | Default borders |
interactive.hover | Hover on clickable elements only |
interactive.selection | Active/selected items |
interactive.selectionForeground | Text on selection |
interactive.focusRing | Focus indicators |
Status Colors
| Token | Usage |
|---|
status.error | Errors, validation failures |
status.warning | Warnings, cautions |
status.success | Success messages |
status.info | Informational messages |
Each has variants: *, *Foreground, *Background, *Border.
Primary Colors
| Token | Usage |
|---|
primary.base | Primary CTA buttons |
primary.hover | Hover on primary elements |
primary.foreground | Text on primary background |
Primary vs Selection: Primary = "click me" (CTA), Selection = "currently active" (state).
Syntax Colors
For code display only. Never use for UI elements.
| Token | Usage |
|---|
syntax.base.background | Code block background |
syntax.base.foreground | Default code text |
syntax.base.keyword | Keywords |
syntax.base.string | Strings |
syntax.highlights.diffAdded | Added lines |
syntax.highlights.diffRemoved | Removed lines |
Examples
Input Area
const { currentTheme } = useThemeSystem();
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea className="bg-transparent" />
<div className="bg-transparent">{/* Footer - transparent! */}</div>
</div>
Active Tab
<button className={isActive
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover/50'
}>
Error Message
<div style={{
color: currentTheme.colors.status.error,
backgroundColor: currentTheme.colors.status.errorBackground
}}>
Card
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<h3 style={{ color: currentTheme.colors.surface.foreground }}>Title</h3>
<p style={{ color: currentTheme.colors.surface.mutedForeground }}>Description</p>
</div>
Icon System (MANDATORY)
OpenChamber uses an SVG sprite-based icon system. Never import from @remixicon/react. Always use the shared Icon component.
Import
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
Usage
<Icon name="arrow-down-s" className="h-4 w-4" />
<Icon name="loader-4" className="size-4 animate-spin" />
Naming Convention
Convert Remixicon component names to kebab-case sprite names:
- Strip
Ri prefix
- Strip
Line suffix
- Convert PascalCase to kebab-case
- Lowercase everything
| Remixicon | Sprite name |
|---|
RiArrowDownSLine | arrow-down-s |
RiCheckLine | check |
RiLoader4Line | loader-4 |
RiGithubFill | github-fill |
RiBrainAi3Line | brain-ai-3 |
Fill Variants
For filled (solid) icon variants, append -fill explicitly. The generator tries Line suffix first, then Fill, then bare name.
<Icon name="github-fill" /> {}
<Icon name="github" /> {}
Sizing
The Icon component has no size prop. Use Tailwind classes:
<Icon name="check" className="h-4 w-4" /> {}
<Icon name="check" className="size-5" /> {}
<Icon name="check" className="h-3 w-3" /> {}
Adding a New Icon (Workflow)
In order:
-
Use the icon in code with the correct kebab-case name:
<Icon name="new-icon-name" className="h-4 w-4" />
-
If used as a value (not JSX), use IconName type:
const config = { icon: "new-icon-name" as const };
-
Regenerate the sprite:
bun run icons:generate
-
The script scans all source files, reverse-maps to Remixicon names, extracts SVG paths, and regenerates sprite.ts.
-
Verify: bun run type-check
Do NOT manually edit sprite.ts. Always regenerate.
Type Safety for Icon Values
When icons are stored in objects/arrays, change the type from ComponentType to IconName and render via <Icon name={value} />:
const items = [{ icon: RiStackLine }];
return <items[0].icon className="h-4 w-4" />;
import type { IconName } from "@/components/icon/icons";
const items: { icon: IconName }[] = [{ icon: "stack" }];
return <Icon name={items[0].icon} className="h-4 w-4" />;
Wrong vs Right
Wrong
import { RiArrowDownSLine } from "@remixicon/react";
<RiArrowDownSLine className="h-4 w-4" />
<div style={{ backgroundColor: '#F2F0E5' }}>
<button className="bg-blue-500">
// Primary for active tab
<Tab className="bg-primary">Active</Tab>
// Hover on static element
<div className="hover:bg-interactive-hover">Static card</div>
// Colored footer on input
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea />
<div style={{ backgroundColor: currentTheme.colors.surface.muted }}>Footer</div>
</div>
Right
import { Icon } from "@/components/icon/Icon";
<Icon name="arrow-down-s" className="h-4 w-4" />
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<button style={{ backgroundColor: currentTheme.colors.primary.base }}>
// Selection for active tab
<Tab style={{ backgroundColor: currentTheme.colors.interactive.selection }}>Active</Tab>
// Hover only on clickable
<button className="hover:bg-[var(--interactive-hover)]">Click</button>
// Transparent footer
<div style={{ backgroundColor: currentTheme.colors.surface.elevated }}>
<textarea className="bg-transparent" />
<div className="bg-transparent">Footer</div>
</div>
References
Key Files
- Theme types:
packages/ui/src/types/theme.ts
- Theme hook:
packages/ui/src/contexts/useThemeSystem.ts
- CSS generator:
packages/ui/src/lib/theme/cssGenerator.ts
- Built-in themes:
packages/ui/src/lib/theme/themes/
- Icon component:
packages/ui/src/components/icon/Icon.tsx
- Icon sprite data:
packages/ui/src/components/icon/sprite.ts (auto-generated)
- Icon types:
packages/ui/src/components/icon/icons.ts
- Icon sprite generator:
scripts/generate-icon-sprite.mjs
- Icon docs:
packages/ui/src/components/icon/README.md