Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions _docs/config/theme.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,29 @@ Reasons:
2. Keeping only crabcode-owned theme config makes precedence predictable for users.
3. It avoids accidental overrides when existing OpenCode configs are shared across tools.

## Appearance

Every theme has an `appearance` of either `"dark"` or `"light"`:

- Declared in theme JSON as `"appearance": "dark"` or `"appearance": "light"`.
- If omitted, Crabcode infers it from the theme's background luminance.
- Searchable in `/themes`: type `light` or `dark` to filter the list.
- Selecting a theme aligns the UI's dark/light color slots to that appearance.

OpenCode dual-mode themes are imported as two selectable entries: the base ID (dark) and `{id}-light` (light). Example: `github` and `github-light`. Themes whose light Mode is identical to dark (fake dual-mode) only keep the base entry.

## Background transparency

By default the main UI paints a **full-screen solid** theme background (OpenCode-style), so light themes like Grok Day look consistent in any terminal.

In the `/themes` dialog, press **`ctrl+t`** to toggle transparency on/off. When on, the main canvas uses the terminal's own background (`Color::Reset`). Preference is persisted. Panels and dialogs stay solid either way.

Themes that declared `"background": "transparent"` are solidified when generated/loaded; runtime transparency is only via the toggle.

## Selecting a theme

- Use the `/themes` dialog to preview and select a theme.
- The selected theme is persisted in the local preferences database and restored on next launch.
- While the dialog is open, highlighting a theme previews it live; Esc cancels back to the previously active theme.

