Neocol and LaGana Lion themes, dark and light, picked per project with /og-theme and switched live

Claude Code themes that tell you at a glance who you are working for: Claude's normal theme (personal), Neocol and LaGana Lion, each in dark and light.
| Theme | Accent | Prompt border | Message background |
|---|---|---|---|
| Neocol dark / light | aqua #00a6a5 / teal #3d7986 | teal / aqua | navy #0d2c39 / gray #e6e7e8 |
| LaGana Lion dark / light | chevron red #E8473A | navy #4A6080 / #344563 | navy #1E2D45 / #EAECF0 |
The colors come from the design systems (neocol-branding, 2026, and lagana-branding, Lion collection).
/og-theme opens a numbered menu. Press the number and the theme changes at once:
1. Claude dark 3. Neocol dark 5. LaGana Lion dark
2. Claude light 4. Neocol light 6. LaGana Lion light
You can also pick directly:
/og-theme 4 # Neocol light
/og-theme lagana # changes the brand, keeps dark/light
/og-theme light # changes only the mode
/og-theme reset # removes the project theme: back to the global theme
Each project (the session's directory) has its own theme file, ~/.claude/themes/og-tema-<folder>-<hash>.json, and the project's .claude/settings.local.json points at it ("theme": "custom:og-tema-..."). Claude Code reloads ~/.claude/themes/ when a file changes, so /og-theme only rewrites that file and the session changes color at once. Sessions open in different projects each keep their own theme.
Claude Code reads the theme setting only at startup. So on the first pick in a project the theme takes effect from the next session (the command says so). After that, every change is live.
The four brand themes also show in /theme ("Neocol Dark", "LaGana Lion Light"...). In a project with /og-theme, /theme changes only the global theme, and the project keeps its own.
On Windows the file lives in %USERPROFILE%\.claude\themes; with CLAUDE_CONFIG_DIR set, in $CLAUDE_CONFIG_DIR/themes.
hooks/register.tsx 151 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { baseName, fromKey, key, label, OPTIONS, parseArgs, projectSlug, themeFile, themeOf, withTheme } from './theme'
5import type { Choice } from './theme'
6
7const COMMAND = 'og-theme'
8const PANE = 'og-theme'
9const USAGE = 'Usage: /og-theme (menu), /og-theme <1-6>, /og-theme [claude|neocol|lagana] [dark|light], /og-theme reset.'
10
11const choice = atom({ plugin: 'og-themes', key: 'choice' } as const, null)
12const note = atom({ plugin: 'og-themes', key: 'note' } as const, null)
13
14type Project = { cwd: string; name: string; slug: string; settings: string; themeFile: string }
15
16async function project($: EngineInterface): Promise<Project> {
17 const [cwd, configDir, home, profile] = await Promise.all([
18 $.session.cwd(),
19 $.env.get('CLAUDE_CONFIG_DIR'),
20 $.env.get('HOME'),
21 $.env.get('USERPROFILE'),
22 ])
23 const slug = projectSlug(cwd)
24 // HOME is often unset on Windows; USERPROFILE is its home there.
25 const claudeDir = configDir ?? `${home ?? profile}/.claude`
26
27 return {
28 cwd,
29 name: baseName(cwd),
30 slug,
31 settings: `${cwd}/.claude/settings.local.json`,
32 themeFile: `${claudeDir}/themes/${slug}.json`,
33 }
34}
35
36async function readText($: EngineInterface, path: string) {
37 return (await $.fs.exists(path)) ? $.fs.read(path) : undefined
38}
39
40/** The colors the plugin ships for a brand; Claude's own theme overrides nothing. */
41async function overridesOf($: EngineInterface, picked: Choice): Promise<Record<string, string>> {
42 if (picked.brand === 'claude') return {}
43 const text = await $.fs.read(`${$.plugin.root}/themes/${key(picked)}.json`)
44
45 return JSON.parse(text).overrides ?? {}
46}
47
48/** Whether this session draws the project's theme file, so rewriting it shows at once. */
49async function isLive($: EngineInterface, slug: string) {
50 const row = (await $.config.list()).find(one => one.key === 'theme')
51
52 return row?.value === `custom:${slug}`
53}
54
55/**
56 * Writes the project's theme file and points the project's settings at it.
57 * The session reloads ~/.claude/themes on change, so once it runs on that
58 * file every later pick shows at once; the first one waits for a new session.
59 */
60async function apply($: EngineInterface, picked: Choice): Promise<string> {
61 const p = await project($)
62 await $.fs.write(p.themeFile, themeFile(picked, p.name, await overridesOf($, picked)))
63 const settings = await readText($, p.settings)
64 if (themeOf(settings) !== `custom:${p.slug}`) await $.fs.write(p.settings, withTheme(settings, `custom:${p.slug}`))
65 await $.store.set(`choice:${p.slug}`, key(picked))
66 await update($, choice, () => key(picked))
67
68 const text = (await isLive($, p.slug))
69 ? `This project's theme: ${label(picked)}.`
70 : `This project's theme: ${label(picked)}. It takes effect from the next session in this project; after that, changes show at once.`
71 await update($, note, () => text)
72
73 return text
74}
75
76async function reset($: EngineInterface): Promise<string> {
77 const p = await project($)
78 const settings = await readText($, p.settings)
79 if (themeOf(settings) === `custom:${p.slug}`) await $.fs.write(p.settings, withTheme(settings, undefined))
80 await $.store.set(`choice:${p.slug}`, null)
81 await update($, choice, () => null)
82
83 return 'Project theme removed: the next session uses the global theme.'
84}
85
86async function openMenu($: EngineInterface) {
87 await update($, note, () => null)
88 await $.ui.open({ id: PANE, title: 'Theme', focus: true, closeOnEscape: true })
89}
90
91export const register: Register = on => {
92 on('session.start', async ($, e, next) => {
93 const started = await next(e)
94 await $.command.register({
95 name: COMMAND,
96 description: 'Project theme: Claude, Neocol or LaGana Lion, dark or light',
97 argumentHint: '[1-6 | claude|neocol|lagana dark|light | reset]',
98 })
99 const p = await project($)
100 const stored = fromKey(await $.store.get(`choice:${p.slug}`))
101 await update($, choice, () => (stored ? key(stored) : null))
102 // Rewrite the file from the shipped colors, so an update of the plugin reaches it.
103 if (stored && themeOf(await readText($, p.settings)) === `custom:${p.slug}`) {
104 await $.fs.write(p.themeFile, themeFile(stored, p.name, await overridesOf($, stored)))
105 }
106
107 return started
108 }).catch(($, e, next) => next(e))
109
110 on('command.run', { command: COMMAND }, async ($, e) => {
111 const args = parseArgs(e.args)
112 if (args.kind === 'menu') {
113 await openMenu($)
114 const now = fromKey(await read($, choice))
115 return { text: now ? `This project's theme: ${label(now)}.` : 'This project uses the global theme.' }
116 }
117 if (args.kind === 'reset') return { text: await reset($) }
118 if (args.kind === 'unknown') return { text: `Unknown option "${args.words.join(' ')}". ${USAGE}` }
119
120 const now = fromKey(await read($, choice))
121 const picked: Choice = { brand: args.brand ?? now?.brand ?? 'claude', mode: args.mode ?? now?.mode ?? 'dark' }
122
123 return { text: await apply($, picked) }
124 }).catch(() => ({ text: 'og-themes: could not save the project theme.' }))
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127 const { Box, Text, Button } = $.ui.resolve(e)
128 const [current, shownNote] = await Promise.all([read($, choice), read($, note)])
129
130 return (
131 <Box flexDirection="column">
132 <Text bold>This project's theme: press a number</Text>
133 <Box flexDirection="column" marginTop={1}>
134 {OPTIONS.map((option, i) => (
135 <Button
136 key={`pick:${key(option)}`}
137 label={`${i + 1}. ${label(option)}${current === key(option) ? ' ●' : ''}`}
138 hotkey={String(i + 1)}
139 variant={current === key(option) ? 'primary' : undefined}
140 onPress={() => apply($, option)}
141 />
142 ))}
143 </Box>
144 <Box marginTop={1}>
145 {shownNote !== null ? <Text dimColor>{shownNote}</Text> : <Text dimColor>Esc closes.</Text>}
146 </Box>
147 </Box>
148 )
149 })
150}
151hooks/theme.ts 85 lines1export type Brand = 'claude' | 'neocol' | 'lagana'
2export type Mode = 'dark' | 'light'
3export type Choice = { brand: Brand; mode: Mode }
4
5const BRANDS: readonly Brand[] = ['claude', 'neocol', 'lagana']
6const MODES: readonly Mode[] = ['dark', 'light']
7const LABELS: Record<Brand, string> = { claude: 'Claude', neocol: 'Neocol', lagana: 'LaGana Lion' }
8
9/** The menu, in the order its numbers name. */
10export const OPTIONS: readonly Choice[] = BRANDS.flatMap(brand => MODES.map(mode => ({ brand, mode })))
11
12export function label({ brand, mode }: Choice): string {
13 return `${LABELS[brand]} ${mode}`
14}
15
16export function key({ brand, mode }: Choice): string {
17 return `${brand}-${mode}`
18}
19
20export function fromKey(text: unknown): Choice | undefined {
21 return OPTIONS.find(option => key(option) === text)
22}
23
24export type Args =
25 | { kind: 'menu' }
26 | { kind: 'reset' }
27 | { kind: 'pick'; brand?: Brand; mode?: Mode }
28 | { kind: 'unknown'; words: string[] }
29
30/** `/og-theme 3`, `/og-theme neocol light` (any order, either word), `/og-theme reset`. */
31export function parseArgs(args: string): Args {
32 const words = args.toLowerCase().split(/\s+/).filter(word => word !== '')
33 if (words.length === 0) return { kind: 'menu' }
34 if (words.length === 1 && words[0] === 'reset') return { kind: 'reset' }
35 if (words.length === 1 && /^\d+$/.test(words[0] ?? '')) {
36 const option = OPTIONS[Number(words[0]) - 1]
37 return option ? { kind: 'pick', ...option } : { kind: 'unknown', words }
38 }
39 const brand = BRANDS.find(b => words.includes(b))
40 const mode = MODES.find(m => words.includes(m))
41 const unknown = words.filter(word => word !== brand && word !== mode)
42
43 return unknown.length > 0 ? { kind: 'unknown', words: unknown } : { kind: 'pick', brand, mode }
44}
45
46/** FNV-1a, so two folders with the same name get their own theme file. */
47function hash(text: string): string {
48 let h = 0x811c9dc5
49 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
50
51 return (h >>> 0).toString(16).padStart(8, '0').slice(0, 6)
52}
53
54export function baseName(cwd: string): string {
55 return cwd.split(/[\\/]/).filter(part => part !== '').pop() ?? 'root'
56}
57
58/** The project's own theme slug: a file in ~/.claude/themes the session watches. */
59export function projectSlug(cwd: string): string {
60 const name = baseName(cwd).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 32)
61
62 // The `og-tema-` prefix predates the English command; kept so projects already set keep their file.
63 return `og-tema-${name || 'root'}-${hash(cwd)}`
64}
65
66/** The project's theme file: Claude's own preset, or a brand's colors on its base. */
67export function themeFile(choice: Choice, project: string, overrides: Record<string, string>): string {
68 const theme = { name: `og-theme · ${project} (${label(choice)})`, base: choice.mode, overrides }
69
70 return `${JSON.stringify(theme, null, 2)}\n`
71}
72
73/** The settings file with `theme` set, or removed when `value` is undefined; other keys kept. */
74export function withTheme(text: string | undefined, value: string | undefined): string {
75 const settings: Record<string, unknown> = text?.trim() ? JSON.parse(text) : {}
76 if (value === undefined) delete settings.theme
77 else settings.theme = value
78
79 return `${JSON.stringify(settings, null, 2)}\n`
80}
81
82export function themeOf(text: string | undefined): unknown {
83 return text?.trim() ? JSON.parse(text).theme : undefined
84}
85types/index.d.ts 14 lines1/** A choice as `<brand>-<mode>`, such as `neocol-dark`. */
2export type ChoiceKey = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'og-themes': {
7 /** The project's choice; null when the project has none. */
8 choice: ChoiceKey | null
9 /** The line under the menu after a pick. */
10 note: string | null
11 }
12 }
13}
14