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 847 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, 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
221const formatDuration = (ms: number) => {
222 const s = Math.max(0, Math.round(ms / 1000))
223 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
224}
225
226const listing = (collection?: string, mine: readonly Palette[] = []) => {
227 const groups = collection ? GROUPS.filter(g => slug(g).includes(slug(collection))) : GROUPS
228 const all = [...PRESETS, ...mine]
229 const lines = groups
230 .map(g => {
231 const names = all.filter(one => one.group === g).map(one => one.name)
232 return names.length ? `**${g}** (${names.length}): ${names.join(', ')}` : ''
233 })
234 .filter(Boolean)
235 if (lines.length === 0) return `No collection matches \`${collection}\`. Try \`/bubbles list\`.`
236 return `${all.length} themes in ${GROUPS.length - 1} collections. Apply one with \`/bubbles <name>\`.\n\n${lines.join('\n\n')}`
237}
238
239// ── Hooks ────────────────────────────────────────────────────────────────
240
241export const register: Register = on => {
242 let tick: Timer | null = null
243 const stopShimmer = () => {
244 tick?.cancel()
245 tick = null
246 }
247
248 on('session.start', async ($, e, next) => {
249 await $.command.register({
250 name: 'bubbles',
251 description: 'Recolor the chat: open the studio, or /bubbles <name> | random | next | list | bg | base | off',
252 argumentHint: '[name | random | next | prev | list | bg <hex> | base <mode> | off | help]',
253 })
254 await restore($)
255 await resolveBase($)
256 return next(e)
257 })
258
259 // Follow Claude Code's own dark/light setting while the base is `auto`.
260 on('config.set', { key: 'theme' }, async ($, e, next) => {
261 const done = await next(e)
262 await resolveBase($)
263 return done
264 })
265
266 on('prompt.submit', async ($, e, next) => {
267 stopShimmer()
268 if ((await chromeLook($)) && !(await read($, reducedMotion))) {
269 tick = $.clock.every(SHIMMER_MS, () => void update($, frame, f => (f + 1) % 960))
270 }
271 return next(e)
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 stopShimmer()
276 return next(e)
277 })
278
279 on('command.run', { command: 'bubbles' }, async ($, e) => {
280 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
281 const arg = rest.join(' ')
282 switch (verb.toLowerCase()) {
283 case '':
284 await openStudio($)
285 return { text: 'Chat Bubbles studio opened.' }
286 case 'help':
287 case '?':
288 return { text: HELP }
289 case 'off':
290 case 'reset':
291 case 'none':
292 await apply($, null)
293 return { text: 'Theme off.' }
294 case 'list':
295 case 'all':
296 return { text: listing(arg || undefined, await read($, saved)) }
297 case 'random': {
298 const pick = pickRandom(arg || undefined)
299 if (!pick) return { text: `No collection matches \`${arg}\`. Try \`/bubbles list\`.` }
300 await apply($, pick)
301 return { text: `🎲 ${pick.name} · ${pick.group}` }
302 }
303 case 'next':
304 case 'prev': {
305 const one = await step($, verb.toLowerCase() === 'next' ? 1 : -1)
306 return { text: one ? `🎨 ${one.name} · ${one.group}` : 'Nothing to step through yet.' }
307 }
308 case 'bg':
309 case 'background':
310 return { text: await setBackground($, arg) }
311 case 'prompt': {
312 const mode = arg.toLowerCase() as PromptStyle
313 if (!PROMPTS.includes(mode)) return { text: `Use \`/bubbles prompt ${PROMPTS.join('\`, \`/bubbles prompt ')}\`. Now: ${PROMPT_LABEL[await read($, promptStyle)]}.` }
314 await update($, promptStyle, () => mode)
315 await persist($, 'promptStyle', mode)
316 return { text: `Your prompts: ${PROMPT_LABEL[mode]}.` }
317 }
318 case 'base': {
319 const mode = arg.toLowerCase() as BaseMode
320 if (!BASES.includes(mode)) return { text: 'Use `/bubbles base auto`, `/bubbles base dark` or `/bubbles base light`.' }
321 await setBase($, mode)
322 return { text: `Colors tuned for a ${await read($, resolvedBase)} canvas (${mode}).` }
323 }
324 default: {
325 const hit = findPreset(e.args, await read($, saved))
326 if (!hit) {
327 const near = searchPresets(e.args, await read($, saved), 5).map(one => `\`${one.name}\``)
328 return { text: `No theme matches "${e.args.trim()}".${near.length ? ` Close: ${near.join(', ')}.` : ''} Try \`/bubbles list\`.` }
329 }
330 await apply($, hit)
331 return { text: `🎨 ${hit.name} · ${hit.group}` }
332 }
333 }
334 })
335
336 // Note which prompts carry media, before they are drawn. A slash command's own
337 // row comes in by another door. Only the row id and a yes/no are kept.
338 for (const door of ['prompt', 'command'] as const) {
339 on('session.append', { door }, async ($, e, next) => {
340 const blocks = blocksOf(e.message.content)
341 await persist($, 'media', await update($, media, was => remember(was, e.uuid, blocks)))
342 return next(e)
343 })
344 }
345
346 // Your prompts: a bubble on the right in the rival color, the way every
347 // messenger does it. Other user-role rows (task notifications, messages from
348 // agents) stay on the left with a quiet stripe.
349 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
350 const style = await read($, messageStyle)
351 const current = await currentLook($)
352 if (!current || style === 'off') return next(e)
353 const { look } = current
354 const { Box, Text } = $.ui.resolve(e)
355 const isYours = YOURS.includes(e.props.origin.kind)
356
357 if (!isYours) {
358 if (!(await read($, themeChrome))) return next(e)
359 return (
360 <Box flexDirection="row">
361 <Text color={look.muted}>{'▏'}</Text>
362 <Box flexDirection="column" flexGrow={1}>
363 {await next(e)}
364 </Box>
365 </Box>
366 )
367 }
368 // Room on the left pushes the bubble right; long prompts wrap inside it.
369 // The engine's own row may not sit under a Box that has `width` or
370 // `minWidth` (the engine refuses the tree and draws its own), so the row
371 // takes its width from the parent and only the spacer beside it has one.
372 // `up` pulls the row over the app's row above it, onto the empty pill that row
373 // leaves. The bubble's rounded corners are transparent, so the pill would show
374 // through them: the bubble sits on a plate of the canvas color. Only then:
375 // `frame` puts the app's row inside this Box, and the engine refuses
376 // `position` on a Box above it.
377 const right = (bubble: RenderNode, up = 0) => (
378 <Box key="you-bubble" flexDirection="row" justifyContent="flex-end" {...(up ? { marginTop: -up, position: 'relative' as const } : {})}>
379 <Box width="18%" flexShrink={0} />
380 {up ? (
381 <Box backgroundColor={look.canvas} flexShrink={1}>
382 {bubble}
383 </Box>
384 ) : (
385 bubble
386 )}
387 </Box>
388 )
389 // `bubble` draws the prompt's text itself: predictable size and color, but
390 // the app's own row also carries pasted images and files, which `text` does
391 // not, so `frame` wraps that row instead (the app sizes it, so the frame
392 // can be tighter than a bubble) and `native` leaves it alone.
393 const mode = style === 'outline' ? 'frame' : await read($, promptStyle)
394 if (mode === 'native') return next(e)
395 if (mode === 'frame' || !e.props.isExpanded) {
396 return right(
397 <Box borderStyle="round" borderColor={look.you} backgroundColor={look.youBg} paddingX={1} flexShrink={1}>
398 {await next(e)}
399 </Box>,
400 )
401 }
402 // Only text: the app's own row is all there is to draw.
403 if (e.props.text.trim() === '') return next(e)
404 const text = (
405 <Box borderStyle="round" borderColor={look.you} backgroundColor={look.youBg} paddingX={1} flexShrink={1}>
406 <Text color={look.youText} wrap="wrap">
407 {e.props.text}
408 </Text>
409 </Box>
410 )
411 // A prompt known to have no media is just the bubble. One with media also
412 // gets the app's own row without its text above the bubble: the images and
413 // files, outside the colored block. Nothing wraps
414 // that row (a wrapper shrinks it and breaks its own right alignment); the
415 // empty pill it leaves is covered by pulling the bubble up.
416 // The terminal draws images as text inside the prompt, so there it is just the bubble.
417 if (e.surface === 'terminal') return right(text)
418 // Never seen (sent before the mod was installed): the app's own row, so
419 // nothing is lost and no empty pill is left behind.
420 const known = (await read($, media))[e.requestId]
421 if (known === undefined) return next(e)
422 if (!known) return right(text)
423 return (
424 <Box flexDirection="column">
425 {await next({ ...e, props: { ...e.props, text: '' } })}
426 {right(text, PILL_CELLS)}
427 </Box>
428 )
429 })
430
431 // Claude's replies: on the left, a quiet card; the label is text, not a bar.
432 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
433 const style = await read($, messageStyle)
434 const current = await currentLook($)
435 if (!current || style === 'off') return next(e)
436 const { look } = current
437 const els = $.ui.resolve(e)
438 const { Box, Text } = els
439 if (style === 'outline' || e.props.text.length > 30000) {
440 return (
441 <Box borderStyle="round" borderColor={look.frame} paddingX={1}>
442 {await next(e)}
443 </Box>
444 )
445 }
446 return (
447 <Box flexDirection="column" borderStyle="round" borderColor={look.frame} backgroundColor={look.replyBg} paddingX={1}>
448 {e.props.isFirstOfReply ? (
449 <Text bold color={look.accent}>
450 {'✦ Claude'}
451 </Text>
452 ) : null}
453 {paint(els, e.props.text, look)}
454 </Box>
455 )
456 })
457
458 // The footer's mode labels, then the theme's name as a button that opens the studio.
459 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
460 const current = await currentLook($)
461 if (!current) return next(e)
462 const { pal, look } = current
463 const isChrome = await read($, themeChrome)
464 const { Box, Text, Button } = $.ui.resolve(e)
465 return (
466 <Box flexDirection="row" gap={1}>
467 {e.props.modes.length > 0 ? (
468 isChrome ? (
469 <Text color={look.muted}>{e.props.modes.join(' & ')}</Text>
470 ) : (
471 <Text dimColor>{e.props.modes.join(' & ')}</Text>
472 )
473 ) : null}
474 <Box key="theme-chip" flexDirection="row">
475 {isChrome ? <Text color={look.accent}>{'▍'}</Text> : null}
476 <Button
477 key="open-studio"
478 label={`🎨 ${pal.name}`}
479 plain
480 hover={{ underline: true }}
481 onPress={() => void openStudio($)}
482 />
483 </Box>
484 </Box>
485 )
486 })
487
488 // The hint under the prompt, in the theme's quiet color. The terminal keeps
489 // its own line (its pills stay live); other surfaces take the tree.
490 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
491 const current = await chromeLook($)
492 if (!current || e.surface === 'terminal') return next(e)
493 const { Text } = $.ui.resolve(e)
494 return <Text color={current.look.muted}>{e.props.tail ? `${e.props.hint} ${e.props.tail}` : e.props.hint}</Text>
495 })
496
497 // "Baked for 12s" at the end of a turn (terminal), with a spark.
498 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
499 const current = await chromeLook($)
500 if (!current) return next(e)
501 const { look } = current
502 const { Text } = $.ui.resolve(e)
503 return (
504 <Text>
505 <Text bold color={look.accent}>
506 {'✦ '}
507 </Text>
508 <Text color={look.muted}>{`${e.props.word} for ${formatDuration(e.props.durationMs)}`}</Text>
509 </Text>
510 )
511 })
512
513 // Tool rows, folded runs, results and command output: a stripe and a strip.
514 const striped = (look: Look, mark: string, drawn: RenderNode, K: Pick<ElementTable, 'Box' | 'Text'>, strength = 0.1, glyph = '▍') => (
515 <K.Box flexDirection="row" backgroundColor={strength > 0 ? stripOf(look, mark, strength) : undefined}>
516 <K.Text bold color={mark}>
517 {glyph}
518 </K.Text>
519 <K.Box flexDirection="column" flexGrow={1}>
520 {drawn}
521 </K.Box>
522 </K.Box>
523 )
524
525 // Tool rows are striped in the terminal only: the desktop app draws its own
526 // folded tool summary, and a stripe around it is an empty bar.
527 // Finished tools stay quiet so the ones running (highlight) and failing (red) are what you see.
528 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
529 const current = await chromeLook($)
530 if (!current || e.surface !== 'terminal') return next(e)
531 const { look } = current
532 const mark = e.props.isErrored ? look.error : e.props.isRunning ? look.highlight : look.muted
533 return striped(look, mark, await next(e), $.ui.resolve(e), e.props.isErrored ? 0.16 : 0.06)
534 })
535
536 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
537 const current = await chromeLook($)
538 if (!current || e.surface !== 'terminal') return next(e)
539 const { look } = current
540 return striped(look, e.props.isActive ? look.highlight : look.muted, await next(e), $.ui.resolve(e), 0.05)
541 })
542
543 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
544 const current = await chromeLook($)
545 if (!current || e.surface !== 'terminal') return next(e)
546 const { look } = current
547 return striped(look, e.props.isErrored ? look.error : look.muted, await next(e), $.ui.resolve(e), 0, '▏')
548 })
549
550 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
551 const current = await chromeLook($)
552 if (!current || e.surface !== 'terminal') return next(e)
553 const { look } = current
554 return striped(look, e.props.isErrored ? look.error : look.highlight, await next(e), $.ui.resolve(e), 0.08)
555 })
556
557 // The spinner: a twinkling spark and the word shimmering through the palette.
558 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
559 const current = await chromeLook($)
560 if (!current) return next(e)
561 const { pal, look } = current
562 const { Box, Text } = $.ui.resolve(e)
563 const still = await read($, reducedMotion)
564 const f = still ? 0 : await read($, frame)
565 const stops = loopGradient([look.accent, look.secondary, look.highlight, look.secondary])
566 const spark = (
567 <Text bold color={stops[(f * 2) % stops.length] ?? pal.accent}>
568 {`${SPARKS[f % SPARKS.length] ?? '✦'} `}
569 </Text>
570 )
571 // The terminal keeps its own line (elapsed time, tokens) beside the spark.
572 if (e.surface === 'terminal') {
573 return (
574 <Box flexDirection="row">
575 {spark}
576 {await next(e)}
577 </Box>
578 )
579 }
580 const words = e.props.message ?? e.props.word
581 return (
582 <Box flexDirection="row" gap={1}>
583 <Text>
584 {spark}
585 {[...words].map((ch, i) => (
586 <Text bold color={stops[(i + f) % stops.length] ?? look.accent}>
587 {ch}
588 </Text>
589 ))}
590 <Text color={look.text}>{e.props.suffix}</Text>
591 </Text>
592 <Text color={look.muted}>{MODE_LABEL[e.props.mode] ?? ''}</Text>
593 </Box>
594 )
595 })
596
597 // Every pane a mod opens, this one included: a tinted backdrop.
598 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
599 const current = await chromeLook($)
600 if (!current) return next(e)
601 const { Box } = $.ui.resolve(e)
602 return (
603 <Box flexDirection="column" backgroundColor={current.look.replyBg} paddingX={1}>
604 {await next(e)}
605 </Box>
606 )
607 })
608
609 // ── The studio ──────────────────────────────────────────────────────────
610 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
611 const els = $.ui.resolve(e)
612 const { Box, Text, Button } = els
613 // Mobile draws no fields: collections become buttons and the mixer is hidden.
614 const Input = 'Input' in els ? els.Input : undefined
615 const Select = 'Select' in els ? els.Select : undefined
616
617 const pal = await read($, active)
618 const shown = await read($, group)
619 const search = await read($, query)
620 const mixer = await read($, custom)
621 const mine = await read($, saved)
622 const style = await read($, messageStyle)
623 const prompt = await read($, promptStyle)
624 const isChrome = await read($, themeChrome)
625 const mode = await read($, base)
626 const canvas = (await read($, resolvedBase)) as Base
627 const bg = await read($, bgOverride)
628 const said = await read($, notice)
629 const ui = lookOf(pal ?? DEFAULT_CUSTOM, canvas, bg)
630
631 const results = search ? searchPresets(search, mine, 40) : inGroup(shown, mine)
632 const swatch = (one: Palette) => (
633 <Text>
634 <Text color={one.accent}>██</Text>
635 <Text color={one.secondary}>██</Text>
636 <Text color={one.highlight}>██</Text>
637 <Text color={one.text}>██</Text>
638 <Text color={lookOf(one, canvas, bg).replyBg}>██</Text>
639 </Text>
640 )
641 const heading = (label: string) => (
642 <Text bold color={ui.accent}>
643 {label}
644 </Text>
645 )
646
647 return (
648 <Box flexDirection="column" gap={1}>
649 <Box flexDirection="column">
650 {heading('💬 CHAT BUBBLES')}
651 <Box gap={1}>
652 <Text color={ui.text}>Active:</Text>
653 {pal ? swatch(pal) : null}
654 <Text bold color={ui.secondary}>
655 {pal ? `${pal.name} · ${pal.group}` : "Claude Code's own look"}
656 </Text>
657 </Box>
658 </Box>
659
660 <Box flexDirection="column">
661 {Input ? (
662 <Input
663 key="search"
664 label="Search"
665 value={search}
666 placeholder={`${PRESETS.length} themes: try "hockey", "neon", "gryffindor"`}
667 submitLabel="search"
668 onInput={value => void update($, query, () => value)}
669 onSubmit={value => void update($, query, () => value)}
670 />
671 ) : null}
672 {search ? (
673 <Box gap={1}>
674 <Text color={ui.muted}>{`${results.length === 40 ? '40+' : results.length} match${results.length === 1 ? '' : 'es'}`}</Text>
675 <Button key="clear-search" label="Clear" plain dimColor onPress={() => update($, query, () => '')} />
676 </Box>
677 ) : Select ? (
678 <Select
679 key="group"
680 label="Collection"
681 value={shown}
682 options={GROUPS.map(g => ({ value: g, label: g === MINE ? `${g} (${mine.length})` : g }))}
683 onSelect={value => update($, group, () => value)}
684 />
685 ) : (
686 <Box flexWrap="wrap" gap={1}>
687 {GROUPS.map(g => (
688 <Button key={`g-${slug(g)}`} label={g} dimColor={g !== shown} onPress={() => update($, group, () => g)} />
689 ))}
690 </Box>
691 )}
692 {results.length === 0 ? (
693 <Text color={ui.muted}>{search ? 'No theme matches that.' : 'No saved themes yet. Mix one below and press Enter on "Save as".'}</Text>
694 ) : null}
695 {results.map(one => (
696 <Box key={`row-${one.id}`} gap={1}>
697 {swatch(one)}
698 <Button
699 key={`apply-${one.id}`}
700 label={search ? `${one.name} · ${one.group}` : one.name}
701 variant={pal?.id === one.id ? 'primary' : undefined}
702 onPress={() => apply($, one)}
703 />
704 {pal?.id === one.id ? <Text color={ui.highlight}>◆ on</Text> : null}
705 </Box>
706 ))}
707 </Box>
708
709 {Input ? (
710 <Box flexDirection="column">
711 {heading('MIX YOUR OWN')}
712 {SLOTS.map(({ slot, label }) => (
713 <Box key={`slot-${slot}`} gap={1}>
714 <Text color={mixer[slot] ?? lookOf(mixer, canvas).replyBg}>██</Text>
715 <Input
716 key={`hex-${slot}`}
717 label={label}
718 value={mixer[slot] ?? ''}
719 placeholder={slot === 'background' ? 'auto (tinted from the accent)' : '#ff2bd6'}
720 submitLabel="set"
721 onSubmit={async value => {
722 if (slot === 'background' && value.trim() === '') {
723 const next = await update($, custom, c => ({ ...c, background: undefined }))
724 await persist($, 'custom', next)
725 await update($, notice, () => 'Mixer background back to auto')
726 return
727 }
728 const hex = normalizeHex(value)
729 if (!hex) {
730 await update($, notice, () => `"${value}" isn't a hex color. Try #ff2bd6 or f0f.`)
731 return
732 }
733 const next = await update($, custom, c => ({ ...c, [slot]: hex }))
734 await persist($, 'custom', next)
735 await update($, notice, () => `${slot} set to ${hex}`)
736 }}
737 />
738 </Box>
739 ))}
740 <Box gap={1} flexWrap="wrap">
741 {swatch(mixer)}
742 <Button
743 key="apply-custom"
744 label="Apply custom"
745 variant="primary"
746 onPress={async () => apply($, { ...(await read($, custom)), id: 'custom', name: 'Custom', group: MINE })}
747 />
748 <Button
749 key="random-neon"
750 label="🎲 Random neon"
751 onPress={async () => {
752 const next = await update($, custom, () => randomNeon())
753 await persist($, 'custom', next)
754 await apply($, next)
755 }}
756 />
757 </Box>
758 <Input
759 key="save-name"
760 label="Save as"
761 placeholder="name your theme, Enter to save"
762 submitLabel="save"
763 onSubmit={async value => {
764 const name = value.trim().slice(0, 60)
765 if (!name) return
766 const one: Palette = { ...(await read($, custom)), name, group: MINE, id: `mine:${slug(name)}` }
767 const list = await update($, saved, l => [...l.filter(x => x.id !== one.id), one])
768 await persist($, 'saved', list)
769 await update($, group, () => MINE)
770 await update($, query, () => '')
771 await apply($, one)
772 }}
773 />
774 </Box>
775 ) : null}
776
777 <Box flexDirection="column">
778 {heading('LOOK')}
779 <Box gap={1} flexWrap="wrap">
780 <Button
781 key="style-toggle"
782 label={`Messages: ${STYLE_LABEL[style]}`}
783 onPress={async () => {
784 const next = await update($, messageStyle, v => STYLES[(STYLES.indexOf(v) + 1) % STYLES.length] ?? 'full')
785 await persist($, 'messageStyle', next)
786 }}
787 />
788 <Button
789 key="prompt-toggle"
790 label={`Prompts: ${PROMPT_LABEL[prompt]}`}
791 onPress={async () => {
792 const next = await update($, promptStyle, v => PROMPTS[(PROMPTS.indexOf(v) + 1) % PROMPTS.length] ?? 'bubble')
793 await persist($, 'promptStyle', next)
794 }}
795 />
796 <Button
797 key="chrome-toggle"
798 label={`Tools, spinner & panes: ${isChrome ? 'on' : 'off'}`}
799 onPress={async () => {
800 const next = await update($, themeChrome, v => !v)
801 await persist($, 'themeChrome', next)
802 }}
803 />
804 <Button
805 key="base-toggle"
806 label={`Canvas: ${mode}${mode === 'auto' ? ` (${canvas})` : ''}`}
807 onPress={async () => setBase($, BASES[(BASES.indexOf(mode) + 1) % BASES.length] ?? 'auto')}
808 />
809 </Box>
810 {Input ? (
811 <Box gap={1}>
812 <Text color={bg ?? ui.replyBg}>██</Text>
813 <Input
814 key="bg-override"
815 label="Background for every theme"
816 value={bg ?? ''}
817 placeholder="auto — or a hex like #1a1a2e"
818 submitLabel="set"
819 onSubmit={async value => {
820 await update($, notice, () => '')
821 const said = await setBackground($, value)
822 await update($, notice, () => said.replace(/`/g, ''))
823 }}
824 />
825 </Box>
826 ) : null}
827 <Box gap={1} flexWrap="wrap">
828 {mine.length > 0 && shown === MINE && !search ? (
829 <Button
830 key="clear-mine"
831 label="Clear my themes"
832 dimColor
833 onPress={async () => {
834 await update($, saved, () => [])
835 await persist($, 'saved', [])
836 }}
837 />
838 ) : null}
839 <Button key="off" label="Theme off" dimColor onPress={() => apply($, null)} />
840 </Box>
841 </Box>
842 {said ? <Text color={ui.highlight}>{said}</Text> : null}
843 </Box>
844 )
845 })
846}
847hooks/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