For theme file format and naming, use the [OpenCode theme docs](https://opencode.ai/docs/themes/).
225 changes: 221 additions & 4 deletions scripts/gen-themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

// @ts-nocheck

import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

type GitHubFile = {
Expand Down Expand Up @@ -160,6 +160,205 @@ function injectTextWeak(themeJson: Record<string, unknown>) {
themeJson.theme = theme
}

/**
* Solid backgrounds by default. Upstream themes sometimes set `background` to
* "transparent" or semi-transparent `#rrggbbaa`; replace / strip so the TUI
* paints a real opaque bg. Users can still enable transparency at runtime via
* /themes (ctrl+t).
*/
function solidifyBackground(themeJson: Record<string, unknown>) {
if (!themeJson.theme || !themeJson.defs) return

const theme = themeJson.theme as Record<string, unknown>
const defs = themeJson.defs as Record<string, string>
const bg = theme.background
if (bg === undefined) return

const isTransparent = (v: unknown) =>
typeof v === 'string' && v.trim().toLowerCase() === 'transparent'

const stripAlpha = (hex: string): string => {
const h = hex.trim()
if (/^#[0-9a-fA-F]{8}$/.test(h)) return h.slice(0, 7)
if (/^#[0-9a-fA-F]{4}$/.test(h)) return `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`
return h
}

const solidForMode = (mode: ThemeMode): string => {
const panel = getModeValue(theme.backgroundPanel, mode)
const menu = getModeValue(theme.backgroundMenu, mode)
const element = getModeValue(theme.backgroundElement, mode)
for (const ref of [panel, menu, element]) {
if (!ref || isTransparent(ref)) continue
const hex = resolveToHex(defs, theme, ref)
if (hex.startsWith('#')) return stripAlpha(hex)
}
return mode === 'dark' ? '#0d0d0d' : '#fafafa'
}

const solidifyValue = (v: unknown, mode: ThemeMode): string => {
if (typeof v !== 'string' || isTransparent(v)) return solidForMode(mode)
if (v.startsWith('#')) return stripAlpha(v)
// def ref — resolve, strip alpha, keep as hex so we don't mutate shared defs
const hex = resolveToHex(defs, theme, v)
if (hex.startsWith('#')) return stripAlpha(hex)
return solidForMode(mode)
}

if (typeof bg === 'string') {
theme.background = {
dark: solidifyValue(bg, 'dark'),
light: solidifyValue(bg, 'light'),
}
return
}

if (bg && typeof bg === 'object') {
const dual = bg as Record<string, unknown>
for (const mode of ['dark', 'light'] as const) {
if (dual[mode] !== undefined) {
dual[mode] = solidifyValue(dual[mode], mode)
} else {
dual[mode] = solidForMode(mode)
}
}
theme.background = dual
}
}

/**
* Tag each theme as "dark" or "light" based on its primary (dark-mode) background
* luminance. Searchable in /themes.
*/
function injectAppearance(themeJson: Record<string, unknown>) {
if (typeof themeJson.appearance === 'string') return
if (!themeJson.theme || !themeJson.defs) {
themeJson.appearance = 'dark'
return
}

const theme = themeJson.theme as Record<string, unknown>
const defs = themeJson.defs as Record<string, string>
const bgRef =
getModeValue(theme.background, 'dark') ??
getModeValue(theme.backgroundPanel, 'dark') ??
getModeValue(theme.backgroundMenu, 'dark')
if (!bgRef || bgRef.toLowerCase() === 'transparent') {
themeJson.appearance = 'dark'
return
}
const hex = resolveToHex(defs, theme, bgRef)
const lum = luminance(hex)
themeJson.appearance = lum !== undefined && lum > 0.5 ? 'light' : 'dark'
}

/** True when theme.background exposes a distinct light Mode hex. */
function hasLightMode(themeJson: Record<string, unknown>): boolean {
const theme = themeJson.theme
if (!theme || typeof theme !== 'object') return false
const background = (theme as Record<string, unknown>).background
return (
!!background &&
typeof background === 'object' &&
typeof (background as Record<string, unknown>).light === 'string'
)
}

/**
* Some OpenCode themes declare `{ dark, light }` Mode objects but use the same
* values for both (e.g. aura, nightowl). Emitting a *-light sibling would be a
* duplicate — skip those.
*/
function hasDistinctLightPalette(themeJson: Record<string, unknown>): boolean {
const theme = themeJson.theme
if (!theme || typeof theme !== 'object') return false

let compared = 0
for (const value of Object.values(theme as Record<string, unknown>)) {
if (!value || typeof value !== 'object') continue
const mode = value as Record<string, unknown>
if (typeof mode.dark !== 'string' || typeof mode.light !== 'string') continue
compared++
if (mode.dark !== mode.light) return true
}
// No Mode slots, or every Mode slot is identical dark===light.
return false
}

/**
* Dual-mode OpenCode themes keep both palettes. Emit a sibling `{id}-light.json`
* with `appearance: "light"` so `/themes` can filter/select the light palette
* as its own entry (dark sibling stays the default).
*
* Skipped when light Mode is missing or identical to dark (fake dual-mode).
*/
function makeLightSibling(
themeJson: Record<string, unknown>,
baseId: string,
): Record<string, unknown> | undefined {
if (!hasLightMode(themeJson) || !hasDistinctLightPalette(themeJson)) {
return undefined
}
// Keep dual Mode slots; runtime picks light via appearance → dark_mode=false.
const sibling = structuredClone(themeJson) as Record<string, unknown>
sibling.id = `${baseId}-light`
sibling.appearance = 'light'
if (typeof sibling.name === 'string' && sibling.name.length > 0) {
sibling.name = `${sibling.name} Light`
} else {
// Title-case the id for nicer /themes labels (e.g. "Github Light").
sibling.name =
baseId
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ') + ' Light'
}
return sibling
}

function writeBundledThemesList(generatedIds: string[]) {
// Hand-authored themes live in src/themes/; generated ones in src/generated_themes/.
const handAuthored: Array<{ id: string; path: string }> = [
{ id: 'crabcode-orange', path: 'themes/crabcode-orange.json' },
{ id: 'groknight', path: 'themes/groknight.json' },
{ id: 'grokday', path: 'themes/grokday.json' },
]

const formatEntry = (id: string, path: string) => {
if (id.includes('-')) {
return ` (\n "${id}",\n include_str!("${path}"),\n ),`
}
return ` ("${id}", include_str!("${path}")),`
}

const entries = [
...handAuthored.map(({ id, path }) => formatEntry(id, path)),
...generatedIds.map((id) => formatEntry(id, `generated_themes/${id}.json`)),
].join('\n')

const themeRsPath = join(process.cwd(), 'src', 'theme.rs')
const themeRs = readFileSync(themeRsPath, 'utf8')
const start = 'const BUNDLED_THEMES: &[(&str, &str)] = &['
const end = '];'
const startIdx = themeRs.indexOf(start)
if (startIdx < 0) throw new Error('BUNDLED_THEMES start marker not found in src/theme.rs')
const endIdx = themeRs.indexOf(end, startIdx)
if (endIdx < 0) throw new Error('BUNDLED_THEMES end marker not found in src/theme.rs')

const next =
themeRs.slice(0, startIdx) +
start +
'\n' +
entries +
'\n' +
end +
themeRs.slice(endIdx + end.length)
writeFileSync(themeRsPath, next)
console.log(
`Updated BUNDLED_THEMES in src/theme.rs (${handAuthored.length + generatedIds.length} themes)`,
)
}

async function fetchThemes() {
const response = await fetch(GITHUB_API_URL)
if (!response.ok) {
Expand All @@ -171,6 +370,8 @@ async function fetchThemes() {
rmSync(THEMES_DIR, { recursive: true, force: true })
mkdirSync(THEMES_DIR, { recursive: true })

const generatedIds: string[] = []

for (const file of files) {
if (!file?.name?.endsWith('.json')) continue
if (!file.download_url) continue
Expand All @@ -185,20 +386,36 @@ async function fetchThemes() {
}

const themeContent = await themeResponse.text()
const baseId = file.name.replace(/\.json$/, '')
const themePath = join(THEMES_DIR, file.name)

try {
const themeJson = JSON.parse(themeContent) as Record<string, unknown>
injectTextWeak(themeJson)
solidifyBackground(themeJson)
injectAppearance(themeJson)
writeFileSync(themePath, JSON.stringify(themeJson, null, 2) + '\n')
generatedIds.push(baseId)
console.log(`Saved ${file.name}`)

const lightSibling = makeLightSibling(themeJson, baseId)
if (lightSibling) {
const lightName = `${baseId}-light.json`
writeFileSync(join(THEMES_DIR, lightName), JSON.stringify(lightSibling, null, 2) + '\n')
generatedIds.push(`${baseId}-light`)
console.log(`Saved ${lightName}`)
}
} catch {
writeFileSync(themePath, themeContent)
generatedIds.push(baseId)
console.log(`Saved ${file.name} (raw)`)
}

console.log(`Saved ${file.name}`)
}

console.log(`\nDone! Themes saved to ${THEMES_DIR}`)
generatedIds.sort((a, b) => a.localeCompare(b))
writeBundledThemesList(generatedIds)

console.log(`\nDone! Themes saved to ${THEMES_DIR} (${generatedIds.length} generated)`)
}

fetchThemes().catch((err) => {
Expand Down
Loading
Loading