Messenger-style chat for Claude Code: your prompts on the right in a rival color, Claude on the left, only running or failing tools stand out. 441 themes…

Your chat with Claude, laid out like every messenger you already use: your messages on the right, Claude's on the left, and your bubble in a rival color so you can tell who said what before you read a word.
╭──────────────────────────────╮
│ fix the failing login test │ ← you: right, warm
╰──────────────────────────────╯
╭──────────────────────────────────────────────────────────────────╮
│ ✦ Claude │ ← Claude: left, quiet
│ The test mocks an expired token. I updated the fixture and… │
╰──────────────────────────────────────────────────────────────────╯
▍ Bash(npm test) ✗ 2 failed ← only failures shout
A fork of Theme Studio by Alliance Optima, redesigned for reading speed. It keeps all 441 contrast-checked themes and the studio.
| Theme Studio | Chat Bubbles | |
|---|---|---|
| Your messages | Full width, left, a YOU chip | Right-aligned bubble, like a messenger |
| Your bubble color | The theme's accent (same family as Claude's) | A rival color: orange under a cool theme, sky blue under a warm one |
| Claude's replies | Solid colored header bar | A quiet card with a ✦ Claude label |
| Intensity | Full neon | Each color pulled 25% toward neutral ink, softer backgrounds |
| Finished tools | Accent stripe (everything is colored) | Gray: only running and failing tools stand out |
| Desktop tool rows | Empty colored bars under the app's own tool summary | Left to the app: no empty bars |
| Pasted images (desktop) | Hidden in full-color mode | A choice: /bubbles prompt frame keeps them, the default text bubble does not |
| Command | /theme (replaces Claude Code's built-in /theme) | /bubbles (the built-in /theme keeps working) |
Requires Claude Code 2.1.286 or later (plugin function hooks), in the terminal or the desktop app's Code tab.
/plugin marketplace add angeldelbiondo/claude-chat-bubbles
/plugin install chat-bubbles@claude-chat-bubbles
Start a new session (or run /reload-plugins), then:
/bubbles matrix
Using Theme Studio too? Turn one off: both redraw the same messages.
| Command | Does | ||
|---|---|---|---|
/bubbles | Open the studio: search, browse collections, mix your own, toggle options | ||
/bubbles <name> | Apply a theme: /bubbles dracula, /bubbles matrix (ignores case and accents, takes prefixes) | ||
/bubbles random [collection] | A random theme, optionally from one collection | ||
/bubbles next · /bubbles prev | Step through the current collection | ||
/bubbles list [collection] | Every theme, or one collection's | ||
| `/bubbles bg <#hex \ | auto>` | One background for every theme; text is re-checked for contrast | |
| `/bubbles base <auto \ | dark \ | light>` | Tune colors for a dark or light canvas |
| `/bubbles prompt <bubble | frame | native>` | Your prompts: a text bubble (default), a frame around the app's own row (keeps pasted images), or untouched |
/bubbles off | Back to Claude Code's own look |
The bubble color is picked from the opposite side of the color wheel from the theme's accent:
#ff8c42 dark / #c2410c light)#38bdf8 dark / #0369a1 light)Your text on the bubble is held to WCAG AA (4.5:1), the border to 3:1. The test suite checks every theme on both canvases.
/bubbles prompt <bubble | frame | native>, also a button in the studio.
| Mode | What you get | Trade-off |
|---|---|---|
bubble (default) | Your text in a right-aligned bubble the mod draws itself, in the rival color, sized to the text. Pasted images and files are drawn by the app above the bubble, outside the colored block | The app's row leaves a small empty pill under the attachments; the mod covers it by pulling the bubble up a measured amount (4.7 cells, at the default zoom), which leaves ~10px between the image and the bubble. The bubble sits on a plate of the canvas color, so the app's empty pill can't show through its rounded corners (if your app background differs from the canvas of /bubbles base, the four corner wedges can show a faint difference) |
frame | The app's own message row, framed in the rival color | Keeps pasted images, but the app sizes its row, so the frame can be tight and leave empty space |
native | The app's own row, untouched | No rival color |
Prompts sent before the mod was installed are left in the app's own look (the mod never saw them, so it can't tell whether they carry an image), and nothing is lost.
Plugins can't reach Claude Code's window chrome (the sidebar, the prompt box, the app background) or recolor the desktop app's native bubble in place, so no theme can. The app's own message row is also drawn once and sized by the app: the engine refuses a tree that sets width, minWidth, height, minHeight, overflow or position on any Box above it, and a frame around it that shrinks to fit collapses to nothing. That is why bubble draws its own text instead of restyling the row.
Chat Bubbles only draws. Checked with claude plugin validate, it calls:
| Engine API | Why |
|---|---|
$.ui.* | Draw messages, the studio pane, toasts |
$.state / $.store | Remember your theme and options (local to your machine) |
session.append (your prompts and slash commands) | Looks at the kinds of block in each row (text, image, file) so the app's row is drawn only for prompts that carry an image or file. Only the row id and a yes/no are kept (in session memory and in the plugin store); no text is kept, and nothing is sent anywhere |
$.config.list | Read whether Claude Code's theme is dark or light |
$.settings.read | Read prefersReducedMotion |
$.command.register, $.clock | The /bubbles command, the spinner shimmer |
It makes no network requests, reads or writes no files, runs no processes and never calls the model. There are no runtime dependencies. Mods run with your permissions, so read the code before installing any mod, this one included: it's about 1,000 lines of TypeScript in chat-bubbles/hooks.
Found a vulnerability? See SECURITY.md.
git clone https://github.com/angeldelbiondo/claude-chat-bubbles
cd claude-chat-bubbles
claude --plugin-dir ./chat-bubbles # edits hot-reload
| Script | Does |
|---|---|
npm run typecheck | tsc over the plugin and its tests (run /plugin-types .claude/types in Claude Code once first) |
npm run validate | claude plugin validate on the marketplace and the plugin |
npm test | claude plugin test: unit tests plus engine tests on terminal and desktop |
See CONTRIBUTING.md to add a theme.
Chat Bubbles ordena el chat de Claude Code como cualquier app de mensajería: tus mensajes a la derecha, en un color rival al del tema (naranja si el tema es frío, celeste si es cálido), y los de Claude a la izquierda. Baja la intensidad de los 441 temas y deja en gris las tools que terminaron bien, para que solo resalten las que fallan o están corriendo. Se instala con los dos comandos de arriba y se usa con /bubbles <tema>.
Forked from Theme Studio by Alliance Optima, under the MIT license. The 441 palettes, the studio and the markdown painter are theirs.
Theme names describe the palettes they evoke. They are not affiliated with, sponsored by or endorsed by the owners of any franchise, team, brand or place named, and every trademark belongs to its owner. The themes carry colors only: no logos, artwork or other marks.
hooks/register.tsx 865 lines1// Chat Bubbles: Claude Code's chat laid out like a messenger. Your prompts are a
2// bubble on the right in a rival color, Claude's replies a quiet card on the left,
3// and only running or failing tools stand out. Colors come from 441 palettes or
4// one you mix. `/bubbles` opens the studio; `/bubbles help` lists the commands.
5// Forked from Theme Studio by Alliance Optima (MIT).
6
7import { atom, read, update } from 'claude-code'
8import type { ElementTable, EngineInterface, Register, RenderElement, RenderNode, Timer } from 'claude-code'
9
10import type { BaseMode, MessageStyle, Palette, PromptStyle } from '../types'
11import { isHex, loopGradient, normalizeHex } from './color'
12import { lookOf, stripOf } from './look'
13import type { Base, Look } from './look'
14import { paint } from './markdown'
15import { blocksOf, clean, remember } from './media'
16import {
17 DEFAULT_CUSTOM,
18 GROUPS,
19 MINE,
20 findPreset,
21 inGroup,
22 isPalette,
23 pickRandom,
24 randomNeon,
25 searchPresets,
26 slug,
27} from './palette'
28import { PRESETS } from './presets'
29
30const PANE = 'chat-bubbles'
31
32// ── State ────────────────────────────────────────────────────────────────
33// Session values live in $.state (they survive hot reloads); the ones worth
34// keeping between sessions are mirrored to $.store under the same key.
35
36const active = atom({ plugin: 'chat-bubbles', key: 'active' } as const, null)
37const group = atom({ plugin: 'chat-bubbles', key: 'group' } as const, GROUPS[0] ?? MINE)
38const query = atom({ plugin: 'chat-bubbles', key: 'query' } as const, '')
39const custom = atom({ plugin: 'chat-bubbles', key: 'custom' } as const, DEFAULT_CUSTOM)
40const saved = atom({ plugin: 'chat-bubbles', key: 'saved' } as const, [])
41const messageStyle = atom({ plugin: 'chat-bubbles', key: 'messageStyle' } as const, 'full')
42const promptStyle = atom({ plugin: 'chat-bubbles', key: 'promptStyle' } as const, 'bubble')
43const media = atom({ plugin: 'chat-bubbles', key: 'media' } as const, {})
44const themeChrome = atom({ plugin: 'chat-bubbles', key: 'themeChrome' } as const, true)
45const base = atom({ plugin: 'chat-bubbles', key: 'base' } as const, 'auto')
46const resolvedBase = atom({ plugin: 'chat-bubbles', key: 'resolvedBase' } as const, 'dark')
47const bgOverride = atom({ plugin: 'chat-bubbles', key: 'bgOverride' } as const, null)
48const reducedMotion = atom({ plugin: 'chat-bubbles', key: 'reducedMotion' } as const, false)
49const frame = atom({ plugin: 'chat-bubbles', key: 'frame' } as const, 0)
50const notice = atom({ plugin: 'chat-bubbles', key: 'notice' } as const, '')
51
52const STYLES: readonly MessageStyle[] = ['full', 'outline', 'off']
53const STYLE_LABEL: Record<MessageStyle, string> = { full: 'full color', outline: 'outline only', off: 'off' }
54const PROMPTS: readonly PromptStyle[] = ['bubble', 'frame', 'native']
55const PROMPT_LABEL: Record<PromptStyle, string> = { bubble: 'bubble (text only)', frame: 'frame (keeps images)', native: 'native (untouched)' }
56const BASES: readonly BaseMode[] = ['auto', 'dark', 'light']
57
58type Slot = 'accent' | 'secondary' | 'highlight' | 'text' | 'background'
59const SLOTS: readonly { slot: Slot; label: string }[] = [
60 { slot: 'accent', label: 'Accent ' },
61 { slot: 'secondary', label: 'Secondary ' },
62 { slot: 'highlight', label: 'Highlight ' },
63 { slot: 'text', label: 'Text ' },
64 { slot: 'background', label: 'Background' },
65]
66
67const SPARKS = ['✦', '✧', '✶', '✷', '✸', '✹', '✸', '✷']
68const MODE_LABEL: Record<string, string> = {
69 thinking: 'thinking',
70 requesting: 'warming up',
71 responding: 'writing',
72 'tool-input': 'preparing',
73 'tool-use': 'working',
74}
75const SHIMMER_MS = 260
76// How wide the studio asks to dock beside a fullscreen transcript; the person's own drag wins.
77const STUDIO_COLUMNS = 64
78const YOURS = ['composer', 'sdk', 'bridge']
79
80// The app's row for a prompt leaves an empty pill (with no text it is only that,
81// under any attachments). Measured on desktop: a margin unit is 16px, the pill is
82// ~23px and starts ~12px under the attachments, and the bubble would start ~85px
83// below that. Pulling it up 5 units covered the pill but left the bubble 5px from
84// the image, touching it; 4.7 (75px) leaves ~10px and still covers the pill, which
85// starts at ~12px. A margin unit is 16px at the default zoom.
86const PILL_CELLS = 4.7
87
88const HELP = [
89 '**Chat Bubbles** — messenger-style chat for Claude Code.',
90 '',
91 '- `/bubbles` — open the studio (browse, search, mix your own)',
92 '- `/bubbles <name>` — apply a theme by name, e.g. `/bubbles dracula`',
93 '- `/bubbles random [collection]` — surprise me, e.g. `/bubbles random hockey`',
94 '- `/bubbles next` · `/bubbles prev` — step through the current collection',
95 '- `/bubbles list [collection]` — every theme, or one collection',
96 '- `/bubbles bg <#hex | auto>` — set a background for every theme',
97 '- `/bubbles base <auto | dark | light>` — tune colors for a dark or light canvas',
98 '- `/bubbles prompt <bubble | frame | native>` — your prompts: a text bubble, a frame that keeps pasted images, or untouched',
99 '- `/bubbles off` — back to Claude Code\'s own look',
100].join('\n')
101
102// ── Engine-facing helpers (top level, as the engine requires for `$`) ─────
103
104async function persist($: EngineInterface, key: string, value: unknown) {
105 try {
106 await $.store.set(key, value)
107 } catch {
108 // The store refuses only non-JSON or oversize data; the session value stands.
109 }
110}
111
112async function apply($: EngineInterface, palette: Palette | null) {
113 await update($, active, () => palette)
114 await persist($, 'active', palette)
115 await update($, notice, () => (palette ? `Applied ${palette.name}` : "Theme off: Claude Code's own look"))
116 $.ui.toast(palette ? `🎨 ${palette.name}` : '🎨 Theme off')
117}
118
119/** The Look every drawing uses, or null when no theme is on. */
120async function currentLook($: EngineInterface): Promise<{ pal: Palette; look: Look } | null> {
121 const pal = await read($, active)
122 if (!pal) return null
123 const look = lookOf(pal, (await read($, resolvedBase)) as Base, await read($, bgOverride))
124 return { pal, look }
125}
126
127/** Whether the tool rows, spinner, footer and panes follow the theme. */
128async function chromeLook($: EngineInterface) {
129 if (!(await read($, themeChrome))) return null
130 return currentLook($)
131}
132
133async function resolveBase($: EngineInterface) {
134 const mode = await read($, base)
135 let next: Base = mode === 'light' ? 'light' : 'dark'
136 if (mode === 'auto') {
137 try {
138 const row = (await $.config.list()).find(r => r.key === 'theme')
139 next = String(row?.value ?? '').startsWith('light') ? 'light' : 'dark'
140 } catch {
141 next = 'dark'
142 }
143 }
144 await update($, resolvedBase, () => next)
145}
146
147/** Bring back what was picked in earlier sessions, skipping anything malformed. */
148async function restore($: EngineInterface) {
149 const get = async (key: string) => {
150 try {
151 return await $.store.get(key)
152 } catch {
153 return undefined
154 }
155 }
156 const storedActive = await get('active')
157 if (storedActive === null || isPalette(storedActive)) await update($, active, () => storedActive)
158 const storedCustom = await get('custom')
159 if (isPalette(storedCustom)) await update($, custom, () => storedCustom)
160 const storedSaved = await get('saved')
161 if (Array.isArray(storedSaved)) await update($, saved, () => storedSaved.filter(isPalette))
162 const storedStyle = await get('messageStyle')
163 if (STYLES.includes(storedStyle as MessageStyle)) await update($, messageStyle, () => storedStyle as MessageStyle)
164 const storedPrompt = await get('promptStyle')
165 if (PROMPTS.includes(storedPrompt as PromptStyle)) await update($, promptStyle, () => storedPrompt as PromptStyle)
166 const storedChrome = await get('themeChrome')
167 if (typeof storedChrome === 'boolean') await update($, themeChrome, () => storedChrome)
168 const storedBase = await get('base')
169 if (BASES.includes(storedBase as BaseMode)) await update($, base, () => storedBase as BaseMode)
170 // Only row ids stay: a store or session state from an older version can hold text.
171 const storedMedia = await get('media')
172 const stored = typeof storedMedia === 'object' && storedMedia !== null && !Array.isArray(storedMedia) ? storedMedia : {}
173 await persist($, 'media', await update($, media, seen => clean({ ...stored, ...seen })))
174 const storedBg = await get('bgOverride')
175 if (storedBg === null || isHex(storedBg)) await update($, bgOverride, () => storedBg)
176 try {
177 const settings = await $.settings.read()
178 await update($, reducedMotion, () => settings.prefersReducedMotion === true)
179 } catch {
180 // Settings unreadable: keep the motion on.
181 }
182}
183
184async function step($: EngineInterface, delta: number) {
185 const pal = await read($, active)
186 const list = inGroup(pal?.group ?? (await read($, group)), await read($, saved))
187 if (list.length === 0) return null
188 const at = pal ? list.findIndex(one => one.id === pal.id) : -1
189 const next = list[(at + delta + list.length) % list.length] ?? null
190 if (next) await apply($, next)
191 return next
192}
193
194async function setBackground($: EngineInterface, raw: string) {
195 const arg = raw.trim().toLowerCase()
196 if (arg === '' || arg === 'auto' || arg === 'off' || arg === 'none') {
197 await update($, bgOverride, () => null)
198 await persist($, 'bgOverride', null)
199 return 'Background back to each theme\'s own tint.'
200 }
201 const hex = normalizeHex(arg)
202 if (!hex) return `\`${raw.trim()}\` isn't a hex color. Try \`/bubbles bg #1a1a2e\` or \`/bubbles bg auto\`.`
203 await update($, bgOverride, () => hex)
204 await persist($, 'bgOverride', hex)
205 return `Background set to \`${hex}\` for every theme. Text is re-checked for contrast against it.`
206}
207
208async function setBase($: EngineInterface, mode: BaseMode) {
209 await update($, base, () => mode)
210 await persist($, 'base', mode)
211 await resolveBase($)
212}
213
214/** Opens the studio: docked beside the transcript where the surface docks panes, else above the prompt. */
215async function openStudio($: EngineInterface) {
216 return $.ui.open({ id: PANE, title: 'Chat Bubbles', focus: true, closeOnEscape: true, columns: STUDIO_COLUMNS })
217}
218
219// ── Pure drawing helpers ─────────────────────────────────────────────────
220
221/** A tree that draws nothing: an empty string, or Boxes that hold nothing and take no room of their own. */
222const blank = (node: RenderNode | null | undefined): boolean =>
223 node === null ||
224 node === undefined ||
225 node === '' ||
226 (typeof node === 'object' &&
227 node.type === 'Box' &&
228 !Object.keys(node.props ?? {}).some(k => /height|padding|margin|border/i.test(k)) &&
229 (node.children ?? []).every(blank))
230
231const formatDuration = (ms: number) => {
232 const s = Math.max(0, Math.round(ms / 1000))
233 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
234}
235
236const listing = (collection?: string, mine: readonly Palette[] = []) => {
237 const groups = collection ? GROUPS.filter(g => slug(g).includes(slug(collection))) : GROUPS
238 const all = [...PRESETS, ...mine]
239 const lines = groups
240 .map(g => {
241 const names = all.filter(one => one.group === g).map(one => one.name)
242 return names.length ? `**${g}** (${names.length}): ${names.join(', ')}` : ''
243 })
244 .filter(Boolean)
245 if (lines.length === 0) return `No collection matches \`${collection}\`. Try \`/bubbles list\`.`
246 return `${all.length} themes in ${GROUPS.length - 1} collections. Apply one with \`/bubbles <name>\`.\n\n${lines.join('\n\n')}`
247}
248
249// ── Hooks ────────────────────────────────────────────────────────────────
250
251export const register: Register = on => {
252 let tick: Timer | null = null
253 let mainTurn = ''
254 const stopShimmer = () => {
255 tick?.cancel()
256 tick = null
257 }
258
259 on('session.start', async ($, e, next) => {
260 await $.command.register({
261 name: 'bubbles',
262 description: 'Recolor the chat: open the studio, or /bubbles <name> | random | next | list | bg | base | off',
263 argumentHint: '[name | random | next | prev | list | bg <hex> | base <mode> | off | help]',
264 })
265 await restore($)
266 await resolveBase($)
267 return next(e)
268 })
269
270 // Follow Claude Code's own dark/light setting while the base is `auto`.
271 on('config.set', { key: 'theme' }, async ($, e, next) => {
272 const done = await next(e)
273 await resolveBase($)
274 return done
275 })
276
277 // The spinner shimmers while the main loop's turn runs: from its turn.start (a local slash command or a
278 // dropped prompt starts none, and a subagent's run raises none) to its own end, never a subagent's.
279 on('turn.start', async ($, e, next) => {
280 stopShimmer()
281 mainTurn = e.turnId
282 if ((await chromeLook($)) && !(await read($, reducedMotion))) {
283 tick = $.clock.every(SHIMMER_MS, () => void update($, frame, f => (f + 1) % 960))
284 }
285 return next(e)
286 })
287
288 on('turn.complete', async ($, e, next) => {
289 if (!e.agentId) stopShimmer()
290 return next(e)
291 })
292
293 on('turn.abort', async ($, e, next) => {
294 if (e.turnId === mainTurn) stopShimmer()
295 return next(e)
296 }).catch(($, e, next) => next(e))
297
298 on('command.run', { command: 'bubbles' }, async ($, e) => {
299 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
300 const arg = rest.join(' ')
301 switch (verb.toLowerCase()) {
302 case '':
303 await openStudio($)
304 return { text: 'Chat Bubbles studio opened.' }
305 case 'help':
306 case '?':
307 return { text: HELP }
308 case 'off':
309 case 'reset':
310 case 'none':
311 await apply($, null)
312 return { text: 'Theme off.' }
313 case 'list':
314 case 'all':
315 return { text: listing(arg || undefined, await read($, saved)) }
316 case 'random': {
317 const pick = pickRandom(arg || undefined)
318 if (!pick) return { text: `No collection matches \`${arg}\`. Try \`/bubbles list\`.` }
319 await apply($, pick)
320 return { text: `🎲 ${pick.name} · ${pick.group}` }
321 }
322 case 'next':
323 case 'prev': {
324 const one = await step($, verb.toLowerCase() === 'next' ? 1 : -1)
325 return { text: one ? `🎨 ${one.name} · ${one.group}` : 'Nothing to step through yet.' }
326 }
327 case 'bg':
328 case 'background':
329 return { text: await setBackground($, arg) }
330 case 'prompt': {
331 const mode = arg.toLowerCase() as PromptStyle
332 if (!PROMPTS.includes(mode)) return { text: `Use \`/bubbles prompt ${PROMPTS.join('\`, \`/bubbles prompt ')}\`. Now: ${PROMPT_LABEL[await read($, promptStyle)]}.` }
333 await update($, promptStyle, () => mode)
334 await persist($, 'promptStyle', mode)
335 return { text: `Your prompts: ${PROMPT_LABEL[mode]}.` }
336 }
337 case 'base': {
338 const mode = arg.toLowerCase() as BaseMode
339 if (!BASES.includes(mode)) return { text: 'Use `/bubbles base auto`, `/bubbles base dark` or `/bubbles base light`.' }
340 await setBase($, mode)
341 return { text: `Colors tuned for a ${await read($, resolvedBase)} canvas (${mode}).` }
342 }
343 default: {
344 const hit = findPreset(e.args, await read($, saved))
345 if (!hit) {
346 const near = searchPresets(e.args, await read($, saved), 5).map(one => `\`${one.name}\``)
347 return { text: `No theme matches "${e.args.trim()}".${near.length ? ` Close: ${near.join(', ')}.` : ''} Try \`/bubbles list\`.` }
348 }
349 await apply($, hit)
350 return { text: `🎨 ${hit.name} · ${hit.group}` }
351 }
352 }
353 })
354
355 // Note which prompts carry media, before they are drawn. A slash command's own
356 // row comes in by another door. Only the row id and a yes/no are kept.
357 for (const door of ['prompt', 'command'] as const) {
358 on('session.append', { door }, async ($, e, next) => {
359 const blocks = blocksOf(e.message.content)
360 await persist($, 'media', await update($, media, was => remember(was, e.uuid, blocks)))
361 return next(e)
362 })
363 }
364
365 // Your prompts: a bubble on the right in the rival color, the way every
366 // messenger does it. Other user-role rows (task notifications, messages from
367 // agents) stay on the left with a quiet stripe.
368 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
369 const style = await read($, messageStyle)
370 const current = await currentLook($)
371 if (!current || style === 'off') return next(e)
372 const { look } = current
373 const { Box, Text } = $.ui.resolve(e)
374 const isYours = YOURS.includes(e.props.origin.kind)
375
376 if (!isYours) {
377 if (!(await read($, themeChrome))) return next(e)
378 return (
379 <Box flexDirection="row">
380 <Text color={look.muted}>{'▏'}</Text>
381 <Box flexDirection="column" flexGrow={1}>
382 {await next(e)}
383 </Box>
384 </Box>
385 )
386 }
387 // Room on the left pushes the bubble right; long prompts wrap inside it.
388 // The engine's own row may not sit under a Box that has `width` or
389 // `minWidth` (the engine refuses the tree and draws its own), so the row
390 // takes its width from the parent and only the spacer beside it has one.
391 // `up` pulls the row over the app's row above it, onto the empty pill that row
392 // leaves. The bubble's rounded corners are transparent, so the pill would show
393 // through them: the bubble sits on a plate of the canvas color. Only then:
394 // `frame` puts the app's row inside this Box, and the engine refuses
395 // `position` on a Box above it.
396 const right = (bubble: RenderNode, up = 0) => (
397 <Box key="you-bubble" flexDirection="row" justifyContent="flex-end" {...(up ? { marginTop: -up, position: 'relative' as const } : {})}>
398 <Box width="18%" flexShrink={0} />
399 {up ? (
400 <Box backgroundColor={look.canvas} flexShrink={1}>
401 {bubble}
402 </Box>
403 ) : (
404 bubble
405 )}
406 </Box>
407 )
408 // `bubble` draws the prompt's text itself: predictable size and color, but
409 // the app's own row also carries pasted images and files, which `text` does
410 // not, so `frame` wraps that row instead (the app sizes it, so the frame
411 // can be tighter than a bubble) and `native` leaves it alone.
412 const mode = style === 'outline' ? 'frame' : await read($, promptStyle)
413 if (mode === 'native') return next(e)
414 if (mode === 'frame' || !e.props.isExpanded) {
415 return right(
416 <Box borderStyle="round" borderColor={look.you} backgroundColor={look.youBg} paddingX={1} flexShrink={1}>
417 {await next(e)}
418 </Box>,
419 )
420 }
421 // Only text: the app's own row is all there is to draw.
422 if (e.props.text.trim() === '') return next(e)
423 const text = (
424 <Box borderStyle="round" borderColor={look.you} backgroundColor={look.youBg} paddingX={1} flexShrink={1}>
425 <Text color={look.youText} wrap="wrap">
426 {e.props.text}
427 </Text>
428 </Box>
429 )
430 // A prompt known to have no media is just the bubble. One with media also
431 // gets the app's own row without its text above the bubble: the images and
432 // files, outside the colored block. Nothing wraps
433 // that row (a wrapper shrinks it and breaks its own right alignment); the
434 // empty pill it leaves is covered by pulling the bubble up.
435 // The terminal draws images as text inside the prompt, so there it is just the bubble.
436 if (e.surface === 'terminal') return right(text)
437 // Never seen (sent before the mod was installed): the app's own row, so
438 // nothing is lost and no empty pill is left behind.
439 const known = (await read($, media))[e.requestId]
440 if (known === undefined) return next(e)
441 if (!known) return right(text)
442 return (
443 <Box flexDirection="column">
444 {await next({ ...e, props: { ...e.props, text: '' } })}
445 {right(text, PILL_CELLS)}
446 </Box>
447 )
448 })
449
450 // Claude's replies: on the left, a quiet card; the label is text, not a bar.
451 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
452 const style = await read($, messageStyle)
453 const current = await currentLook($)
454 if (!current || style === 'off') return next(e)
455 const { look } = current
456 const els = $.ui.resolve(e)
457 const { Box, Text } = els
458 if (style === 'outline' || e.props.text.length > 30000) {
459 return (
460 <Box borderStyle="round" borderColor={look.frame} paddingX={1}>
461 {await next(e)}
462 </Box>
463 )
464 }
465 return (
466 <Box flexDirection="column" borderStyle="round" borderColor={look.frame} backgroundColor={look.replyBg} paddingX={1}>
467 {e.props.isFirstOfReply ? (
468 <Text bold color={look.accent}>
469 {'✦ Claude'}
470 </Text>
471 ) : null}
472 {paint(els, e.props.text, look)}
473 </Box>
474 )
475 })
476
477 // The footer's row beneath, then the theme's name as a button that opens the studio.
478 // my-claude-mods: keeps the row beneath (the engine's mode labels and other mods'
479 // buttons, such as plan-progress) instead of drawing the mode labels itself.
480 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
481 const current = await currentLook($)
482 if (!current) return next(e)
483 const { pal, look } = current
484 const isChrome = await read($, themeChrome)
485 const { Box, Text, Button } = $.ui.resolve(e)
486 const below = await next(e)
487 return (
488 <Box flexDirection="row" gap={1}>
489 {below}
490 <Box key="theme-chip" flexDirection="row">
491 {isChrome ? <Text color={look.accent}>{'▍'}</Text> : null}
492 <Button
493 key="open-studio"
494 label={`🎨 ${pal.name}`}
495 plain
496 hover={{ underline: true }}
497 onPress={() => void openStudio($)}
498 />
499 </Box>
500 </Box>
501 )
502 })
503
504 // The hint under the prompt, in the theme's quiet color. The terminal keeps
505 // its own line (its pills stay live); other surfaces take the tree.
506 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
507 const current = await chromeLook($)
508 if (!current || e.surface === 'terminal') return next(e)
509 const { Text } = $.ui.resolve(e)
510 return <Text color={current.look.muted}>{e.props.tail ? `${e.props.hint} ${e.props.tail}` : e.props.hint}</Text>
511 })
512
513 // "Baked for 12s" at the end of a turn (terminal), with a spark.
514 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
515 const current = await chromeLook($)
516 if (!current) return next(e)
517 const { look } = current
518 const { Text } = $.ui.resolve(e)
519 return (
520 <Text>
521 <Text bold color={look.accent}>
522 {'✦ '}
523 </Text>
524 <Text color={look.muted}>{`${e.props.word} for ${formatDuration(e.props.durationMs)}`}</Text>
525 </Text>
526 )
527 })
528
529 // Tool rows, folded runs, results and command output: a stripe and a strip. A tree that draws nothing
530 // (another mod hiding the row with an empty Box) stays as it is: a stripe around it would add a line.
531 const striped = (look: Look, mark: string, drawn: RenderElement, K: Pick<ElementTable, 'Box' | 'Text'>, strength = 0.1, glyph = '▍') => blank(drawn) ? drawn : (
532 <K.Box flexDirection="row" backgroundColor={strength > 0 ? stripOf(look, mark, strength) : undefined}>
533 <K.Text bold color={mark}>
534 {glyph}
535 </K.Text>
536 <K.Box flexDirection="column" flexGrow={1}>
537 {drawn}
538 </K.Box>
539 </K.Box>
540 )
541
542 // Tool rows are striped in the terminal only: the desktop app draws its own
543 // folded tool summary, and a stripe around it is an empty bar.
544 // Finished tools stay quiet so the ones running (highlight) and failing (red) are what you see.
545 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
546 const current = await chromeLook($)
547 if (!current || e.surface !== 'terminal') return next(e)
548 const { look } = current
549 const mark = e.props.isErrored ? look.error : e.props.isRunning ? look.highlight : look.muted
550 return striped(look, mark, await next(e), $.ui.resolve(e), e.props.isErrored ? 0.16 : 0.06)
551 })
552
553 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
554 const current = await chromeLook($)
555 if (!current || e.surface !== 'terminal') return next(e)
556 const { look } = current
557 return striped(look, e.props.isActive ? look.highlight : look.muted, await next(e), $.ui.resolve(e), 0.05)
558 })
559
560 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
561 const current = await chromeLook($)
562 if (!current || e.surface !== 'terminal') return next(e)
563 const { look } = current
564 return striped(look, e.props.isErrored ? look.error : look.muted, await next(e), $.ui.resolve(e), 0, '▏')
565 })
566
567 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
568 const current = await chromeLook($)
569 if (!current || e.surface !== 'terminal') return next(e)
570 const { look } = current
571 return striped(look, e.props.isErrored ? look.error : look.highlight, await next(e), $.ui.resolve(e), 0.08)
572 })
573
574 // The spinner: a twinkling spark and the word shimmering through the palette.
575 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
576 const current = await chromeLook($)
577 if (!current) return next(e)
578 const { pal, look } = current
579 const { Box, Text } = $.ui.resolve(e)
580 const still = await read($, reducedMotion)
581 const f = still ? 0 : await read($, frame)
582 const stops = loopGradient([look.accent, look.secondary, look.highlight, look.secondary])
583 const spark = (
584 <Text bold color={stops[(f * 2) % stops.length] ?? pal.accent}>
585 {`${SPARKS[f % SPARKS.length] ?? '✦'} `}
586 </Text>
587 )
588 // The terminal keeps its own line (elapsed time, tokens) beside the spark.
589 if (e.surface === 'terminal') {
590 return (
591 <Box flexDirection="row">
592 {spark}
593 {await next(e)}
594 </Box>
595 )
596 }
597 const words = e.props.message ?? e.props.word
598 return (
599 <Box flexDirection="row" gap={1}>
600 <Text>
601 {spark}
602 {[...words].map((ch, i) => (
603 <Text bold color={stops[(i + f) % stops.length] ?? look.accent}>
604 {ch}
605 </Text>
606 ))}
607 <Text color={look.text}>{e.props.suffix}</Text>
608 </Text>
609 <Text color={look.muted}>{MODE_LABEL[e.props.mode] ?? ''}</Text>
610 </Box>
611 )
612 })
613
614 // Every pane a mod opens, this one included: a tinted backdrop. Only the studio gets side padding: another
615 // mod sizes its pane to bodyColumns, and two columns less would clip it.
616 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
617 const current = await chromeLook($)
618 if (!current) return next(e)
619 const { Box } = $.ui.resolve(e)
620 return (
621 <Box flexDirection="column" backgroundColor={current.look.replyBg} paddingX={e.requestId === PANE ? 1 : 0}>
622 {await next(e)}
623 </Box>
624 )
625 })
626
627 // ── The studio ──────────────────────────────────────────────────────────
628 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
629 const els = $.ui.resolve(e)
630 const { Box, Text, Button } = els
631 // Mobile draws no fields: collections become buttons and the mixer is hidden.
632 const Input = 'Input' in els ? els.Input : undefined
633 const Select = 'Select' in els ? els.Select : undefined
634
635 const pal = await read($, active)
636 const shown = await read($, group)
637 const search = await read($, query)
638 const mixer = await read($, custom)
639 const mine = await read($, saved)
640 const style = await read($, messageStyle)
641 const prompt = await read($, promptStyle)
642 const isChrome = await read($, themeChrome)
643 const mode = await read($, base)
644 const canvas = (await read($, resolvedBase)) as Base
645 const bg = await read($, bgOverride)
646 const said = await read($, notice)
647 const ui = lookOf(pal ?? DEFAULT_CUSTOM, canvas, bg)
648
649 const results = search ? searchPresets(search, mine, 40) : inGroup(shown, mine)
650 const swatch = (one: Palette) => (
651 <Text>
652 <Text color={one.accent}>██</Text>
653 <Text color={one.secondary}>██</Text>
654 <Text color={one.highlight}>██</Text>
655 <Text color={one.text}>██</Text>
656 <Text color={lookOf(one, canvas, bg).replyBg}>██</Text>
657 </Text>
658 )
659 const heading = (label: string) => (
660 <Text bold color={ui.accent}>
661 {label}
662 </Text>
663 )
664
665 return (
666 <Box flexDirection="column" gap={1}>
667 <Box flexDirection="column">
668 {heading('💬 CHAT BUBBLES')}
669 <Box gap={1}>
670 <Text color={ui.text}>Active:</Text>
671 {pal ? swatch(pal) : null}
672 <Text bold color={ui.secondary}>
673 {pal ? `${pal.name} · ${pal.group}` : "Claude Code's own look"}
674 </Text>
675 </Box>
676 </Box>
677
678 <Box flexDirection="column">
679 {Input ? (
680 <Input
681 key="search"
682 label="Search"
683 value={search}
684 placeholder={`${PRESETS.length} themes: try "hockey", "neon", "gryffindor"`}
685 submitLabel="search"
686 onInput={value => void update($, query, () => value)}
687 onSubmit={value => void update($, query, () => value)}
688 />
689 ) : null}
690 {search ? (
691 <Box gap={1}>
692 <Text color={ui.muted}>{`${results.length === 40 ? '40+' : results.length} match${results.length === 1 ? '' : 'es'}`}</Text>
693 <Button key="clear-search" label="Clear" plain dimColor onPress={() => update($, query, () => '')} />
694 </Box>
695 ) : Select ? (
696 <Select
697 key="group"
698 label="Collection"
699 value={shown}
700 options={GROUPS.map(g => ({ value: g, label: g === MINE ? `${g} (${mine.length})` : g }))}
701 onSelect={value => update($, group, () => value)}
702 />
703 ) : (
704 <Box flexWrap="wrap" gap={1}>
705 {GROUPS.map(g => (
706 <Button key={`g-${slug(g)}`} label={g} dimColor={g !== shown} onPress={() => update($, group, () => g)} />
707 ))}
708 </Box>
709 )}
710 {results.length === 0 ? (
711 <Text color={ui.muted}>{search ? 'No theme matches that.' : 'No saved themes yet. Mix one below and press Enter on "Save as".'}</Text>
712 ) : null}
713 {results.map(one => (
714 <Box key={`row-${one.id}`} gap={1}>
715 {swatch(one)}
716 <Button
717 key={`apply-${one.id}`}
718 label={search ? `${one.name} · ${one.group}` : one.name}
719 variant={pal?.id === one.id ? 'primary' : undefined}
720 onPress={() => apply($, one)}
721 />
722 {pal?.id === one.id ? <Text color={ui.highlight}>◆ on</Text> : null}
723 </Box>
724 ))}
725 </Box>
726
727 {Input ? (
728 <Box flexDirection="column">
729 {heading('MIX YOUR OWN')}
730 {SLOTS.map(({ slot, label }) => (
731 <Box key={`slot-${slot}`} gap={1}>
732 <Text color={mixer[slot] ?? lookOf(mixer, canvas).replyBg}>██</Text>
733 <Input
734 key={`hex-${slot}`}
735 label={label}
736 value={mixer[slot] ?? ''}
737 placeholder={slot === 'background' ? 'auto (tinted from the accent)' : '#ff2bd6'}
738 submitLabel="set"
739 onSubmit={async value => {
740 if (slot === 'background' && value.trim() === '') {
741 const next = await update($, custom, c => ({ ...c, background: undefined }))
742 await persist($, 'custom', next)
743 await update($, notice, () => 'Mixer background back to auto')
744 return
745 }
746 const hex = normalizeHex(value)
747 if (!hex) {
748 await update($, notice, () => `"${value}" isn't a hex color. Try #ff2bd6 or f0f.`)
749 return
750 }
751 const next = await update($, custom, c => ({ ...c, [slot]: hex }))
752 await persist($, 'custom', next)
753 await update($, notice, () => `${slot} set to ${hex}`)
754 }}
755 />
756 </Box>
757 ))}
758 <Box gap={1} flexWrap="wrap">
759 {swatch(mixer)}
760 <Button
761 key="apply-custom"
762 label="Apply custom"
763 variant="primary"
764 onPress={async () => apply($, { ...(await read($, custom)), id: 'custom', name: 'Custom', group: MINE })}
765 />
766 <Button
767 key="random-neon"
768 label="🎲 Random neon"
769 onPress={async () => {
770 const next = await update($, custom, () => randomNeon())
771 await persist($, 'custom', next)
772 await apply($, next)
773 }}
774 />
775 </Box>
776 <Input
777 key="save-name"
778 label="Save as"
779 placeholder="name your theme, Enter to save"
780 submitLabel="save"
781 onSubmit={async value => {
782 const name = value.trim().slice(0, 60)
783 if (!name) return
784 const one: Palette = { ...(await read($, custom)), name, group: MINE, id: `mine:${slug(name)}` }
785 const list = await update($, saved, l => [...l.filter(x => x.id !== one.id), one])
786 await persist($, 'saved', list)
787 await update($, group, () => MINE)
788 await update($, query, () => '')
789 await apply($, one)
790 }}
791 />
792 </Box>
793 ) : null}
794
795 <Box flexDirection="column">
796 {heading('LOOK')}
797 <Box gap={1} flexWrap="wrap">
798 <Button
799 key="style-toggle"
800 label={`Messages: ${STYLE_LABEL[style]}`}
801 onPress={async () => {
802 const next = await update($, messageStyle, v => STYLES[(STYLES.indexOf(v) + 1) % STYLES.length] ?? 'full')
803 await persist($, 'messageStyle', next)
804 }}
805 />
806 <Button
807 key="prompt-toggle"
808 label={`Prompts: ${PROMPT_LABEL[prompt]}`}
809 onPress={async () => {
810 const next = await update($, promptStyle, v => PROMPTS[(PROMPTS.indexOf(v) + 1) % PROMPTS.length] ?? 'bubble')
811 await persist($, 'promptStyle', next)
812 }}
813 />
814 <Button
815 key="chrome-toggle"
816 label={`Tools, spinner & panes: ${isChrome ? 'on' : 'off'}`}
817 onPress={async () => {
818 const next = await update($, themeChrome, v => !v)
819 await persist($, 'themeChrome', next)
820 }}
821 />
822 <Button
823 key="base-toggle"
824 label={`Canvas: ${mode}${mode === 'auto' ? ` (${canvas})` : ''}`}
825 onPress={async () => setBase($, BASES[(BASES.indexOf(mode) + 1) % BASES.length] ?? 'auto')}
826 />
827 </Box>
828 {Input ? (
829 <Box gap={1}>
830 <Text color={bg ?? ui.replyBg}>██</Text>
831 <Input
832 key="bg-override"
833 label="Background for every theme"
834 value={bg ?? ''}
835 placeholder="auto — or a hex like #1a1a2e"
836 submitLabel="set"
837 onSubmit={async value => {
838 await update($, notice, () => '')
839 const said = await setBackground($, value)
840 await update($, notice, () => said.replace(/`/g, ''))
841 }}
842 />
843 </Box>
844 ) : null}
845 <Box gap={1} flexWrap="wrap">
846 {mine.length > 0 && shown === MINE && !search ? (
847 <Button
848 key="clear-mine"
849 label="Clear my themes"
850 dimColor
851 onPress={async () => {
852 await update($, saved, () => [])
853 await persist($, 'saved', [])
854 }}
855 />
856 ) : null}
857 <Button key="off" label="Theme off" dimColor onPress={() => apply($, null)} />
858 </Box>
859 </Box>
860 {said ? <Text color={ui.highlight}>{said}</Text> : null}
861 </Box>
862 )
863 })
864}
865hooks/color.ts 106 lines1// Color math for Chat Bubbles: hex parsing, mixing, gradients and WCAG
2// contrast. Pure functions, no engine access, so they are easy to test.
3
4export type Rgb = readonly [number, number, number]
5
6const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
7
8/** `#rgb`, `#rrggbb`, with or without `#`, any case → `#rrggbb`; else null. */
9export const normalizeHex = (raw: string): string | null => {
10 const s = raw.trim().replace(/^#/, '').toLowerCase()
11 if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`
12 if (/^[0-9a-f]{3}$/.test(s)) return `#${[...s].map(c => c + c).join('')}`
13 return null
14}
15
16export const isHex = (value: unknown): value is string =>
17 typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
18
19export const toRgb = (hex: string): Rgb => {
20 const n = parseInt(hex.slice(1, 7), 16)
21 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
22}
23
24export const toHex = ([r, g, b]: Rgb): string =>
25 `#${[r, g, b].map(v => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('')}`
26
27/** Linear blend from `from` (t = 0) to `to` (t = 1). */
28export const mix = (from: string, to: string, t: number): string => {
29 const a = toRgb(from)
30 const b = toRgb(to)
31 const k = clamp(t, 0, 1)
32 return toHex([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k])
33}
34
35/** WCAG 2.x relative luminance, 0 (black) to 1 (white). */
36const channel = (v: number) => {
37 const c = v / 255
38 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
39}
40
41export const luminance = (hex: string): number => {
42 const [r, g, b] = toRgb(hex)
43 return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
44}
45
46/** WCAG contrast ratio, 1 to 21. */
47export const contrast = (a: string, b: string): number => {
48 const la = luminance(a)
49 const lb = luminance(b)
50 return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
51}
52
53/** Black or white, whichever reads better on `bg`. */
54export const inkOn = (bg: string): string => (contrast('#000000', bg) >= contrast('#ffffff', bg) ? '#111111' : '#ffffff')
55
56/**
57 * `fg` nudged toward black or white (whichever side `bg` is not on) just far
58 * enough to reach `min` contrast against `bg`, keeping as much hue as it can.
59 */
60export const ensureContrast = (fg: string, bg: string, min = 4.5): string => {
61 if (contrast(fg, bg) >= min) return fg
62 const target = luminance(bg) > 0.5 ? '#000000' : '#ffffff'
63 // Binary search for the smallest blend that clears the bar.
64 let lo = 0
65 let hi = 1
66 for (let i = 0; i < 12; i++) {
67 const mid = (lo + hi) / 2
68 if (contrast(mix(fg, target, mid), bg) >= min) hi = mid
69 else lo = mid
70 }
71 return mix(fg, target, hi)
72}
73
74/** Hue in degrees, 0 to 360; 0 for greys. */
75export const hueOf = (hex: string): number => {
76 const [r, g, b] = toRgb(hex).map(v => v / 255) as [number, number, number]
77 const max = Math.max(r, g, b)
78 const d = max - Math.min(r, g, b)
79 if (d === 0) return 0
80 const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
81 return (h * 60 + 360) % 360
82}
83
84/** Cool hues (yellow-green through violet) against warm ones (magenta through yellow). */
85export const isCool = (hex: string): boolean => {
86 const h = hueOf(hex)
87 return h >= 75 && h < 285
88}
89
90export const hslToHex =(h: number, s: number, l: number): string => {
91 const k = (n: number) => (n + h / 30) % 12
92 const a = s * Math.min(l, 1 - l)
93 const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
94 return toHex([f(0) * 255, f(8) * 255, f(4) * 255])
95}
96
97/** `steps` colors per segment through `stops`, looping back to the first. */
98export const loopGradient = (stops: readonly string[], steps = 4): string[] => {
99 const out: string[] = []
100 stops.forEach((from, i) => {
101 const to = stops[(i + 1) % stops.length] ?? from
102 for (let s = 0; s < steps; s++) out.push(mix(from, to, s / steps))
103 })
104 return out
105}
106hooks/look.ts 83 lines1// A Look is a palette resolved against a canvas: every background the mod
2// paints and every foreground that sits on one, contrast-checked so a theme
3// stays readable on dark and light alike. Pure, and memoized per input.
4//
5// Chat Bubbles tones each theme down and gives your messages a rival color:
6// warm (orange) under a cool theme, cool (sky blue) under a warm one, so whose
7// message is whose reads at a glance, before a word of it.
8
9import type { Palette } from '../types'
10import { ensureContrast, isCool, mix } from './color'
11
12export type Base = 'dark' | 'light'
13
14export type Look = {
15 base: Base
16 /** The canvas everything is tinted from. */
17 canvas: string
18 replyBg: string
19 codeBg: string
20 /** Body text on a reply card. */
21 text: string
22 /** Headings, labels: large or bold text, so 3:1 is the bar. */
23 accent: string
24 secondary: string
25 highlight: string
26 /** The reply card's border: quiet, it frames without shouting. */
27 frame: string
28 /** Your bubble: the rival color, its tint and the text on it. */
29 you: string
30 youBg: string
31 youText: string
32 /** Quiet labels: hints, the footer, finished tools. */
33 muted: string
34 error: string
35}
36
37const CANVAS: Record<Base, string> = { dark: '#15151b', light: '#f7f6f2' }
38const ERROR: Record<Base, string> = { dark: '#ff4d5e', light: '#c8102e' }
39const INK: Record<Base, string> = { dark: '#f2f2f2', light: '#1a1a1a' }
40const RIVAL = { warm: { dark: '#ff8c42', light: '#c2410c' }, cool: { dark: '#38bdf8', light: '#0369a1' } }
41// How far each theme color is pulled toward neutral ink: 0 keeps the theme's full intensity.
42const CALM = 0.25
43
44const cache = new Map<string, Look>()
45
46export const lookOf = (pal: Palette, base: Base = 'dark', bgOverride: string | null = null): Look => {
47 const key = `${pal.id}|${pal.accent}${pal.secondary}${pal.highlight}${pal.text}${pal.background ?? ''}|${base}|${bgOverride ?? ''}`
48 const hit = cache.get(key)
49 if (hit) return hit
50
51 const canvas = CANVAS[base]
52 const ink = INK[base]
53 const calm = (hex: string) => mix(hex, ink, CALM)
54 const custom = bgOverride ?? pal.background
55 const replyBg = custom ?? mix(canvas, pal.secondary, base === 'dark' ? 0.06 : 0.04)
56 const codeBg = mix(replyBg, pal.highlight, base === 'dark' ? 0.14 : 0.1)
57 const rival = RIVAL[isCool(pal.accent) ? 'warm' : 'cool'][base]
58 const youBg = mix(custom ?? canvas, rival, base === 'dark' ? 0.16 : 0.1)
59
60 const look: Look = {
61 base,
62 canvas,
63 replyBg,
64 codeBg,
65 text: ensureContrast(mix(pal.text, ink, 0.5), replyBg, 4.5),
66 accent: ensureContrast(calm(pal.accent), replyBg, 3),
67 secondary: ensureContrast(calm(pal.secondary), replyBg, 3),
68 highlight: ensureContrast(calm(pal.highlight), replyBg, 3),
69 frame: mix(replyBg, pal.secondary, 0.35),
70 you: ensureContrast(rival, youBg, 3),
71 youBg,
72 youText: ensureContrast(ink, youBg, 4.5),
73 muted: ensureContrast(mix(pal.secondary, canvas, 0.45), canvas, 3),
74 error: ERROR[base],
75 }
76 if (cache.size > 64) cache.clear()
77 cache.set(key, look)
78 return look
79}
80
81/** A faint strip behind a tool row or command output, in `mark`. */
82export const stripOf = (look: Look, mark: string, strength = 0.1): string => mix(look.canvas, mark, strength)
83hooks/markdown.tsx 166 lines1// A small markdown painter for Claude's replies, in a Look's colors.
2//
3// It draws what it can style better than the engine's renderer can be
4// recolored: headings, lists (nested, numbered, tasks), quotes, rules and
5// inline `code`, **bold**, *italic*, ~~strike~~. Code fences go to the
6// surface's Code element (its own highlighting); tables and lines holding
7// links go to the surface's Markdown element, so links stay clickable.
8
9import type { ElementTable, RenderNode } from 'claude-code'
10
11import type { Look } from './look'
12
13export type Kit = Pick<ElementTable, 'Text' | 'Code' | 'Markdown'>
14
15// `code` | **bold** | __bold__ | ~~strike~~ | *italic* | _italic_. The last
16// needs a non-word character (or the start) before it and none after, so
17// snake_case_names stay as written; no lookbehind, for older runtimes.
18const INLINE =
19 /(`[^`\n]+`)|(\*\*(?!\s)[^*\n]+?\*\*)|(__(?!\s)[^_\n]+?__)|(~~(?!\s)[^~\n]+?~~)|(\*(?![\s*])[^*\n]+?\*)|(^|[^\w])(_(?![\s_])[^_\n]+?_)(?!\w)/g
20
21export const inline = (K: Kit, line: string, look: Look): RenderNode[] => {
22 const { Text } = K
23 const out: RenderNode[] = []
24 let last = 0
25 for (const m of line.matchAll(INLINE)) {
26 // For _italic_, group 6 is the boundary character consumed before it.
27 const lead = m[7] !== undefined ? (m[6] ?? '') : ''
28 const at = (m.index ?? 0) + lead.length
29 const tok = m[7] ?? m[0]
30 if (at > last) out.push(line.slice(last, at))
31 if (m[1]) {
32 out.push(
33 <Text color={look.highlight} backgroundColor={look.codeBg}>
34 {` ${tok.slice(1, -1)} `}
35 </Text>,
36 )
37 } else if (m[2] || m[3]) {
38 out.push(
39 <Text bold color={look.highlight}>
40 {tok.slice(2, -2)}
41 </Text>,
42 )
43 } else if (m[4]) {
44 out.push(
45 <Text strikethrough color={look.muted}>
46 {tok.slice(2, -2)}
47 </Text>,
48 )
49 } else {
50 out.push(
51 <Text italic color={look.secondary}>
52 {tok.slice(1, -1)}
53 </Text>,
54 )
55 }
56 last = at + tok.length
57 }
58 if (last < line.length) out.push(line.slice(last))
59 return out
60}
61
62const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/
63const TABLE = /^\s*\|/
64const LINK = /\[[^\]]*\]\([^)]*\)|<https?:\/\/[^>]+>/
65const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
66const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/
67const LIST = /^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)$/
68const QUOTE = /^\s*>\s?(.*)$/
69
70export const paint = (K: Kit, md: string, look: Look): RenderNode[] => {
71 const { Text, Code, Markdown } = K
72 const lines = md.split('\n')
73 const out: RenderNode[] = []
74 let i = 0
75 while (i < lines.length) {
76 const line = lines[i] ?? ''
77
78 const fence = line.match(FENCE)
79 if (fence) {
80 const marker = fence[1] ?? '```'
81 const close = new RegExp(`^\\s*${marker[0] === '`' ? '`' : '~'}{${marker.length},}\\s*$`)
82 const body: string[] = []
83 i++
84 while (i < lines.length && !close.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
85 i++
86 out.push(<Code source={body.join('\n')} language={fence[2] || undefined} />)
87 continue
88 }
89
90 if (TABLE.test(line)) {
91 const rows: string[] = []
92 while (i < lines.length && TABLE.test(lines[i] ?? '')) rows.push(lines[i++] ?? '')
93 out.push(<Markdown text={rows.join('\n')} />)
94 continue
95 }
96
97 i++
98
99 if (LINK.test(line)) {
100 out.push(<Markdown text={line} />)
101 continue
102 }
103
104 const heading = line.match(HEADING)
105 if (heading) {
106 const level = (heading[1] ?? '#').length
107 const title = heading[2] ?? ''
108 out.push(
109 level === 1 ? (
110 <Text bold underline color={look.accent}>
111 {inline(K, title, look)}
112 </Text>
113 ) : (
114 <Text bold color={level === 2 ? look.accent : look.secondary}>
115 {inline(K, title, look)}
116 </Text>
117 ),
118 )
119 continue
120 }
121
122 if (RULE.test(line)) {
123 out.push(
124 <Text color={look.muted}>
125 {'─'.repeat(28)}
126 </Text>,
127 )
128 continue
129 }
130
131 const item = line.match(LIST)
132 if (item) {
133 const indent = (item[1] ?? '').replace(/\t/g, ' ')
134 const marker = item[2] ?? '-'
135 const task = item[3]
136 const mark = task ? (/[xX]/.test(task) ? '☑ ' : '☐ ') : /\d/.test(marker) ? `${marker} ` : indent.length >= 2 ? '◦ ' : '• '
137 out.push(
138 <Text color={look.text}>
139 {indent}
140 <Text bold color={look.secondary}>
141 {mark}
142 </Text>
143 {inline(K, item[4] ?? '', look)}
144 </Text>,
145 )
146 continue
147 }
148
149 const quote = line.match(QUOTE)
150 if (quote) {
151 out.push(
152 <Text>
153 <Text color={look.secondary}>{'▌ '}</Text>
154 <Text italic color={look.text}>
155 {inline(K, quote[1] ?? '', look)}
156 </Text>
157 </Text>,
158 )
159 continue
160 }
161
162 out.push(<Text color={look.text}>{line.trim() === '' ? ' ' : inline(K, line, look)}</Text>)
163 }
164 return out
165}
166hooks/media.ts 28 lines1// Which of your prompts carry pasted images or files. The app's own row is the
2// only thing that draws those, so Chat Bubbles draws it only for them. The
3// record is plugin state and is also written to the plugin store: row id to
4// yes/no, nothing else. A row never seen (sent before the mod was installed) is
5// unknown, and an unknown row is left to the app.
6
7export type Seen = Readonly<Record<string, boolean>>
8type Block = { type: string; text?: string }
9
10const KEPT = 2000
11// A row id is a uuid. Anything else in a record is dropped: an older version kept
12// the start of what you typed as a key, and session state outlives a reload.
13const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
14
15/** The record reduced to row id -> yes/no, the newest KEPT of them. */
16export const clean = (record: Record<string, unknown>): Seen =>
17 Object.fromEntries(Object.entries(record).filter((entry): entry is [string, boolean] => ID.test(entry[0]) && typeof entry[1] === 'boolean').slice(-KEPT))
18
19/** A stored prompt's blocks: a plain prompt is stored as a bare string. */
20export const blocksOf = (content: unknown): Block[] =>
21 typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : []
22
23/** Whether a row's blocks hold anything but text (an image, a document). */
24export const hasMedia = (blocks: readonly Block[]) => blocks.some(b => b.type !== 'text')
25
26/** The record with a stored row added. */
27export const remember = (seen: Seen, uuid: string, blocks: readonly Block[]): Seen => clean({ ...seen, [uuid]: hasMedia(blocks) })
28hooks/palette.ts 100 lines1// Palette helpers: lookup, validation of stored data, the mixer's random
2// neon. Pure functions over the preset table.
3
4import type { Palette } from '../types'
5import { hslToHex, isHex } from './color'
6import { PRESETS } from './presets'
7
8export const MINE = 'My themes'
9
10/** Collections in table order, then the person's own. */
11export const GROUPS: readonly string[] = [...new Set(PRESETS.map(one => one.group)), MINE]
12
13export const DEFAULT_CUSTOM: Palette = {
14 id: 'custom',
15 name: 'Custom',
16 group: MINE,
17 accent: '#ff2bd6',
18 secondary: '#00f0ff',
19 highlight: '#fffb00',
20 text: '#f5e9ff',
21}
22
23export const slug = (s: string) =>
24 s
25 .toLowerCase()
26 .normalize('NFD')
27 .replace(/[̀-ͯ]/g, '')
28 .replace(/[^a-z0-9]+/g, '-')
29 .replace(/^-|-$/g, '')
30
31/** True for a well-formed palette, so a hand-edited or stale store can't break drawing. */
32export const isPalette = (v: unknown): v is Palette => {
33 if (!v || typeof v !== 'object') return false
34 const o = v as Record<string, unknown>
35 return (
36 typeof o.id === 'string' &&
37 typeof o.name === 'string' &&
38 typeof o.group === 'string' &&
39 isHex(o.accent) &&
40 isHex(o.secondary) &&
41 isHex(o.highlight) &&
42 isHex(o.text) &&
43 (o.background === undefined || isHex(o.background))
44 )
45}
46
47/** Exact name first, then accent-insensitive prefix, then substring. */
48export const findPreset = (query: string, mine: readonly Palette[] = []): Palette | undefined => {
49 const q = slug(query)
50 if (!q) return undefined
51 const all = [...PRESETS, ...mine]
52 return (
53 all.find(one => slug(one.name) === q) ??
54 all.find(one => slug(one.name).startsWith(q)) ??
55 all.find(one => slug(one.name).includes(q))
56 )
57}
58
59/** Every theme whose name or collection matches, best first, at most `limit`. */
60export const searchPresets = (query: string, mine: readonly Palette[] = [], limit = 30): Palette[] => {
61 const q = slug(query)
62 if (!q) return []
63 const all = [...PRESETS, ...mine]
64 const score = (one: Palette) => {
65 const n = slug(one.name)
66 if (n === q) return 0
67 if (n.startsWith(q)) return 1
68 if (n.includes(q)) return 2
69 return slug(one.group).includes(q) ? 3 : 9
70 }
71 return all
72 .map(one => ({ one, s: score(one) }))
73 .filter(x => x.s < 9)
74 .sort((a, b) => a.s - b.s)
75 .slice(0, limit)
76 .map(x => x.one)
77}
78
79export const inGroup = (group: string, mine: readonly Palette[] = []): Palette[] =>
80 group === MINE ? [...mine] : PRESETS.filter(one => one.group === group)
81
82/** A random pick, optionally from one collection; `rand` is injectable for tests. */
83export const pickRandom = (group?: string, rand: () => number = Math.random): Palette | undefined => {
84 const pool = group ? PRESETS.filter(one => slug(one.group) === slug(group)) : PRESETS
85 return pool[Math.floor(rand() * pool.length)]
86}
87
88/** A split-complementary neon palette around a random hue. */
89export const randomNeon = (rand: () => number = Math.random): Palette => {
90 const h = Math.floor(rand() * 360)
91 return {
92 ...DEFAULT_CUSTOM,
93 name: 'Random neon',
94 accent: hslToHex(h, 1, 0.58),
95 secondary: hslToHex((h + 150) % 360, 1, 0.55),
96 highlight: hslToHex((h + 60) % 360, 1, 0.6),
97 text: hslToHex(h, 0.6, 0.94),
98 }
99}
100hooks/presets.ts 524 lines1import type { Palette } from '../types'
2
3// The built-in palettes. Each is inspired by a franchise, team, place or mood
4// and carries colors only: no logos, artwork or other marks. Names are
5// descriptive; the trademarks they evoke belong to their owners.
6//
7// p(collection, name, accent, secondary, highlight, text)
8
9const p = (group: string, name: string, accent: string, secondary: string, highlight: string, text: string): Palette => ({
10 id: `${group}:${name}`.toLowerCase().replace(/[^a-z0-9:]+/g, '-'),
11 name,
12 group,
13 accent,
14 secondary,
15 highlight,
16 text,
17})
18
19export const PRESETS: readonly Palette[] = [
20 p('Neon', "Synthwave '84", '#ff2bd6', '#00f0ff', '#fffb00', '#f5e9ff'),
21 p('Neon', 'Tron Grid', '#00e5ff', '#2e8bff', '#ff9d00', '#d8f6ff'),
22 p('Neon', 'Vaporwave', '#ff71ce', '#01cdfe', '#05ffa1', '#fffb96'),
23 p('Neon', 'Miami Vice', '#ff4fa3', '#2de2e6', '#ffd319', '#fdf0ff'),
24 p('Neon', 'Laser Tag', '#ff00ff', '#00ff9f', '#00b8ff', '#fbefff'),
25 p('Neon', 'Blacklight Party', '#b026ff', '#39ff14', '#ff2079', '#f5eaff'),
26 p('Neon', 'Glowstick Rave', '#c6ff00', '#ff2bd6', '#00f0ff', '#fbffe6'),
27
28 p('Wizarding', 'Gryffindor Common Room', '#e3242b', '#eeba30', '#ff8c42', '#f5e6c8'),
29 p('Wizarding', 'Slytherin Dungeon', '#2fbf71', '#c0c0c0', '#7dffb0', '#e4f2e9'),
30 p('Wizarding', 'Ravenclaw Tower', '#4a7bea', '#cd7f32', '#e2a65a', '#e6ecff'),
31 p('Wizarding', 'Hufflepuff Burrow', '#ffdb00', '#ecb939', '#ff9f1c', '#fff6d6'),
32 p('Wizarding', 'Great Hall Candlelight', '#d4af37', '#9b6dff', '#ff5e5e', '#f2e8cf'),
33 p('Wizarding', 'Patronus Charm', '#a8e6ff', '#e0f7ff', '#7cc6ff', '#f4fbff'),
34 p('Wizarding', 'Hogwarts Express', '#e8173f', '#d4af37', '#c0c0c0', '#fff0f2'),
35 p('Wizarding', "Weasleys' Wheezes", '#ff7b00', '#b06bff', '#7ed957', '#fff4e6'),
36 p('Wizarding', "Dumbledore's Army", '#e8173f', '#3d6bff', '#ffd23f', '#fff0f2'),
37 p('Wizarding', 'Forbidden Forest', '#4caf50', '#8bc34a', '#c0c0c0', '#eef6ea'),
38 p('Wizarding', 'Diagon Alley', '#c9733a', '#9b6dff', '#ffd27f', '#f8efe6'),
39 p('Wizarding', 'Deathly Hallows', '#c0c0c8', '#7a8aa8', '#e8e8ff', '#ececf2'),
40
41 p('Disney & Pixar', 'Magic Kingdom', '#3d6bff', '#ffd23f', '#ff6ec7', '#eef3ff'),
42 p('Disney & Pixar', 'Ice Palace', '#7fdbff', '#b3ecff', '#c77dff', '#f0fbff'),
43 p('Disney & Pixar', 'Under the Sea', '#00c2a8', '#9b5de5', '#ff4f79', '#e8fffb'),
44 p('Disney & Pixar', 'Pride Rock Sunset', '#ff8c00', '#ffcc33', '#ff4d4d', '#fff1d6'),
45 p('Disney & Pixar', 'Space Ranger', '#7ed957', '#a66cff', '#ffd000', '#f6fff0'),
46 p('Disney & Pixar', 'Scare Floor', '#3fd0e8', '#a6e22e', '#c86bfa', '#f0faff'),
47
48 p('Heroes & Galaxies', 'Jedi Temple', '#2fd3ff', '#4cff4c', '#ffe81f', '#e8f4ff'),
49 p('Heroes & Galaxies', 'Sith Lord', '#ff1f1f', '#ff7a7a', '#ffe81f', '#f2dede'),
50 p('Heroes & Galaxies', 'Arc Reactor', '#ff3b3f', '#f0b323', '#4fe3ff', '#fff2e0'),
51 p('Heroes & Galaxies', 'Spider-Verse', '#ff1744', '#2979ff', '#ffea00', '#f5f5ff'),
52 p('Heroes & Galaxies', 'Upside Down', '#ff1e1e', '#ff6f61', '#ffd166', '#f6e7e7'),
53 p('Heroes & Galaxies', 'The Matrix', '#00ff41', '#20c020', '#b4ff6e', '#d0ffd8'),
54
55 p('Iconic Brands', 'Streaming Green', '#1ed760', '#b3b3b3', '#7dffa8', '#f0f0f0'),
56 p('Iconic Brands', 'Binge Red', '#e50914', '#ff5a5f', '#ffd1d1', '#f5f5f1'),
57 p('Iconic Brands', 'Dream House Pink', '#ff1493', '#ff69b4', '#ffd1e8', '#fff0f7'),
58 p('Iconic Brands', 'Classic Cola', '#f40009', '#ff6b6b', '#ffe0e0', '#fff5f5'),
59 p('Iconic Brands', 'Coffee House', '#00a862', '#cba258', '#d4e9e2', '#f2f0eb'),
60 p('Iconic Brands', 'Robin Egg Box', '#81d8d0', '#0abab5', '#ffc4d6', '#f2fffd'),
61 p('Iconic Brands', 'Golden Arches', '#ffc72c', '#da291c', '#ffe08a', '#fff6dc'),
62
63 p('Games', 'Blocky Overworld', '#5fbf3a', '#b07b4f', '#4aedd9', '#e8e8e8'),
64 p('Games', 'Electric Mouse', '#ffde00', '#ff3b30', '#5b6cff', '#fffbe6'),
65 p('Games', 'Hero of Time', '#2fa84f', '#d4af37', '#4cc9f0', '#f1f7e8'),
66 p('Games', 'Battle Bus', '#9d4dff', '#00d0ff', '#ffcf00', '#f3eaff'),
67 p('Games', 'Mushroom Kingdom', '#e52521', '#049cd8', '#fbd000', '#fff6e6'),
68 p('Games', 'Joy-Con Pop', '#ff3c28', '#0ab9e6', '#ffd700', '#f5f5f5'),
69
70 p('Dev Classics', 'Dracula', '#bd93f9', '#ff79c6', '#50fa7b', '#f8f8f2'),
71 p('Dev Classics', 'Nord', '#88c0d0', '#81a1c1', '#ebcb8b', '#eceff4'),
72 p('Dev Classics', 'Tokyo Night', '#7aa2f7', '#bb9af7', '#ff9e64', '#c0caf5'),
73 p('Dev Classics', 'Catppuccin Mocha', '#cba6f7', '#89b4fa', '#f38ba8', '#cdd6f4'),
74 p('Dev Classics', 'Gruvbox', '#fe8019', '#b8bb26', '#fb4934', '#ebdbb2'),
75 p('Dev Classics', 'Rosé Pine', '#ebbcba', '#c4a7e7', '#f6c177', '#e0def4'),
76 p('Dev Classics', 'Solarized Dark', '#268bd2', '#2aa198', '#b58900', '#eee8d5'),
77 p('Dev Classics', 'One Dark', '#61afef', '#c678dd', '#e5c07b', '#dcdfe4'),
78 p('Dev Classics', 'Monokai', '#f92672', '#a6e22e', '#e6db74', '#f8f8f2'),
79 p('Dev Classics', 'Night Owl', '#82aaff', '#c792ea', '#ffcb6b', '#d6deeb'),
80 p('Dev Classics', 'Ayu Mirage', '#ffcc66', '#73d0ff', '#f28779', '#cccac2'),
81 p('Dev Classics', 'Everforest', '#a7c080', '#83c092', '#dbbc7f', '#d3c6aa'),
82 p('Dev Classics', 'Kanagawa', '#7e9cd8', '#957fb8', '#e6c384', '#dcd7ba'),
83 p('Dev Classics', 'Material Ocean', '#82aaff', '#89ddff', '#ffcb6b', '#e0e6f0'),
84
85 p('Marvel', 'Avengers Assemble', '#e23636', '#518cca', '#f7d02c', '#f2f4ff'),
86 p('Marvel', 'Infinity Gauntlet', '#d4af37', '#a259ff', '#ff4d4d', '#f6efff'),
87 p('Marvel', 'Wakanda Forever', '#a46bff', '#c0c0c0', '#e6c34a', '#efe8ff'),
88 p('Marvel', 'Asgard Thunder', '#4fc3f7', '#d4af37', '#e53935', '#eef8ff'),
89 p('Marvel', 'Hulk Smash', '#6ad04a', '#9c5bd6', '#c8ff4d', '#effbe9'),
90 p('Marvel', 'Deadpool & Wolverine', '#e01e37', '#ffd000', '#4a90e2', '#fff1f2'),
91
92 p('Inside Out', 'Headquarters', '#ffd23f', '#3b8bff', '#ff4b3e', '#fff9e6'),
93 p('Inside Out', 'Joy', '#ffd23f', '#7fd8ff', '#ffef9a', '#fffbe8'),
94 p('Inside Out', 'Sadness', '#4a7dff', '#8fb3ff', '#bcd2ff', '#e8efff'),
95 p('Inside Out', 'Anger', '#ff3b30', '#ff8a3d', '#ffd166', '#fff0ee'),
96 p('Inside Out', 'Disgust & Fear', '#7bd389', '#a77dff', '#c6f68d', '#f2fff4'),
97 p('Inside Out', 'Anxiety & Ennui', '#ff8c42', '#6a7bff', '#ffbf69', '#fff4ea'),
98 p('Inside Out', 'Core Memories', '#ffd23f', '#4a7dff', '#ff3b30', '#f7f2ff'),
99
100 p('Middle-earth', 'The One Ring', '#d4af37', '#ff6a00', '#ffe08a', '#f6eedb'),
101 p('Middle-earth', 'The Shire', '#7cb342', '#c8a165', '#ffd54f', '#f3f0e0'),
102 p('Middle-earth', 'Rivendell', '#8fd3b6', '#c9b37e', '#e8f1ff', '#eef6f1'),
103 p('Middle-earth', 'Mordor', '#ff4500', '#b22222', '#ffb300', '#f5e2d8'),
104 p('Middle-earth', 'Lothlórien', '#e8c766', '#b8e0d2', '#fff4c2', '#f8f5e6'),
105 p('Middle-earth', 'Gondor White Tree', '#e6e8ef', '#9fb7d9', '#d4af37', '#f4f5f9'),
106
107 p('Movies & TV', 'Venom', '#f2f2f7', '#7a7f99', '#ff2a2a', '#dcdce6'),
108 p('Movies & TV', 'Carnage', '#ff1a1a', '#ff6a6a', '#ffd0d0', '#fff0f0'),
109 p('Movies & TV', 'Jurassic Park', '#ffcc00', '#e63939', '#52b788', '#f4f1de'),
110 p('Movies & TV', 'Mosquito in Amber', '#ffb000', '#ff7b00', '#ffe29a', '#fff5e0'),
111 p('Movies & TV', 'Arrakis Spice', '#e8a33d', '#c1440e', '#4fc3f7', '#f6ead8'),
112 p('Movies & TV', 'Pandora Glow', '#00c2ff', '#b06bff', '#39ff14', '#e9fbff'),
113 p('Movies & TV', 'Slimer', '#39ff14', '#ff1a1a', '#b2ff59', '#efffe8'),
114 p('Movies & TV', 'Nevermore Academy', '#b388ff', '#c0c0c0', '#ff4081', '#ece6f7'),
115 p('Movies & TV', 'Red Light Green Light', '#ff2e88', '#1ec8a5', '#ffffff', '#fff0f6'),
116
117 p('Cyberpunk', 'Night City', '#fcee0a', '#00f0ff', '#ff003c', '#ececec'),
118 p('Cyberpunk', 'Neon Noir', '#ff006e', '#8338ec', '#3a86ff', '#f0e6ff'),
119 p('Cyberpunk', 'Netrunner', '#39ff14', '#00e5ff', '#ff2079', '#e6ffe9'),
120 p('Cyberpunk', 'Chrome Rain', '#00fff0', '#ff00a0', '#c0c0c0', '#e8faff'),
121 p('Cyberpunk', 'Blade Runner 2049', '#ff8a00', '#ff3d7f', '#00d5ff', '#ffefe0'),
122 p('Cyberpunk', 'Akira Red', '#ff2222', '#ffffff', '#00e5ff', '#ffecec'),
123
124 p('Sports', 'Miami Dolphins', '#00b8c4', '#fc4c02', '#5ec8e5', '#e6fbfc'),
125 p('Sports', 'Canadiens de Montréal', '#e03a3e', '#4a5bd4', '#ffffff', '#f5f5f8'),
126 p('Sports', 'CF Montréal', '#3b6cff', '#c0c0c0', '#8fb0ff', '#eef2ff'),
127 p('Sports', 'Alouettes', '#4a6fe0', '#e31837', '#ffffff', '#eef2ff'),
128 p('Sports', 'Showtime Lakers', '#a46bff', '#fdb927', '#ffe08a', '#f6efff'),
129 p('Sports', 'Celtics Green', '#2fbf5f', '#ba9653', '#e6d3a3', '#eefbf1'),
130 p('Sports', 'Bulls Red', '#e8173f', '#ffffff', '#ff7a8f', '#fff0f3'),
131 p('Sports', 'Maple Leafs', '#3d7bff', '#ffffff', '#9cc0ff', '#eef4ff'),
132 p('Sports', 'Bruins Black & Gold', '#ffb81c', '#c8c8c8', '#ffe08a', '#f5f5f0'),
133 p('Sports', 'Oilers Orange', '#ff4c00', '#3b6fe0', '#ffa366', '#fff2ea'),
134 p('Sports', 'Canucks', '#2f7bff', '#00a86b', '#9fd8ff', '#eef6ff'),
135 p('Sports', 'Raptors', '#e8173f', '#c0c0c0', '#ffd700', '#fff0f2'),
136 p('Sports', 'Blue Jays', '#2b7de9', '#e8173f', '#9fd0ff', '#eef5ff'),
137 p('Sports', 'Habs Vintage', '#c8102e', '#2d4fcf', '#f2e6c9', '#f7f1e3'),
138
139 p('Anime', 'Spirit Bathhouse', '#e85d4f', '#3fb8a5', '#ffd27f', '#fdf3e7'),
140 p('Anime', 'Super Saiyan', '#ffd60a', '#ff7b00', '#4cc9f0', '#fffbe6'),
141 p('Anime', 'Hidden Leaf', '#ff8c1a', '#3a86ff', '#ffd166', '#fff4e6'),
142 p('Anime', 'Unit-01', '#8b5cf6', '#39ff14', '#ff6b00', '#f3edff'),
143 p('Anime', 'Sailor Moon', '#ff6fb5', '#5aa9ff', '#ffe066', '#fff0f8'),
144 p('Anime', 'Straw Hat Pirates', '#ff3b30', '#ffd23f', '#3fa7ff', '#fff6e8'),
145 p('Anime', 'Demon Slayer', '#2ec4a6', '#ff4f6d', '#ffd166', '#effcf9'),
146
147 p('Cartoons', 'Springfield', '#ffd90f', '#4f9dff', '#ff6f91', '#fffbe0'),
148 p('Cartoons', 'Bikini Bottom', '#ffeb3b', '#4fc3f7', '#ff8a65', '#fffde7'),
149 p('Cartoons', 'Portal Gun', '#97ce4c', '#44d4e8', '#f0e14a', '#f2fbe8'),
150 p('Cartoons', 'Land of Ooo', '#4fc3f7', '#ffcc33', '#ff6fb5', '#eefaff'),
151 p('Cartoons', 'Scooby Snack', '#7ed957', '#c08bff', '#ff9f43', '#f3ffee'),
152 p('Cartoons', 'Powerpuff', '#ff6fb5', '#5ab8ff', '#7ed957', '#fff0f8'),
153
154 p('Retro Tech', 'Arcade 1983', '#ff3355', '#33ccff', '#ffee33', '#f0f0ff'),
155 p('Retro Tech', 'Pocket Monochrome', '#9bbc0f', '#8bac0f', '#cadc9f', '#e0f0c0'),
156 p('Retro Tech', 'Amber CRT', '#ffb000', '#ff8c00', '#ffd27a', '#ffe9b8'),
157 p('Retro Tech', 'Green Phosphor', '#33ff66', '#1fcc4d', '#a6ffbf', '#ccffd9'),
158 p('Retro Tech', 'Desktop 95', '#4aa8ff', '#c0c0c0', '#ffff66', '#f0f0f0'),
159 p('Retro Tech', 'Y2K Chrome', '#c0c8ff', '#ff7ad9', '#7af0ff', '#f5f7ff'),
160
161 p('Space', 'Deep Nebula', '#b06bff', '#ff5fa2', '#5ce1ff', '#f2ebff'),
162 p('Space', 'Aurora Borealis', '#3dffb0', '#7c6bff', '#5ce1ff', '#eafff6'),
163 p('Space', 'Mars Colony', '#ff6b35', '#f7c59f', '#ffd166', '#fff1e8'),
164 p('Space', 'Saturn Rings', '#e8c37a', '#9fa8ff', '#ffe7b3', '#f8f3e8'),
165 p('Space', 'Black Hole', '#ff8a00', '#ffd27a', '#a855f7', '#f5ecdf'),
166 p('Space', 'Mission Control', '#5ce1ff', '#ffffff', '#ff5555', '#eaf8ff'),
167
168 p('Nature', 'Ocean Reef', '#00c2c7', '#ff7f6e', '#ffd166', '#e8fbfb'),
169 p('Nature', 'Redwood Forest', '#6fbf73', '#c97b4a', '#e6c96a', '#eef6ea'),
170 p('Nature', 'Desert Sunset', '#ff7b54', '#ffb26b', '#a66cff', '#fff1e6'),
171 p('Nature', 'Cherry Blossom', '#ff9ec7', '#c7a4ff', '#ffe3ef', '#fff4f9'),
172 p('Nature', 'Glacier', '#8fd3ff', '#c4e8ff', '#6ee7d8', '#f2faff'),
173 p('Nature', 'Volcano', '#ff3d00', '#ff9100', '#ffd54f', '#fff0e6'),
174 p('Nature', 'Lavender Fields', '#b794f6', '#f6c6ea', '#ffe08a', '#f7f2ff'),
175
176 p('Seasons & Holidays', 'Halloween', '#ff7518', '#9b5de5', '#39ff14', '#fff1e6'),
177 p('Seasons & Holidays', 'Christmas', '#e53935', '#43a047', '#ffd54f', '#fff5f5'),
178 p('Seasons & Holidays', 'Valentine', '#ff4d8d', '#ff9ec7', '#ffd1e3', '#fff0f5'),
179 p('Seasons & Holidays', 'Saint-Jean', '#3d7bff', '#ffffff', '#ffd23f', '#eef4ff'),
180 p('Seasons & Holidays', 'Canada Day', '#ff2a2a', '#ffffff', '#ff8a8a', '#fff2f2'),
181 p('Seasons & Holidays', 'Autumn Maple', '#e8590c', '#c9a227', '#ff922b', '#fdf1e6'),
182 p('Seasons & Holidays', 'Summer Pool Party', '#00d2ff', '#ff6fb5', '#ffe066', '#ecfbff'),
183
184 p('Sweets', 'Bubblegum', '#ff7ac6', '#7ad7ff', '#fff07a', '#fff0f8'),
185 p('Sweets', 'Mint Chocolate Chip', '#7debc0', '#a0785a', '#d8ffe9', '#effff7'),
186 p('Sweets', 'Cotton Candy', '#ffb3de', '#a5d8ff', '#e9c7ff', '#fff5fb'),
187 p('Sweets', 'Maple Taffy', '#e8a33d', '#ffd59a', '#c9733a', '#fff6e8'),
188 p('Sweets', 'Rainbow Sherbet', '#ff8a5c', '#ffd166', '#ff6fb5', '#fff3ec'),
189 p('Sweets', 'Blueberry Muffin', '#7c8cff', '#e8b67a', '#c4b5fd', '#f2f3ff'),
190
191 p('Cities', 'Montréal by Night', '#4a8cff', '#e03a3e', '#ffd23f', '#eef4ff'),
192 p('Cities', 'Tokyo Neon', '#ff2e88', '#00e5ff', '#fff200', '#fff0f7'),
193 p('Cities', 'Paris Café', '#d4a373', '#9fb7d9', '#f2cc8f', '#f8f1e7'),
194 p('Cities', 'Miami Beach', '#ff6fb5', '#2de2e6', '#ffd319', '#fff0f8'),
195 p('Cities', 'New York Taxi', '#ffd000', '#c0c0c0', '#ff5a36', '#fffbe6'),
196 p('Cities', 'Havana', '#ff7f50', '#40e0d0', '#ffd166', '#fff3ec'),
197 p('Cities', 'Reykjavik', '#7fdbff', '#b8c0ff', '#3dffb0', '#f0faff'),
198
199 p('Aesthetics', 'Lo-fi Study', '#c3a6ff', '#ffb3a7', '#ffe3a3', '#f4efff'),
200 p('Aesthetics', 'Cottagecore', '#9cc98a', '#e8b4b8', '#f2d79b', '#f6f3ea'),
201 p('Aesthetics', 'Dark Academia', '#c9a227', '#a0785a', '#e6d3a3', '#efe7d6'),
202 p('Aesthetics', 'Pastel Goth', '#c08bff', '#ff8ccf', '#8ff5ff', '#f5eeff'),
203 p('Aesthetics', 'Monochrome', '#ffffff', '#a0a0a8', '#d0d0d8', '#e8e8ee'),
204 p('Aesthetics', 'Coastal Grandma', '#8fb8de', '#e8dcc4', '#f4c2a1', '#f4f7fa'),
205
206 p('DC Heroes', 'Gotham Knight', '#ffd60a', '#8a8fa3', '#4a90e2', '#e9eaf0'),
207 p('DC Heroes', 'Man of Steel', '#2f6bff', '#e8173f', '#ffd23f', '#eef3ff'),
208 p('DC Heroes', 'Amazon Warrior', '#e8173f', '#d4af37', '#3d7bff', '#fff2f0'),
209 p('DC Heroes', 'Clown Prince', '#7dff4d', '#b06bff', '#ff2e88', '#f3ffee'),
210 p('DC Heroes', 'Speed Force', '#ff2a2a', '#ffd000', '#ffef9a', '#fff1f1'),
211 p('DC Heroes', 'Atlantis', '#00c2a8', '#ff8c1a', '#7fe6ff', '#e9fffb'),
212
213 p('Pixar Worlds', 'Up House Balloons', '#ff6fb5', '#5ab8ff', '#ffd23f', '#fff4fa'),
214 p('Pixar Worlds', 'WALL·E Sunset', '#ffb000', '#7fd3ff', '#8bd450', '#fff6e0'),
215 p('Pixar Worlds', 'Land of the Dead', '#ff8c1a', '#c77dff', '#3fd0e8', '#fff2e6'),
216 p('Pixar Worlds', 'Little Chef', '#e63946', '#a8dadc', '#f1c27d', '#fff4f2'),
217 p('Pixar Worlds', 'Radiator Springs', '#ff2a2a', '#ffb000', '#4fc3f7', '#fff3ea'),
218 p('Pixar Worlds', 'Super Family', '#ff4b3e', '#ffb000', '#ffd8a8', '#fff3ee'),
219
220 p('Disney Princesses', 'Enchanted Rose', '#ffd23f', '#3d7bff', '#ff4d6d', '#fffbe6'),
221 p('Disney Princesses', 'Ocean Wayfinder', '#00b4d8', '#ff7b54', '#7ed957', '#eafaff'),
222 p('Disney Princesses', 'Floating Lanterns', '#b06bff', '#ffd23f', '#ff9ec7', '#f6efff'),
223 p('Disney Princesses', 'Honor Warrior', '#e63946', '#3fa7ff', '#ffd166', '#fff2f2'),
224 p('Disney Princesses', 'Casita Magic', '#ffb000', '#2ec4a6', '#ff6fb5', '#fff7e8'),
225 p('Disney Princesses', 'Glass Slipper', '#9fd3ff', '#e0c3ff', '#ffffff', '#f4faff'),
226
227 p('Great Houses', 'House of the Wolf', '#b8c4d6', '#7a8aa8', '#e8f0ff', '#eef2f8'),
228 p('Great Houses', 'House of the Dragon', '#e8173f', '#ff7b00', '#ffd23f', '#fff0ee'),
229 p('Great Houses', 'House of the Lion', '#d4af37', '#e8173f', '#ffe08a', '#f8f0dc'),
230 p('Great Houses', 'House of the Stag', '#ffd000', '#9a9aa8', '#ffef9a', '#fbf8e8'),
231 p('Great Houses', 'The Night King', '#7fd3ff', '#c4e8ff', '#4a7dff', '#eef8ff'),
232 p('Great Houses', 'Iron Throne', '#a0a0b0', '#d4af37', '#e8173f', '#ececf2'),
233
234 p('Game Legends', 'Blue Blur', '#2f7bff', '#ffd000', '#ff3b30', '#eef4ff'),
235 p('Game Legends', 'Power Pellet', '#ffe600', '#2f4bff', '#ff4fa3', '#fffbe0'),
236 p('Game Legends', 'Spartan Armor', '#6fbf4a', '#ffb000', '#4fc3f7', '#eef8ea'),
237 p('Game Legends', 'Hallownest', '#e8eaf6', '#6b7cff', '#7fe6ff', '#eceef8'),
238 p('Game Legends', 'Island Getaway', '#7ed957', '#ffd23f', '#5ab8ff', '#f3ffe9'),
239 p('Game Legends', 'Street Fighter', '#ff3b30', '#3d7bff', '#ffd23f', '#fff2f0'),
240 p('Game Legends', 'Among the Crew', '#e8173f', '#3fd0e8', '#ffd23f', '#fff0f2'),
241 p('Game Legends', 'Tetris Blocks', '#00e5ff', '#ff3b30', '#ffe600', '#eefcff'),
242
243 p('Music Vibes', 'Synth-pop', '#ff2bd6', '#7c6bff', '#00f0ff', '#fbefff'),
244 p('Music Vibes', 'Reggae Sunsplash', '#2fbf5f', '#ffd000', '#ff3b30', '#effbf1'),
245 p('Music Vibes', 'Punk Rock', '#ff2e88', '#c6ff00', '#ffffff', '#fff0f7'),
246 p('Music Vibes', 'Jazz Club', '#d4a373', '#9b6dff', '#ffd27f', '#f6eee4'),
247 p('Music Vibes', 'K-pop Lightstick', '#ff6fd8', '#8be9ff', '#fff07a', '#fff2fc'),
248 p('Music Vibes', 'Hip-hop Gold', '#ffd700', '#e8173f', '#c0c0c0', '#fffae6'),
249 p('Music Vibes', 'Disco Ball', '#e0c3ff', '#7fe6ff', '#ffd23f', '#f8f4ff'),
250
251 p('Mythology', 'Mount Olympus', '#ffd700', '#7fb8ff', '#ffffff', '#fffae8'),
252 p('Mythology', 'Valhalla', '#7fd3ff', '#c0c0c0', '#ff7b00', '#eef8ff'),
253 p('Mythology', 'Pharaoh Gold', '#d4af37', '#2ec4a6', '#3d7bff', '#f8f0dc'),
254 p('Mythology', 'Underworld', '#a855f7', '#ff3d00', '#3dffb0', '#f3eaff'),
255 p('Mythology', 'Dragon Kingdom', '#e8173f', '#ffd700', '#2fbf71', '#fff0ee'),
256 p('Mythology', 'Atlantean', '#00c2c7', '#7c6bff', '#7fffd4', '#e9fdfd'),
257
258 p('Gemstones', 'Ruby', '#ff2a4f', '#ff8fa3', '#ffd1da', '#fff0f3'),
259 p('Gemstones', 'Sapphire', '#3d6bff', '#8fb0ff', '#c9d6ff', '#eef2ff'),
260 p('Gemstones', 'Emerald', '#2fbf71', '#8be3b5', '#d4af37', '#ecfbf3'),
261 p('Gemstones', 'Amethyst', '#a855f7', '#d8b4fe', '#f0abfc', '#f7efff'),
262 p('Gemstones', 'Opal', '#9ff5e0', '#ffb3de', '#c4b5fd', '#f5fffc'),
263 p('Gemstones', 'Topaz', '#ffb000', '#4fc3f7', '#ffe08a', '#fff7e6'),
264 p('Gemstones', 'Rose Gold', '#f4a7a3', '#e6c7a8', '#ffd9d6', '#fff5f3'),
265
266 p('Supercars', 'Prancing Horse', '#ff2800', '#ffd000', '#ffffff', '#fff1ee'),
267 p('Supercars', 'Verde Mantis', '#8bff3a', '#ffd000', '#c0c0c0', '#f2ffe9'),
268 p('Supercars', 'Gulf Livery', '#7fd3ff', '#ff7b00', '#ffffff', '#eef9ff'),
269 p('Supercars', 'M Power Stripes', '#3d9bff', '#e8173f', '#7c6bff', '#eef6ff'),
270 p('Supercars', 'British Racing', '#2fbf71', '#d4af37', '#e8e8e8', '#eef8f1'),
271 p('Supercars', 'Midnight Drift', '#b06bff', '#00e5ff', '#ff2e88', '#f3ecff'),
272
273 p('Food & Drinks', 'Matcha Latte', '#9cc96a', '#e8dcc4', '#d4e9b8', '#f4f8ec'),
274 p('Food & Drinks', 'Espresso', '#c69c6d', '#e8c39e', '#ffb000', '#f5ede4'),
275 p('Food & Drinks', 'Sushi Bar', '#ff7f6e', '#2fbf71', '#f5f0e1', '#fff4f1'),
276 p('Food & Drinks', 'Poutine', '#e8a33d', '#c9733a', '#fff2cc', '#fbf3e6'),
277 p('Food & Drinks', 'Tropical Smoothie', '#ff6fb5', '#ffb000', '#7ed957', '#fff1f8'),
278 p('Food & Drinks', 'Taco Tuesday', '#ffd000', '#2fbf5f', '#ff3b30', '#fffbe6'),
279 p('Food & Drinks', 'Lemonade Stand', '#fff07a', '#ff9ec7', '#7fe6ff', '#fffde8'),
280
281 p('Québec', 'Fleurdelisé', '#2f6bff', '#ffffff', '#9fc0ff', '#eef3ff'),
282 p('Québec', 'Cabane à sucre', '#c9733a', '#e8a33d', '#ffe0a3', '#fbf1e6'),
283 p('Québec', 'Carnaval de Québec', '#e8173f', '#ffffff', '#7fd3ff', '#fff2f3'),
284 p('Québec', 'Vieux-Montréal', '#d4a373', '#9fb7d9', '#ffd27f', '#f6efe6'),
285 p('Québec', 'Gaspésie', '#00b4d8', '#ff7b54', '#ffd166', '#eafaff'),
286 p('Québec', 'Laurentides en automne', '#ff6b1a', '#e8b100', '#c2410c', '#fdf1e6'),
287 p('Québec', 'Nuit blanche', '#b06bff', '#ff2e88', '#00e5ff', '#f5eeff'),
288 p('Québec', 'Mont-Royal', '#7cb342', '#c9733a', '#ffd166', '#f2f7ec'),
289 p('Québec', 'Marché Jean-Talon', '#ff7b54', '#7ed957', '#ffd23f', '#fff3ec'),
290 p('Québec', 'Stade olympique', '#c0c8d8', '#3d8bff', '#ff7b00', '#eef0f6'),
291 p('Québec', 'Festival de jazz', '#b06bff', '#ffd23f', '#ff2e88', '#f5eeff'),
292 p('Québec', 'Château Frontenac', '#2fbf8a', '#c9733a', '#d4af37', '#ecfbf5'),
293 p('Québec', 'Îles-de-la-Madeleine', '#ff6f91', '#00b4d8', '#ffd166', '#fff1f5'),
294
295 p('Zodiac', 'Aries', '#ff3b30', '#ff9500', '#ffd60a', '#fff1ee'),
296 p('Zodiac', 'Taurus', '#7cb342', '#e8b4b8', '#d4af37', '#f2f7ec'),
297 p('Zodiac', 'Gemini', '#ffd60a', '#7fe6ff', '#ff9ec7', '#fffbe6'),
298 p('Zodiac', 'Cancer', '#c4e8ff', '#e0e0f0', '#9fd3ff', '#f4f9ff'),
299 p('Zodiac', 'Leo', '#ffb000', '#ff6b1a', '#ffe08a', '#fff7e6'),
300 p('Zodiac', 'Virgo', '#9cc98a', '#c9a227', '#e8dcc4', '#f4f7ee'),
301 p('Zodiac', 'Libra', '#ff9ec7', '#9fd3ff', '#e0c3ff', '#fff4f9'),
302 p('Zodiac', 'Scorpio', '#e8173f', '#a855f7', '#ff6b6b', '#fdeef2'),
303 p('Zodiac', 'Sagittarius', '#a855f7', '#3d7bff', '#ffb000', '#f5eeff'),
304 p('Zodiac', 'Capricorn', '#a0a0b0', '#7a5c3e', '#c9a227', '#eeeef2'),
305 p('Zodiac', 'Aquarius', '#00c2ff', '#7c6bff', '#7fffd4', '#ebfaff'),
306 p('Zodiac', 'Pisces', '#7fe6d8', '#b8a4ff', '#ff9ec7', '#effcfa'),
307
308 p('Elements', 'Fire', '#ff3d00', '#ff9100', '#ffd54f', '#fff0e6'),
309 p('Elements', 'Water', '#2f8bff', '#00c2c7', '#9fd3ff', '#eef5ff'),
310 p('Elements', 'Earth', '#8bc34a', '#c9733a', '#e6c96a', '#f2f6ea'),
311 p('Elements', 'Air', '#c4e8ff', '#e0e0f0', '#ffffff', '#f6fbff'),
312 p('Elements', 'Lightning', '#fff200', '#7c6bff', '#00e5ff', '#fffde0'),
313 p('Elements', 'Shadow', '#8a7fb8', '#5b5b7a', '#c08bff', '#e6e4f0'),
314
315 p('F1 Garage', 'Papaya Orange', '#ff8000', '#47c7fc', '#ffb366', '#fff4e6'),
316 p('F1 Garage', 'Silver Arrows', '#00d2be', '#c0c0c0', '#7ffff0', '#eefcfb'),
317 p('F1 Garage', 'Energy Drink Racing', '#3d5bff', '#e8173f', '#ffd000', '#eef0ff'),
318 p('F1 Garage', 'Scuderia Rosso', '#ef1a2d', '#ffd000', '#ff8a8a', '#fff0f1'),
319 p('F1 Garage', 'Racing Green', '#2fbf8a', '#cedc00', '#9ff0d0', '#ecfbf5'),
320 p('F1 Garage', 'Alpine Blue', '#2f8bff', '#ff6fb5', '#9fd0ff', '#eef5ff'),
321
322 p('Football Clubs', 'Blaugrana', '#e8173f', '#3d5bff', '#ffd000', '#fff0f2'),
323 p('Football Clubs', 'Los Blancos', '#ffffff', '#d4af37', '#7f9cff', '#f4f4f8'),
324 p('Football Clubs', 'The Reds', '#e8173f', '#00c2a8', '#ffd23f', '#fff0f2'),
325 p('Football Clubs', 'Sky Blues', '#6cabdd', '#ffffff', '#ffd23f', '#eef6fc'),
326 p('Football Clubs', 'Les Parisiens', '#3d5bff', '#e8173f', '#ffffff', '#eef0ff'),
327 p('Football Clubs', 'Die Roten', '#e8173f', '#3d7bff', '#ffffff', '#fff0f2'),
328 p('Football Clubs', 'Rossoneri', '#e8173f', '#a0a0a8', '#ffd23f', '#fff0f2'),
329
330 p('Gridiron', 'Kansas City Red', '#e31837', '#ffb81c', '#ffe08a', '#fff0f2'),
331 p('Gridiron', 'Cowboys Star', '#4a7dff', '#c0c0c0', '#ffffff', '#eef3ff'),
332 p('Gridiron', 'Packers Green & Gold', '#2fa84f', '#ffb612', '#ffe08a', '#eef8ec'),
333 p('Gridiron', 'Raiders Silver', '#c0c0c8', '#8a8a96', '#ffffff', '#ececf2'),
334 p('Gridiron', 'Purple People Eaters', '#9b5de5', '#ffc62f', '#e0c3ff', '#f5efff'),
335 p('Gridiron', '49ers Gold Rush', '#e8173f', '#d4af37', '#ffe08a', '#fff0f2'),
336
337 p('World Colors', 'Brasil', '#2fbf5f', '#ffd000', '#3d6bff', '#eefbf1'),
338 p('World Colors', 'Jamaica', '#2fbf5f', '#ffd000', '#c0c0c0', '#eefbf1'),
339 p('World Colors', 'Nippon', '#ff2a4f', '#ffffff', '#ffb3c1', '#fff0f3'),
340 p('World Colors', 'Italia', '#2fbf5f', '#ffffff', '#e8173f', '#eefbf1'),
341 p('World Colors', 'México', '#2fa84f', '#e8173f', '#ffd166', '#eef8ec'),
342 p('World Colors', 'Éire', '#2fbf71', '#ff8c1a', '#ffffff', '#ecfbf3'),
343 p('World Colors', 'Bleu Blanc Rouge', '#3d6bff', '#ffffff', '#e8173f', '#eef2ff'),
344 p('World Colors', 'Haïti', '#3d6bff', '#e8173f', '#ffd23f', '#eef2ff'),
345
346 p('Social Apps', 'Insta Gradient', '#ff2e88', '#ff9500', '#a855f7', '#fff0f7'),
347 p('Social Apps', 'For You Page', '#25f4ee', '#fe2c55', '#ffffff', '#eefffe'),
348 p('Social Apps', 'Blurple', '#5865f2', '#57f287', '#eb459e', '#eef0ff'),
349 p('Social Apps', 'Stream Purple', '#9146ff', '#bf94ff', '#00f593', '#f3ecff'),
350 p('Social Apps', 'Play Button Red', '#ff2a2a', '#ffffff', '#ff8a8a', '#fff0f0'),
351 p('Social Apps', 'Ghost Yellow', '#fffc00', '#ffffff', '#ff9ec7', '#fffde0'),
352 p('Social Apps', 'Bird Blue', '#1d9bf0', '#ffffff', '#7fd3ff', '#eef8ff'),
353
354 p('Galaxy Far Away', 'Tatooine Twin Suns', '#ffb000', '#e8a33d', '#ff6b1a', '#fff6e6'),
355 p('Galaxy Far Away', 'Hoth Echo Base', '#c4e8ff', '#9fb7d9', '#ff5555', '#f4f9ff'),
356 p('Galaxy Far Away', 'Endor Forest', '#6fbf73', '#c97b4a', '#ffd166', '#eef6ea'),
357 p('Galaxy Far Away', 'Beskar Armor', '#c0c8d8', '#7a8aa8', '#ff8c1a', '#eef0f6'),
358 p('Galaxy Far Away', 'Grogu', '#9cd67a', '#c9a27a', '#ffd8a8', '#f2fbec'),
359 p('Galaxy Far Away', 'Hyperspace', '#7fd3ff', '#ffffff', '#b06bff', '#f0faff'),
360
361 p('Studio Ghibli', 'Forest Spirit', '#7cb342', '#9fa8b8', '#ffd23f', '#f2f7ec'),
362 p('Studio Ghibli', 'Moving Castle', '#ff8c42', '#7fb8ff', '#ffd166', '#fff4ea'),
363 p('Studio Ghibli', 'Witch Delivery', '#e8173f', '#7f8cff', '#ffd23f', '#fff0f2'),
364 p('Studio Ghibli', 'Ponyo Seaside', '#ff5a5f', '#00b4d8', '#ffd166', '#fff1f1'),
365 p('Studio Ghibli', 'Castle in the Sky', '#7fd3ff', '#9cc96a', '#ffd27f', '#f0faff'),
366 p('Studio Ghibli', 'Princess of the Forest', '#e8173f', '#c9a27a', '#7cb342', '#fff2f0'),
367
368 p('Kids TV', 'Blue Heeler', '#5ab8ff', '#ffb366', '#ffe08a', '#eef7ff'),
369 p('Kids TV', 'Rescue Pups', '#e8173f', '#3d7bff', '#ffd23f', '#fff0f2'),
370 p('Kids TV', 'Sesame Street', '#ffd000', '#2fbf5f', '#ff3b30', '#fffbe6'),
371 p('Kids TV', 'Muppet Show', '#7ed957', '#ff6fb5', '#ff9500', '#f3ffee'),
372 p('Kids TV', 'Magic School Bus', '#ffb000', '#2fbf71', '#ff6fb5', '#fff7e6'),
373 p('Kids TV', 'Cookie Monster', '#2f8bff', '#c9733a', '#9fd0ff', '#eef5ff'),
374
375 p('Horror Night', 'Haunted Mansion', '#9b5de5', '#3dffb0', '#ff7518', '#f3ecff'),
376 p('Horror Night', 'Pennywise', '#ff2a2a', '#ffffff', '#ff8a8a', '#fff0f0'),
377 p('Horror Night', 'Elm Street', '#e8173f', '#2fbf5f', '#ffb000', '#fff0f2'),
378 p('Horror Night', 'Crystal Lake', '#9fd3ff', '#c9a27a', '#e8173f', '#eef7ff'),
379 p('Horror Night', 'Silent Hill Fog', '#b8b8c0', '#8a7f6f', '#ff4f4f', '#ececee'),
380 p('Horror Night', 'Vampire Velvet', '#c8102e', '#7a1fa2', '#ffb3c1', '#fbeef0'),
381
382 p('Starships', 'Starfleet Command', '#ffd000', '#3d7bff', '#e8173f', '#fffbe6'),
383 p('Starships', 'LCARS Console', '#ff9966', '#cc99cc', '#9999ff', '#fff2ea'),
384 p('Starships', 'Warp Core', '#5ce1ff', '#7c6bff', '#ffffff', '#eafaff'),
385 p('Starships', 'Klingon Bird', '#e8173f', '#a0a0a8', '#ff8a00', '#fff0f2'),
386 p('Starships', 'Normandy', '#4a90e2', '#c0c0c0', '#ff8c1a', '#eef5fd'),
387 p('Starships', 'Serenity', '#c9733a', '#9fb7d9', '#ffd27f', '#f8efe6'),
388
389 p('Pokémon', 'Charmander Flame', '#ff7b00', '#ffd000', '#4fc3f7', '#fff4e6'),
390 p('Pokémon', 'Squirtle Splash', '#4fc3f7', '#c9733a', '#ffe08a', '#eef9ff'),
391 p('Pokémon', 'Bulbasaur Bloom', '#2ec4a6', '#7ed957', '#ff6f91', '#effcf9'),
392 p('Pokémon', 'Gengar Shade', '#9b5de5', '#ff4f6d', '#c08bff', '#f3ecff'),
393 p('Pokémon', 'Eevee Evolutions', '#c9a27a', '#4fc3f7', '#ffd000', '#f8f1e7'),
394 p('Pokémon', 'Mewtwo Psychic', '#c08bff', '#e0c3ff', '#7fe6ff', '#f5efff'),
395 p('Pokémon', 'Rayquaza Sky', '#2fbf71', '#ffd000', '#e8173f', '#ecfbf3'),
396
397 p('Nintendo Worlds', 'Dream Land', '#ff9ec7', '#ffd23f', '#5ab8ff', '#fff4f9'),
398 p('Nintendo Worlds', 'Varia Suit', '#ff7b00', '#e8173f', '#39ff14', '#fff4e6'),
399 p('Nintendo Worlds', 'Ink Battle', '#d4ff00', '#b026ff', '#ff6b00', '#fbffe6'),
400 p('Nintendo Worlds', 'Jungle Barrels', '#c9733a', '#ffd000', '#e8173f', '#f8efe6'),
401 p('Nintendo Worlds', 'Arwing Squadron', '#5ab8ff', '#c0c0c0', '#ff7b00', '#eef7ff'),
402 p('Nintendo Worlds', 'Kart Rainbow Road', '#ff2e88', '#00e5ff', '#ffe600', '#fff0f7'),
403
404 p('Fairy Tales', 'Wonderland', '#5ab8ff', '#ff6fb5', '#ffd23f', '#eef7ff'),
405 p('Fairy Tales', 'Neverland', '#7ed957', '#ffd23f', '#5ab8ff', '#f3ffee'),
406 p('Fairy Tales', 'Emerald City', '#2fbf71', '#ffd700', '#e8173f', '#ecfbf3'),
407 p('Fairy Tales', 'Snow Queen', '#bfe9ff', '#e0c3ff', '#ffffff', '#f5fbff'),
408 p('Fairy Tales', 'Briar Rose', '#ff6fb5', '#5ab8ff', '#ffd23f', '#fff0f8'),
409 p('Fairy Tales', 'Gingerbread House', '#c9733a', '#ff6fb5', '#ffffff', '#f8efe6'),
410
411 p('Pirate Seas', 'Black Pearl', '#c0c0c8', '#d4af37', '#e8173f', '#ececf2'),
412 p('Pirate Seas', 'Kraken Deep', '#00c2a8', '#7c6bff', '#7fffd4', '#e9fffb'),
413 p('Pirate Seas', 'Treasure Map', '#e8c37a', '#c9733a', '#e8173f', '#f8f3e8'),
414 p('Pirate Seas', 'Caribbean Blue', '#00b4d8', '#ffd166', '#ff7b54', '#eafaff'),
415 p('Pirate Seas', 'Jolly Roger', '#ffffff', '#e8173f', '#a0a0a8', '#efeff4'),
416
417 p('TV Classics', 'Blue Crystal', '#4fc3f7', '#c9e000', '#ffd000', '#eef9ff'),
418 p('TV Classics', 'Central Perk', '#ff8c42', '#9b5de5', '#ffd166', '#fff4ea'),
419 p('TV Classics', 'Paper Company', '#5ab8ff', '#c0c0c0', '#ffd000', '#eef7ff'),
420 p('TV Classics', 'Red Room', '#e8173f', '#ffd000', '#ffffff', '#fff0f2'),
421 p('TV Classics', 'Cordyceps', '#c9a227', '#7cb342', '#ff6b35', '#f8f3e3'),
422 p('TV Classics', 'Euphoria Glitter', '#b06bff', '#ff6fd8', '#7fe6ff', '#f6efff'),
423 p('TV Classics', 'Severed Floor', '#5ce1e6', '#ffffff', '#8bd450', '#ecfcfc'),
424
425 p('Tech Icons', 'Cupertino Silver', '#e8e8ee', '#5ab8ff', '#ff9ec7', '#f4f4f8'),
426 p('Tech Icons', 'Windows Blue', '#2f8bff', '#7fd3ff', '#ffd000', '#eef5ff'),
427 p('Tech Icons', 'Android Green', '#3ddc84', '#7fe6ff', '#ffd000', '#ecfbf3'),
428 p('Tech Icons', 'Firefox Blaze', '#ff7139', '#9059ff', '#ffbd4f', '#fff2ea'),
429 p('Tech Icons', 'Browser Primaries', '#ea4335', '#4285f4', '#fbbc05', '#fff1f0'),
430 p('Tech Icons', 'Console Blue', '#3d6bff', '#ffffff', '#00e5ff', '#eef2ff'),
431 p('Tech Icons', 'Console Green', '#52c41a', '#ffffff', '#c6ff00', '#effbea'),
432
433 p('Luxury Fashion', 'Saddle Orange', '#ff7b2e', '#c9733a', '#ffd2a8', '#fff2ea'),
434 p('Luxury Fashion', 'Web Stripe', '#2fa84f', '#e8173f', '#d4af37', '#eef8ec'),
435 p('Luxury Fashion', 'Quilted Noir', '#ffffff', '#d4af37', '#c0c0c8', '#f0f0f4'),
436 p('Luxury Fashion', 'Red Sole', '#e8173f', '#f5d0d6', '#d4af37', '#fff0f2'),
437 p('Luxury Fashion', 'Medusa Gold', '#ffd700', '#ffffff', '#c9a227', '#fffae6'),
438 p('Luxury Fashion', 'Monogram Brown', '#c69c6d', '#e8c39e', '#d4af37', '#f5ede4'),
439
440 p('Weather & Sky', 'Thunderstorm', '#9fb7ff', '#fff200', '#7c6bff', '#eef2ff'),
441 p('Weather & Sky', 'Double Rainbow', '#ff3b30', '#ffd000', '#5ab8ff', '#fff1f0'),
442 p('Weather & Sky', 'Golden Hour', '#ffb000', '#ff7b54', '#ffe08a', '#fff7e6'),
443 p('Weather & Sky', 'Blue Hour', '#5a7dff', '#b06bff', '#ffd27f', '#eef1ff'),
444 p('Weather & Sky', 'First Snowfall', '#e8f4ff', '#a8d8ff', '#ffffff', '#f6faff'),
445 p('Weather & Sky', 'Morning Fog', '#c0c8d0', '#9fb7c9', '#ffffff', '#eef1f4'),
446
447 p('Hockey', 'Nordiques', '#3d8bff', '#e8173f', '#ffffff', '#eef5ff'),
448 p('Hockey', 'Penguins', '#ffb81c', '#c0c0c0', '#ffe08a', '#fff8e6'),
449 p('Hockey', 'Blackhawks', '#e8173f', '#ffd000', '#2fbf71', '#fff0f2'),
450 p('Hockey', 'Red Wings', '#e8173f', '#ffffff', '#ff8a8a', '#fff0f2'),
451 p('Hockey', 'Rangers', '#3d6bff', '#e8173f', '#ffffff', '#eef2ff'),
452 p('Hockey', 'Golden Knights', '#d4af37', '#c0c0c8', '#e8173f', '#f8f0dc'),
453 p('Hockey', 'Lightning Bolt', '#3d8bff', '#ffffff', '#9fd0ff', '#eef5ff'),
454 p('Hockey', 'Senators', '#e8173f', '#d4af37', '#ffffff', '#fff0f2'),
455 p('Hockey', 'Flames', '#e8173f', '#ffc72c', '#ff8a00', '#fff0f2'),
456 p('Hockey', 'Jets', '#4a7dff', '#c0c0c0', '#e8173f', '#eef3ff'),
457
458 p('Toy Box', 'Brick Primary', '#e3000b', '#0055bf', '#ffd500', '#fff5f0'),
459 p('Toy Box', 'Speed Cube', '#ff5800', '#009b48', '#ffd500', '#f5f5f5'),
460 p('Toy Box', 'Modeling Dough', '#ff6fb5', '#4fc3f7', '#ffd23f', '#fff4fa'),
461 p('Toy Box', 'Orange Race Track', '#ff7b00', '#3d7bff', '#ffd000', '#fff4e6'),
462 p('Toy Box', 'Etch Sketch', '#e8173f', '#c0c0c0', '#ffd700', '#f4f4f6'),
463 p('Toy Box', 'Foam Dart Blaster', '#ff7b00', '#3dd6ff', '#c6ff00', '#fff4e6'),
464
465 p('Game Night', 'Board Game Money', '#e8173f', '#2fbf5f', '#ffd23f', '#f6fbef'),
466 p('Game Night', 'Wild Draw Four', '#ff3b30', '#ffd000', '#2f8bff', '#fff1f0'),
467 p('Game Night', 'Chess Noir', '#e8e8ee', '#a0a0a8', '#d4af37', '#efeff4'),
468 p('Game Night', 'Word Tiles', '#e8c37a', '#c9733a', '#e8173f', '#f8f3e8'),
469 p('Game Night', 'Poker Felt', '#2fbf71', '#e8173f', '#ffd700', '#ecfbf3'),
470 p('Game Night', 'Candy Path', '#ff6fb5', '#7ed957', '#ffd23f', '#fff4fa'),
471
472 p('Cocktail Hour', 'Mojito', '#7ed957', '#c4f5a1', '#ffffff', '#f2ffe9'),
473 p('Cocktail Hour', 'Margarita', '#c6ff00', '#7fe6d8', '#ffffff', '#fbffe6'),
474 p('Cocktail Hour', 'Blue Lagoon', '#00b4ff', '#7fe6ff', '#fff07a', '#eaf8ff'),
475 p('Cocktail Hour', 'Aperol Spritz', '#ff6b1a', '#ffb366', '#fff2cc', '#fff2ea'),
476 p('Cocktail Hour', 'Cosmopolitan', '#ff2e88', '#ff9ec7', '#d4af37', '#fff0f7'),
477 p('Cocktail Hour', 'Piña Colada', '#fff2cc', '#ffd23f', '#7ed957', '#fffbe8'),
478
479 p('Art Movements', 'Starry Night', '#3d6bff', '#ffd23f', '#7fd3ff', '#eef2ff'),
480 p('Art Movements', 'Water Lilies', '#7ec8b8', '#c4a7e7', '#ffb3c1', '#f0faf7'),
481 p('Art Movements', 'Pop Art', '#ff2e88', '#ffd000', '#00b4ff', '#fff0f7'),
482 p('Art Movements', 'Bauhaus', '#e8173f', '#2f6bff', '#ffd000', '#f6f4ef'),
483 p('Art Movements', 'Art Deco', '#d4af37', '#2fbf8a', '#e8e8ee', '#f8f0dc'),
484 p('Art Movements', 'Golden Kiss', '#d4af37', '#c9733a', '#7cb342', '#f8f0dc'),
485 p('Art Movements', 'Casa Azul', '#2f6bff', '#ff4f6d', '#ffd23f', '#eef2ff'),
486 p('Art Movements', 'Paper Cut-Outs', '#3d6bff', '#ff7b54', '#7ed957', '#eef2ff'),
487
488 p('Decades', 'Roaring Twenties', '#d4af37', '#e8e8ee', '#2fbf8a', '#f8f0dc'),
489 p('Decades', 'Fifties Diner', '#ff4f6d', '#7fe6d8', '#ffd23f', '#fff1f4'),
490 p('Decades', 'Psychedelic Sixties', '#ff6b1a', '#b06bff', '#c6ff00', '#fff2ea'),
491 p('Decades', 'Seventies Shag', '#e8a33d', '#c9733a', '#9cc96a', '#fbf3e6'),
492 p('Decades', 'Eighties Memphis', '#ff6fb5', '#00d0ff', '#ffd000', '#fff4fa'),
493 p('Decades', 'Nineties Grunge', '#9cc96a', '#c9733a', '#e8173f', '#eef4e6'),
494 p('Decades', 'Millennium Bug', '#5ce1ff', '#c0c8ff', '#ff7ad9', '#eefbff'),
495
496 p('Deep Sea', 'Anglerfish', '#fff07a', '#5ce1ff', '#ff6b35', '#fffde8'),
497 p('Deep Sea', 'Jellyfish Bloom', '#ff9ec7', '#b88bff', '#7fe6ff', '#fff4f9'),
498 p('Deep Sea', 'Manta Ray', '#5a8bff', '#9fb7d9', '#7fe6d8', '#eef3ff'),
499 p('Deep Sea', 'Bioluminescence', '#00ffd0', '#00a3ff', '#b4ff6e', '#e9fffb'),
500 p('Deep Sea', 'Orca Pod', '#e8e8ee', '#5ab8ff', '#a0a0a8', '#f0f4f8'),
501 p('Deep Sea', 'Sea Turtle', '#7cb342', '#c9a27a', '#00c2c7', '#f2f7ec'),
502
503 p('Flower Garden', 'Rose Garden', '#ff4d6d', '#7cb342', '#ffb3c1', '#fff0f3'),
504 p('Flower Garden', 'Sunflower Field', '#ffd000', '#c9733a', '#7cb342', '#fffbe6'),
505 p('Flower Garden', 'Tulip Fields', '#ff3b5c', '#ff9ec7', '#ffd23f', '#fff0f3'),
506 p('Flower Garden', 'Lotus Pond', '#ff9ec7', '#7ec8b8', '#fff2cc', '#fff4f9'),
507 p('Flower Garden', 'Orchid', '#c77dff', '#ff9ec7', '#7ed957', '#f7f0ff'),
508 p('Flower Garden', 'Poppy', '#ff3d00', '#7cb342', '#ffd166', '#fff0e6'),
509
510 p('Focus Modes', 'Deep Work', '#7aa2f7', '#9ece6a', '#e0af68', '#c0caf5'),
511 p('Focus Modes', 'Late Night Coding', '#c678dd', '#56b6c2', '#e5c07b', '#d7dae0'),
512 p('Focus Modes', 'Sunrise Standup', '#ff9e64', '#ffd27f', '#7dcfff', '#fff4ea'),
513 p('Focus Modes', 'Zen Garden', '#9cc96a', '#c9b37e', '#e8dcc4', '#f2f4ea'),
514 p('Focus Modes', 'Paper White', '#e8e8ee', '#b8c0cc', '#ffd27f', '#f4f4f6'),
515 p('Focus Modes', 'Pomodoro', '#ff5a5f', '#7ed957', '#ffd23f', '#fff1f1'),
516
517 p('Basketball', 'Bay Blue & Gold', '#2f6bff', '#ffc72c', '#9fc0ff', '#eef3ff'),
518 p('Basketball', 'Heat Culture', '#e8173f', '#ffb000', '#ff8a00', '#fff0f2'),
519 p('Basketball', 'Garden Orange', '#ff7b00', '#2f6bff', '#ffb366', '#fff4e6'),
520 p('Basketball', 'Valley Sun', '#ff7b00', '#b06bff', '#ffd23f', '#fff4e6'),
521 p('Basketball', 'Silver & Black Spurs', '#c4ced4', '#8a8a96', '#e8e8ee', '#eef0f2'),
522 p('Basketball', 'Thunder Up', '#3d8bff', '#ff7b54', '#ffd000', '#eef5ff'),
523]
524types/index.d.ts 62 lines1/** One theme: four colors and an optional message background. */
2export type Palette = {
3 /** Stable id, `collection:name` slugged; saved themes use `mine:<name>`. */
4 id: string
5 name: string
6 /** The collection it is listed under in the studio. */
7 group: string
8 /** Borders, headings, chips and the first stop of every gradient. */
9 accent: string
10 /** Reply cards, bullets and the second gradient stop. */
11 secondary: string
12 /** Bold text, inline code, running tools and the hottest gradient stop. */
13 highlight: string
14 /** Body text. */
15 text: string
16 /** Message background; tinted from the accent when absent. */
17 background?: string
18}
19
20/** How messages are drawn: recolored text and backgrounds, a border only, or untouched. */
21export type MessageStyle = 'full' | 'outline' | 'off'
22
23/** How your own prompts are drawn: a text bubble, a frame around the app's own row (keeps pasted images), or the app's row untouched. */
24export type PromptStyle = 'bubble' | 'frame' | 'native'
25
26/** Which canvas the theme is tuned for; `auto` follows Claude Code's own theme setting. */
27export type BaseMode = 'auto' | 'dark' | 'light'
28
29declare module 'claude-code' {
30 interface PluginState {
31 'chat-bubbles': {
32 /** The theme in use, or null for Claude Code's own look. */
33 active: Palette | null
34 /** The collection the studio is showing. */
35 group: string
36 /** The studio's search text; empty shows the collection. */
37 query: string
38 /** The palette under "Mix your own". */
39 custom: Palette
40 /** Themes the person saved from the mixer. */
41 saved: Palette[]
42 messageStyle: MessageStyle
43 promptStyle: PromptStyle
44 /** Which of your prompts carry pasted images or files, by row id and by text. */
45 media: Record<string, boolean>
46 /** Theme the tool rows, spinner, footer, command output and mod panes too. */
47 themeChrome: boolean
48 base: BaseMode
49 /** `base` with `auto` resolved: what the colors are tuned against. Other mods read it. */
50 resolvedBase: 'dark' | 'light'
51 /** A background that overrides every theme's, from `/theme bg`. */
52 bgOverride: string | null
53 /** Claude Code's `prefersReducedMotion`: no shimmer when true. */
54 reducedMotion: boolean
55 /** The spinner's shimmer frame while a turn runs. */
56 frame: number
57 /** The studio's last status line. */
58 notice: string
59 }
60 }
61}
62