Design System
Elftia's visual design pursues warmth, affinity, and sophistication while avoiding cold, purely technical aesthetics.
Design Philosophy
Color Principles
- Warm neutral colors: All surface colors have a slight warm tone (hue 24-38), not pure grayscale
- Dark mode: Warm charcoal instead of pure black
- Light mode: Warm cream instead of pure white
- No cold grays: Don't use pure gray (
hsl(0, 0%, ...)) for backgrounds or borders
Corner Radius Principles
| Element | Radius | Tailwind Class |
|---|---|---|
| Cards/containers | 12px | rounded-xl |
| Input fields | 12px | rounded-xl |
| Buttons/badges | 6px | rounded-md |
| Default | 8px | rounded-lg |
Avoid corner radius smaller than 4px (except for line decorations).
Typography Principles
| Purpose | Font | Tailwind Class |
|---|---|---|
| Display/large headlines | Noto Serif / Georgia | font-display |
| Body/interface | Inter | font-sans |
| Code | JetBrains Mono | font-mono |
Use font-semibold (not font-bold) for headlines, paired with tracking-tight.
Color System
Semantic Tokens
All colors defined via CSS variables mapped to utility classes in Tailwind configuration. Never use hard-coded color values.
// Don't do this
<div className="bg-white text-black border-gray-200">
<div style={{ backgroundColor: '#ffffff' }}>
// Correct
<div className="bg-surface-0 text-foreground border-border">
<div style={{ backgroundColor: 'var(--surface-0)' }}>
Common Token Reference
| Purpose | Tailwind Class | CSS Variable |
|---|---|---|
| Page background | bg-background | var(--background) |
| L0 background | bg-surface-0 | var(--surface-0) |
| L1 background (cards/sidebar) | bg-surface-1 | var(--surface-1) |
| L2 background (inputs/secondary containers) | bg-surface-2 | var(--surface-2) |
| L3 background (popovers) | bg-surface-3 | var(--surface-3) |
| Main text | text-foreground | var(--foreground) |
| Secondary text | text-muted-foreground | var(--muted-foreground) |
| Auxiliary text | text-text-subtle | var(--text-subtle) |
| Border | border-border | var(--border) |
| Theme color | bg-primary / text-primary | var(--primary) |
| Success | text-success | var(--success) |
| Error | text-destructive | var(--destructive) |
| Warning | text-warning | var(--warning) |
Dark/Light Mode
Switching Mechanism
Use Tailwind's class strategy (darkMode: 'class'), centrally managed via ThemeContext.
// Correct: get theme info via useTheme
import { useTheme } from '@/shared/state/themeStore';
function MyComponent() {
const { mode, resolvedMode, userTheme } = useTheme();
}
// Don't: manual detection
const isDark = localStorage.getItem('theme') === 'dark';
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
Hierarchy System
Dark mode relies on brightness to distinguish hierarchy: closer UI elements to user have brighter backgrounds.
| Level | Token | Brightness | Purpose | Reference Color |
|---|---|---|---|---|
| L0 | --surface-0 | 7% | Page main background | #121212 |
| L1 | --surface-1 | 12% | Sidebar, cards | #1E1E1E |
| L2 | --surface-2 | 17% | Secondary containers, input | #2B2928 |
| L3 | --surface-3 | 22% | Popover, Dropdown | #383635 |
Brightness difference between levels must be >= 4-5% to ensure visual distinction.
Border Standards
In dark mode, human perception of dark areas is less sensitive, so borders need higher visibility:
| Scenario | Light Mode | Dark Mode |
|---|---|---|
| Card border | border-border/30 ~ /40 | border-border/50 ~ /70 |
| Divider | border-border/20 ~ /30 | border-border/40 ~ /50 |
| Input field | border-border/40 | border-border/60 ~ border-border |
WCAG Accessibility
Based on WCAG 2.1 standards.
Contrast Requirements
| Element Type | Min Contrast | Description |
|---|---|---|
| Normal text (< 18pt) | 4.5:1 | Body, descriptions, labels |
| Large text (>= 18pt or 14pt bold) | 3:1 | Headlines |
| UI controls (icons, borders) | 3:1 | Input borders, icons, badges |
| Disabled state | Exempt, suggest 2.5:1 | Avoid complete invisibility |
Text Token Usage Rules
| Token | Brightness | Allowed Backgrounds | Typical Use |
|---|---|---|---|
text-foreground (93%) | Highest | All surfaces | Main headlines, body text |
text-muted-foreground (65%) | Medium | surface-0, surface-1 | Secondary text, descriptions |
text-text-subtle (50%) | Low | surface-0 only | Timestamps, metadata |
// Good: description text uses text-muted
<p className="text-muted-foreground">5 models total</p>
// Bad: using text-subtle on surface-1 card (insufficient contrast)
<div className="bg-surface-1">
<span className="text-text-subtle">Hard to read</span>
</div>
Color Communicates Information
Never rely on color alone to convey status, must pair with text label or icon:
// Bad: color only
<div className={status === 'error' ? 'border-red-500' : 'border-border'} />
// Good: color + icon + text
<div className={status === 'error' ? 'border-destructive' : 'border-border'}>
{status === 'error' && <AlertCircle className="text-destructive" />}
<span>{errorMessage}</span>
</div>
Focus State
Use focus-visible to provide focus border for keyboard navigation users:
// Good
<button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none">
Button
</button>
// Don't: remove focus styles
<button className="outline-none focus:outline-none">Button</button>
Motion Preference
Respect system prefers-reduced-motion setting:
<div className="motion-safe:animate-fadeIn motion-reduce:animate-none">
Content
</div>
UI Component Standards
Don't Use Native Controls
| Native Control | Project Component | Path |
|---|---|---|
<select> | Select | @/components/ui/select |
<input type="text"> | Input | @/components/ui/input |
<input type="checkbox"> | Switch / Checkbox | @/components/ui/switch |
<button> | Button | @/components/ui/button |
window.confirm() | ConfirmDialog | @/components/ui/confirm-dialog |
window.alert() | Toast component | - |
Dropdown Components
All dropdown components should support:
- Viewport-aware positioning (use
useDropdownPositionHook) - Close on outside click
- Close on Escape key
- Item theme colors and styles
Wallpaper Transparency System
When user sets wallpaper, add data-wallpaper-active="true" attribute to body, triggering CSS transparency rules.
CSS Rule Hierarchy
| Priority | Selector | Effect | Purpose |
|---|---|---|---|
| 1 | .bg-background, .bg-surface-0 | Fully transparent | Page main background |
| 2 | .wallpaper-blur | 35% opaque + blur | Main container (WorkspaceShell) |
| 3 | .wallpaper-blur .wallpaper-blur | Transparent + blur*0.67 | Nested container (avoid stacking) |
| 4 | bg-surface-0/XX in .wallpaper-blur | Transparent | Layout panels |
| 5 | bg-surface-1/XX in .wallpaper-blur | 12% + blur | Content cards (alpha variant) |
| 6 | bg-surface-1, bg-popover | 15% + blur | Buttons/cards (exclude input) |
| 7 | bg-surface-2 | 20% + blur | Secondary container (exclude input) |
| 8 | .wallpaper-panel | 85% + blur*1.33 | Floating dropdown/menu |
| 9 | .wallpaper-solid | Opaque surface-0 | Dialog/Modal |
Hierarchy Stacking Model
WorkspaceShell (wallpaper-blur, 35%)
+-- Sidebar (bg-surface-0/75 -> transparent) = 35%
+-- Main content (bg-background -> transparent) = 35%
| +-- Content cards (bg-surface-1/80 -> 12%) ~ 43%
| +-- Buttons (bg-surface-1 -> 15%) ~ 45%
| +-- Input (input -> solid) = Opaque
| +-- Dropdown panel (wallpaper-panel -> 85%) = 85%
+-- Bottombar (wallpaper-blur -> transparent) = 35%
Wallpaper CSS Class Usage Guide
| Scenario | Recommended |
|---|---|
| Page main container | bg-background or bg-surface-0 (auto fully transparent) |
| Button/card | bg-surface-1 or bg-surface-1/80 (auto semi-transparent) |
| Secondary container | bg-surface-2 (auto 20% semi-transparent) |
| Main layout container | Add wallpaper-blur class |
| Floating dropdown/menu | Add wallpaper-panel class |
| Dialog/Modal | Add wallpaper-solid class |
| Input field | <input> / <textarea> + bg-surface-1 (auto exclude, keep solid) |
Components with Built-in Wallpaper Support
Semi-transparent frosted glass (wallpaper-panel):
Selectdropdown panelDropdownMenuContent/DropdownMenuSubContentContextMenuContent/ContextMenuSubContent
Fully opaque (wallpaper-solid):
DialogContent
Don't
- Don't use bare
bg-surface-0as card background (disappears fully transparent in wallpaper mode) - Don't use
bg-white,bg-black,bg-gray-*,bg-neutral-*hard-coded colors - For opaque effect, add
wallpaper-solidclass simultaneously
User-Customizable Color Overlay Layers (0.1.11+)
On top of the "basic wallpaper transparency system" above, the wallpaper panel exposes three categories of user-adjustable color layers, each driven by body data attributes + CSS variables, CSS selectors layer-wise overriding default surface behavior. All writes managed centrally by themeUtils.applyWallpaperToDocument, components must not directly body.style.setProperty.
1. Dimming Layer (body::before pseudo-element)
| Data Attribute | Trigger | CSS Variable |
|---|---|---|
data-wallpaper-active="true" | Any wallpaper source ready | --wp-dimming (0–1), --wp-dim-{h,s,l} |
data-wp-dim-gradient="true" | wallpaperDimmingGradient set | --wp-dim-gradient (override HSL) |
Brightness fallback: when --wp-dim-l not set, light mode defaults to 100%, dark mode to 0% (corresponding to original white/black behavior).
2. Element Surface Layer (sidebar / cards / tabs / context menu)
| Data Attribute | Trigger | CSS Variable |
|---|---|---|
data-wp-element-tint="true" | wallpaperElementTint is valid hex | --wp-elem-{h,s,l} |
data-wp-element-gradient="true" | wallpaperElementGradient set | --wp-elem-gradient-{15,20,35} (by surface tier alpha versions) |
Design key: each surface tier keeps independent alpha (surface-1 = 15%, surface-2 = 20%, .wallpaper-card = 35%), so even if changed to same tone, visual hierarchy remains distinguishable. Input/Textarea and wallpaper-solid / wallpaper-panel selectors are :not(...) excluded — readability over color consistency.
3. Message Bubble Layer (user / assistant separate)
| Data Attribute | Trigger | CSS Variable |
|---|---|---|
data-wp-bubble-override="true" | wallpaperBubbleOverride === true | --wp-bubble-alpha (0–1) |
data-wp-bubble-tint-user="true" | User bubble hex valid | --wp-bubble-user-{h,s,l} |
data-wp-bubble-tint-assistant="true" | Assistant bubble hex valid | --wp-bubble-asst-{h,s,l} |
data-wp-bubble-gradient-{user,assistant}="true" | Corresponding gradient set | --wp-bubble-{user,asst}-gradient |
CSS selectors cascade in two layers:
/* Layer 1: when override off, bubbles follow element tint (inherit) */
body[data-wp-element-tint="true"]:not([data-wp-bubble-override="true"]) .chat-bubble-user,
body[data-wp-element-tint="true"]:not([data-wp-bubble-override="true"]) .chat-bubble-assistant {
background: hsl(var(--wp-elem-h) var(--wp-elem-s) var(--wp-elem-l) / var(--wp-alpha-35, 0.35)) !important;
}
/* Layer 2: when override on, per-side tint + custom alpha applies */
body[data-wp-bubble-override="true"][data-wp-bubble-tint-user="true"] .chat-bubble-user {
background: hsl(var(--wp-bubble-user-h) var(--wp-bubble-user-s) var(--wp-bubble-user-l) / var(--wp-bubble-alpha, var(--wp-alpha-35, 0.35))) !important;
}
Layer 2 selector has higher specificity + appears later, so with !important it wins on tie.
When Adding New user-tint Field
- Add field in both
ThemePreferences(settings-types.ts) andThemePreferencesSchema(configSchema.ts) - Add setter branch in
ThemeService.setWallpaperPreferences+ default values inreadPreferences/importProfile/resetTheme/mergePreferences - Add field in both Zod schemas in
ThemeRouter(themeProfileSchemaand inline schema intheme:setWallpaperPreferences) - Add context value in
ThemeContext+ setWallpaperPreferences param + commitState fallback - Add param in
themeUtils.applyWallpaperToDocument+_prev*cache + body attribute/CSS variable writes - Add UI in
WallpaperPanel(toggle/color picker/slider) + i18n three languages - Add selector in
index.css(note hierarchy order and!importantpriority) - Passthrough chain:
AppearanceTab→Settings.tsx/ThemeStudioPage.tsx(including DraftState + effective + Apply submit) - Agent stubs:
desktop-api.ts(preload contract),shared/agent/types/settings.ts(shared interface),shared/agent/web/theme.ts(HTTP implementation) - Update this table +
architecture-indexSKILL.md field quick reference +ipc-channels.mdtheme:setWallpaperPreferencesfield table +appearance.mduser documentation
Theme Compatible Development Rules
Use Only Semantic Tokens
Never write #fff/rgb() or Tailwind default colors directly.
Uniformly Use ThemeContext
When component needs theme info, read via useTheme().
Respect Configurable Fonts
For text/code areas use CSS variables:
<div style={{ fontFamily: 'var(--font-ui)' }}>Normal text</div>
<code style={{ fontFamily: 'var(--font-code)' }}>Code</code>
// Or use Tailwind classes
<div className="font-ui">Normal text</div>
<code className="font-code">Code</code>
Allow customCss Override
Avoid !important and large inline styles, prioritize className + CSS variables.
Self-Test Checklist
When creating new UI components:
- Use only semantic tokens (no hard-coded colors)
- Get theme info via
useTheme() - Test dark/light mode switching
- Test wallpaper transparency effect
- Normal text contrast >= 4.5:1
- Dark mode borders use
dark:border-border/50or higher -
text-subtleused only onsurface-0background - Interactive elements use semantic tags or add
role+tabIndex - Focus style uses
focus-visible:ring-2 - Text and borders discernible at 30% screen brightness
- Layout doesn't break when window scaled to 200%