SLOPSHOPPER

chat-bubbles

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.

newpanespinnerrowscommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-bubbles
│ ┃ Chat Bubbles ✕ › fix the failing auth test and add an audit log call │ ┃ 💬 CHAT BUBBLES │ ┃ Active: Claude Code's own look ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Search: 441 themes: try "hockey", "neon", "g ⏺ Update(src/auth.ts) │ ┃ Collection: Neon ▾ ⎿ Added 2 lines, removed 1 line │ ┃ ██████████ [ Synthwave '84 ] ⏺ Bash(bun test) │ ┃ ██████████ [ Tron Grid ] ⎿ 3 pass, 1 fail │ ┃ ██████████ [ Vaporwave ] │ ┃ ██████████ [ Miami Vice ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ██████████ [ Laser Tag ] │ ┃ ██████████ [ Blacklight Party ] ✻ Worked for 42s · done 4:20 PM │ ┃ ██████████ [ Glowstick Rave ] │ ┃ › /bubbles │ ┃ MIX YOUR OWN ⎿ chat-bubbles: Chat Bubbles studio opened. │ ┃ ██ Accent : #ff2bd6 ⏎ set │ ┃ ██ Secondary : #00f0ff ⏎ set │ ┃ ██ Highlight : #fffb00 ⏎ set │ ┃ ██ Text : #f5e9ff ⏎ set │ ┃ ██ Background: auto (tinted from the accent) │ ┃ ██████████ [ Apply custom ] [ 🎲 Random neon │ ┃ Save as: name your theme, Enter to save ⏎ sa │ ┃ │ ┃ LOOK │ ┃ [ Messages: full color ] [ Prompts: bubble ( │ ┃ ██ Background for every theme: auto — or a h ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chat Bubbles
💬 CHAT BUBBLES Active: Claude Code's own look Search: 441 themes: try "hockey", "neon", "gryffindor" ⏎ sea Collection: Neon ▾ ██████████ [ Synthwave '84 ] ██████████ [ Tron Grid ] ██████████ [ Vaporwave ] ██████████ [ Miami Vice ] ██████████ [ Laser Tag ] ██████████ [ Blacklight Party ] ██████████ [ Glowstick Rave ] MIX YOUR OWN ██ Accent : #ff2bd6 ⏎ set ██ Secondary : #00f0ff ⏎ set ██ Highlight : #fffb00 ⏎ set ██ Text : #f5e9ff ⏎ set ██ Background: auto (tinted from the accent) ⏎ set ██████████ [ Apply custom ] [ 🎲 Random neon ] Save as: name your theme, Enter to save ⏎ save LOOK [ Messages: full color ] [ Prompts: bubble (text only) ] [ T ██ Background for every theme: auto — or a hex like #1a1a2e [ Theme off ]
README

Chat Bubbles for Claude Code

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.

What's different from Theme Studio

Theme StudioChat Bubbles
Your messagesFull width, left, a YOU chipRight-aligned bubble, like a messenger
Your bubble colorThe theme's accent (same family as Claude's)A rival color: orange under a cool theme, sky blue under a warm one
Claude's repliesSolid colored header barA quiet card with a ✦ Claude label
IntensityFull neonEach color pulled 25% toward neutral ink, softer backgrounds
Finished toolsAccent stripe (everything is colored)Gray: only running and failing tools stand out
Desktop tool rowsEmpty colored bars under the app's own tool summaryLeft to the app: no empty bars
Pasted images (desktop)Hidden in full-color modeA 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)

Install

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.

Commands

CommandDoes
/bubblesOpen 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 prevStep 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 <bubbleframenative>`Your prompts: a text bubble (default), a frame around the app's own row (keeps pasted images), or untouched
/bubbles offBack to Claude Code's own look

How the rival color works

The bubble color is picked from the opposite side of the color wheel from the theme's accent:

  • Cool theme (greens, blues, violets) → orange bubble (#ff8c42 dark / #c2410c light)
  • Warm theme (reds, pinks, yellows) → sky blue bubble (#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.

Your prompts: three ways to draw them

/bubbles prompt <bubble | frame | native>, also a button in the studio.

ModeWhat you getTrade-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 blockThe 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)
frameThe app's own message row, framed in the rival colorKeeps pasted images, but the app sizes its row, so the frame can be tight and leave empty space
nativeThe app's own row, untouchedNo 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.

Where it draws (and where it can't)

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.

Security and privacy

Chat Bubbles only draws. Checked with claude plugin validate, it calls:

Engine APIWhy
$.ui.*Draw messages, the studio pane, toasts
$.state / $.storeRemember 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.listRead whether Claude Code's theme is dark or light
$.settings.readRead prefersReducedMotion
$.command.register, $.clockThe /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.

Development

git clone https://github.com/angeldelbiondo/claude-chat-bubbles
cd claude-chat-bubbles
claude --plugin-dir ./chat-bubbles   # edits hot-reload
ScriptDoes
npm run typechecktsc over the plugin and its tests (run /plugin-types .claude/types in Claude Code once first)
npm run validateclaude plugin validate on the marketplace and the plugin
npm testclaude plugin test: unit tests plus engine tests on terminal and desktop

See CONTRIBUTING.md to add a theme.

En español

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>.

Credits

Forked from Theme Studio by Alliance Optima, under the MIT license. The 441 palettes, the studio and the markdown painter are theirs.

Trademarks

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.

License

MIT

Source 8 files
hooks/register.tsx 847 lines
1// 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}
847
hooks/color.ts 106 lines
1// 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}
106
hooks/look.ts 83 lines
1// 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)
83
hooks/markdown.tsx 166 lines
1// 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}
166
hooks/media.ts 28 lines
1// 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) })
28
hooks/palette.ts 100 lines
1// 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}
100
hooks/presets.ts 524 lines
1import 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]
524
types/index.d.ts 62 lines
1/** 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