When Claude asks you to choose a design direction, opens a pane that draws each option as a mockup with a Choose button and sends your pick as the next prompt

Three Claude Code mods from Thalix, packaged as one plugin marketplace, with cmods, a small Rust CLI that installs, lists and upgrades them. Each mod is a hook-module plugin: a register(on) module that hooks engine events and draws into Claude Code as a band above the prompt, a side pane or a slash command.
| Mod | What it does | Command | Changes behaviour | |||||||
|---|---|---|---|---|---|---|---|---|---|---|
| prompt-enhancer | An Enhance button above the prompt. It rewrites your rough prompt into a specific one, using the project's instructions, your installed skills and the recent conversation, and puts it back in the box to edit. Then Send sends it. | `/enhance <text> \ | send \ | undo \ | status \ | hide \ | show` | Yes: it calls a model (Sonnet) to rewrite the prompt | ||
| design-picker | When Claude asks you to choose a design direction, it opens a pane that draws each option as an SVG mockup with a Choose button, and sends your pick as the next prompt. | `/design-picker show \ | toggle \ | on \ | off \ | status` | It adds a short system-prompt section steering Claude to the picker; /design-picker off removes it | |||
| smart-model-router | Routes subagents to Sonnet or Haiku under rules you set, and logs every routing decision. Off until you turn it on. | `/router on \ | off \ | sonnet \ | haiku \ | both \ | status \ | log \ | clear` | Yes: it changes which model subagents run on |
Requirements: Claude Code 2.1.285 or newer, and design-picker needs 2.1.286 or newer. cmods warns if the claude on your PATH is older.
cmodsmacOS / Linux (prebuilt binary):
curl -fsSL https://raw.githubusercontent.com/thalixinc/claude-mods-public/main/install.sh | sh
This installs to ~/.local/bin. Set CMODS_INSTALL_DIR to choose another folder, or CMODS_VERSION=v0.1.0 to pin a release. The script checks the archive's SHA-256 before installing.
With Rust (any platform):
cargo install --locked --git https://github.com/thalixinc/claude-mods-public claude-mods-public
Windows: download cmods-x86_64-pc-windows-msvc.zip from Releases and put cmods.exe on your PATH, or use the cargo command above.
cmods install
With no names it opens a menu. Or name the mods you want, or take all three:
cmods install prompt-enhancer design-picker
cmods install all
Mods install at user scope by default, so they load in every project. Pass --project to share them through .claude/settings.json, or --local for just you in this folder. all asks before including the mods that change behaviour, and --yes skips that question.
Start a new Claude Code session (or restart the app) to load them.
Inside Claude Code:
/plugin marketplace add thalixinc/claude-mods-public
/plugin install prompt-enhancer@claude-mods-public
| Command | What it does | |
|---|---|---|
cmods list | Every mod: the available version, the installed version and its scope | |
cmods update | Upgrades cmods itself, then every installed mod that has a newer version (--check to only report) | |
| `cmods uninstall [names\ | all]` | Removes mods (--keep-data keeps their saved data) |
cmods doctor | Checks that claude, git and the marketplace are set up | |
cmods version | Shows the versions of cmods, claude and the marketplace |
If you also have these mods from the private claude-mods marketplace, cmods install warns you. Two copies of the same mod would both hook the same events.
plugins/<name>/.claude-plugin/plugin.json, mirrored in .claude-plugin/marketplace.json. Claude Code only offers an update when that version goes up.cli/Cargo.toml and released as vX.Y.Z tags.cmods check) fails a change that touches a mod or the CLI without bumping its version, or when the two manifests disagree.plugins/<name>/.claude-plugin/plugin.json plus its marketplace.json entry for a mod, and cli/Cargo.toml for the CLI (then run cargo build so Cargo.lock follows).CHANGELOG.md and merge to main. Mod updates reach people as soon as they're on main.git tag v0.1.0 && git push origin v0.1.0
The release workflow builds macOS (arm64, x86_64), Linux (x86_64, arm64, static musl) and Windows (x86_64) binaries, and publishes them with SHA-256 files to a GitHub Release.
Load a mod live from this checkout. Installed copies are cached and don't hot-reload.
claude --plugin-dir plugins/prompt-enhancer
Check before a PR:
claude plugin validate . && claude plugin test plugins/prompt-enhancer
cd cli && cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
MIT
hooks/register.tsx 369 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DesignOption, DesignRequest } from '../types'
5import { PAL, cleanSvg, heroSvg, missingSvg } from './art'
6
7const PANE = 'design-picker'
8const TITLE = 'Design picker'
9const TOOL = 'show_options'
10const TOOL_FULL = 'mcp__design-picker__show_options'
11const SECTION_ID = 'design-picker:directions'
12const USAGE = 'Usage: /design-picker [show | toggle | on | off | status]'
13
14const MIN_OPTIONS = 2
15const MAX_OPTIONS = 6
16
17const request = atom({ plugin: 'design-picker', key: 'request' } as const, null as DesignRequest | null)
18
19// Whether Claude is steered to the picker for design choices, kept across sessions; on by
20// default. Read into a module variable at session start, since the prompt is composed often.
21const ENABLED_KEY = 'enabled'
22let isOn = true
23
24// One AskUserQuestion call per turn is sent back to be drawn; a second one goes through,
25// so a question the picker cannot serve is never blocked.
26let hasRedirected = false
27
28// What is typed in the note field. Kept here and not in state: a state write redraws the
29// pane, and a redraw on every keystroke can put back an older text over what was typed.
30let noteDraft = ''
31
32// ---------------------------------------------------------------- what the model reads
33
34const sectionText = () =>
35 [
36 '# Design directions',
37 `When you need the user to choose between visual design directions (page or screen layouts, visual styles, colour palettes, typography, the look of a component), call the ${TOOL_FULL} tool instead of describing the options in text or calling AskUserQuestion.`,
38 'Draw each option as its own SVG mockup showing what that direction would look like, then end your turn: the user picks in a pane and the pick arrives as their next message.',
39 'Use it only for choices that have a look. Ask every other question the usual way.',
40 ].join(' ')
41
42const TOOL_DESCRIPTION =
43 'Ask the user to choose a visual design direction by SHOWING the options: each one is drawn in a side pane as the SVG mockup you give, with a Choose button. ' +
44 'Use it whenever you would otherwise describe design options in words (layouts, visual styles, palettes, typography, component looks). ' +
45 'Each svg is a complete, self-contained <svg> document with a viewBox (for example 0 0 640 400) and a full background rect, showing that direction applied to the real screen or component under discussion: ' +
46 'the same content in every option, so they differ only in the direction. No scripts, external images or web fonts; keep each under 20,000 characters. ' +
47 'After calling it, end your turn without repeating the options: the pick arrives as the next user message.'
48
49const redirectText = () =>
50 `This looks like a choice between design directions, and the user has the Design picker installed so they can see the options instead of reading about them. ` +
51 `Call ${TOOL_FULL} with the same question and an SVG mockup for each option. If this question has nothing to draw, call AskUserQuestion again and it will go through.`
52
53// A question about how something looks. A bare "design" is left out: an API or a schema
54// has a design too.
55const VISUAL =
56 /\b(visual|visuals|layout|layouts|aesthetic|palette|palettes|colou?rs?|colou?r scheme|theme|themes|typograph\w*|fonts?|mock-?ups?|wireframes?|look and feel|look|styling|ui|design (?:direction|style|language|system)|(?:visual|ui|page|screen|web|app|brand|logo) design)\b/i
57
58export function isDesignQuestion(questions: unknown): boolean {
59 if (!Array.isArray(questions)) return false
60 return questions.some(q => {
61 const item = q as { question?: unknown; header?: unknown; options?: unknown; kind?: unknown } | null
62 if (!item || (item.kind !== undefined && item.kind !== 'choice')) return false
63 if (!Array.isArray(item.options) || item.options.length < MIN_OPTIONS) return false
64 return VISUAL.test(`${String(item.header ?? '')} ${String(item.question ?? '')}`)
65 })
66}
67
68// ---------------------------------------------------------------- the request
69
70const oneLine = (value: unknown, max: number) =>
71 (typeof value === 'string' ? value : '').replace(/\s+/g, ' ').trim().slice(0, max)
72
73// The tool's input as a request, with what was wrong with any mockup; or why it is refused.
74export function parseRequest(input: unknown, now: number): { request: DesignRequest; problems: string[] } | { error: string } {
75 const raw = (input ?? {}) as { question?: unknown; options?: unknown; recommended?: unknown }
76 const question = oneLine(raw.question, 300)
77 if (question === '') return { error: 'question is required: say what the user is choosing.' }
78 if (!Array.isArray(raw.options) || raw.options.length < MIN_OPTIONS || raw.options.length > MAX_OPTIONS) {
79 return { error: `options must hold ${MIN_OPTIONS} to ${MAX_OPTIONS} design directions.` }
80 }
81
82 const options: DesignOption[] = []
83 const problems: string[] = []
84 for (const [i, item] of raw.options.entries()) {
85 const o = (item ?? {}) as { label?: unknown; description?: unknown; svg?: unknown }
86 const label = oneLine(o.label, 60)
87 if (label === '') return { error: `option ${i + 1} has no label.` }
88 if (options.some(x => x.label.toLowerCase() === label.toLowerCase())) return { error: `two options are both labelled "${label}".` }
89 const { svg, problem } = cleanSvg(o.svg)
90 if (problem) problems.push(`"${label}": ${problem}`)
91 options.push({ label, description: oneLine(o.description, 400), svg })
92 }
93 if (options.every(o => o.svg === '')) {
94 return { error: `no option has a mockup that can be drawn (${problems.join('; ')}). Give each option an svg: a complete <svg>…</svg> document.` }
95 }
96
97 const wanted = oneLine(raw.recommended, 60).toLowerCase()
98 const recommended = options.find(o => o.label.toLowerCase() === wanted)?.label ?? ''
99 return { request: { question, options, recommended, picked: '', askedAt: now }, problems }
100}
101
102// The prompt a pick sends.
103export function formatPick(req: DesignRequest, label: string, note: string) {
104 const n = req.options.findIndex(o => o.label === label) + 1
105 const extra = note.trim() === '' ? '' : ` Note: ${note.trim()}`
106 return `Design direction for "${req.question}": I pick "${label}" (option ${n} of ${req.options.length}).${extra}`
107}
108
109export function formatNone(req: DesignRequest, note: string) {
110 const extra = note.trim() === '' ? '' : ` Note: ${note.trim()}`
111 return `Design direction for "${req.question}": none of these options fit. Show me different directions.${extra}`
112}
113
114async function openPicker($: EngineInterface, isAsked: boolean) {
115 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: isAsked ? true : undefined })
116 if (!opened.isPlaced && !isAsked) $.ui.toast('design-picker: design options waiting — run /design-picker to see them')
117}
118
119// Sends the pick as the person's next prompt and closes the pane.
120async function send($: EngineInterface, text: string, picked: string) {
121 await $.ui.close({ id: PANE })
122 try {
123 await $.prompt.submit({ text, asUser: true })
124 } catch (error) {
125 $.ui.toast(`design-picker: could not send the pick (${error instanceof Error ? error.message : String(error)})`)
126 return
127 }
128 noteDraft = ''
129 await update($, request, r => (r ? { ...r, picked } : r))
130}
131
132export const register: Register = on => {
133 on('session.start', async ($, e, next) => {
134 isOn = (await $.store.get(ENABLED_KEY)) !== false
135 await $.command.register({
136 name: 'design-picker',
137 description: 'Pick a design direction from drawn options',
138 argumentHint: '[show|toggle|on|off|status]',
139 })
140 await $.tool.register({
141 name: TOOL,
142 description: TOOL_DESCRIPTION,
143 isDeferred: false,
144 inputSchema: {
145 type: 'object',
146 properties: {
147 question: { type: 'string', description: 'What the user is choosing, as one question' },
148 options: {
149 type: 'array',
150 minItems: MIN_OPTIONS,
151 maxItems: MAX_OPTIONS,
152 description: 'The design directions to choose between',
153 items: {
154 type: 'object',
155 properties: {
156 label: { type: 'string', description: 'Short name of the direction (1-5 words)' },
157 description: { type: 'string', description: 'One or two sentences on what choosing it means' },
158 svg: { type: 'string', description: 'The mockup: a complete, self-contained <svg>…</svg> document with a viewBox' },
159 },
160 required: ['label', 'svg'],
161 },
162 },
163 recommended: { type: 'string', description: 'Optional: the label of the option you recommend' },
164 },
165 required: ['question', 'options'],
166 },
167 })
168 return next(e)
169 })
170
171 on('turn.start', ($, e, next) => {
172 hasRedirected = false
173 return next(e)
174 })
175
176 // The instructions are added only where a surface can draw the mockups.
177 on('prompt.compose', async ($, e, next) => {
178 const composed = await next(e)
179 if (!isOn || !e.surfaces.some(s => s !== 'terminal')) return composed
180 return { ...composed, sections: [...composed.sections, { id: SECTION_ID, text: sectionText(), scope: 'session' as const }] }
181 })
182
183 on('tool.call', { tool: TOOL_FULL }, async ($, e) => {
184 const parsed = parseRequest(e, await $.clock.now())
185 if ('error' in parsed) return { deny: `design-picker: ${parsed.error}` }
186
187 noteDraft = ''
188 await update($, request, () => parsed.request)
189 await openPicker($, false)
190
191 const count = parsed.request.options.length
192 const skipped = parsed.problems.length === 0 ? '' : ` Not drawn: ${parsed.problems.join('; ')}.`
193 return {
194 result: `Showing ${count} options in the Design picker pane.${skipped} End your turn now without repeating the options: the user's pick arrives as their next message.`,
195 }
196 }).catch(() => ({ deny: 'design-picker: could not show the options; describe them in text instead.' }))
197
198 // A design question on its way to the plain dialog is sent back once, to be drawn.
199 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
200 if (!isOn || hasRedirected || e.agentId !== undefined || !isDesignQuestion(e.questions)) return next(e)
201 if (!(await $.session.surfaces()).some(s => s !== 'terminal')) return next(e)
202 hasRedirected = true
203 return { deny: redirectText() }
204 }).catch(($, e, next) => next(e))
205
206 on('command.run', { command: 'design-picker' }, async ($, e) => {
207 const sub = ((e.args ?? '').trim().split(/\s+/)[0] || 'show').toLowerCase()
208
209 if (sub === 'on' || sub === 'off') {
210 isOn = sub === 'on'
211 await $.store.set(ENABLED_KEY, isOn)
212 return {
213 text: isOn
214 ? 'Design picker on: Claude is asked to draw design options here instead of describing them.'
215 : `Design picker off: Claude asks about design the usual way. The ${TOOL} tool still works when it is called.`,
216 }
217 }
218
219 if (sub === 'status') {
220 const req = await read($, request)
221 const shown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
222 return {
223 text: [
224 `Design picker: ${isOn ? 'on' : 'off'}${shown ? ' · pane open' : ''}`,
225 req === null
226 ? 'Options: none shown yet'
227 : `Options: ${req.options.length} for "${req.question}" (${req.options.filter(o => o.svg !== '').length} drawn)` +
228 (req.picked === '' ? ', waiting for a pick' : `, picked "${req.picked}"`),
229 ].join('\n'),
230 }
231 }
232
233 if (sub === 'toggle' && (await $.ui.panes()).some(pane => pane.id === PANE)) {
234 await $.ui.close({ id: PANE })
235 return { text: 'Design picker closed.' }
236 }
237
238 if (sub !== 'show' && sub !== 'toggle') return { text: USAGE }
239
240 await openPicker($, true)
241 const req = await read($, request)
242 return { text: req === null ? 'Design picker opened. No design options have been shown yet.' : `Design picker opened (${req.options.length} options).` }
243 })
244
245 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246 const { Box, Text, Button } = $.ui.resolve(e)
247 const req = await read($, request)
248 const none = 'No design options waiting. They appear here when Claude asks you to choose a design direction.'
249
250 const choose = (label: string) => {
251 if (req) void send($, formatPick(req, label, noteDraft), label)
252 }
253 const chooseLabel = (o: DesignOption) => (req?.picked === o.label ? `Picked: ${o.label}` : `Choose ${o.label}`)
254 const actions = (
255 <Box flexDirection="row" gap={2}>
256 <Button key="none" onPress={() => void (req && send($, formatNone(req, noteDraft), ''))}>
257 None of these
258 </Button>
259 <Button key="dismiss" role="dismiss" dimColor onPress={() => void $.ui.close({ id: PANE })}>
260 Dismiss
261 </Button>
262 </Box>
263 )
264
265 // ---------------- terminal: no drawings there, so the options are a list
266 if (e.surface === 'terminal') {
267 if (req === null) {
268 return (
269 <Box flexDirection="column" paddingX={1}>
270 <Text dimColor wrap="wrap">{none}</Text>
271 </Box>
272 )
273 }
274 return (
275 <Box flexDirection="column" gap={1} paddingX={1}>
276 <Text bold color="magenta" wrap="wrap">{`◇ ${req.question}`}</Text>
277 <Text dimColor wrap="wrap">The mockups draw in the desktop app; the terminal lists the options.</Text>
278 {req.options.map((o, i) => (
279 <Box key={`row${i}`} flexDirection="column">
280 <Text bold wrap="wrap">
281 {`${i + 1}. ${o.label}`}
282 {req.recommended === o.label ? <Text color="yellow"> ★ recommended</Text> : ''}
283 </Text>
284 {o.description !== '' && <Text wrap="wrap">{o.description}</Text>}
285 <Button key={`pick${i + 1}`} hotkey={String(i + 1)} onPress={() => choose(o.label)}>
286 {chooseLabel(o)}
287 </Button>
288 </Box>
289 ))}
290 {actions}
291 </Box>
292 )
293 }
294
295 // ---------------- desktop and other drawn surfaces: one container holding every option
296 const { Svg } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
297 const px = Math.max(300, Math.round((e.props.bodyColumns || 60) * 7.8) - 30)
298 const hero = heroSvg({ count: req?.options.length ?? 0, picked: req?.picked ?? '', width: px })
299 const container = { flexDirection: 'column', backgroundColor: PAL.panel, borderStyle: 'round', borderColor: PAL.frame, paddingX: 1, paddingY: 1, gap: 1 } as const
300
301 if (req === null) {
302 return (
303 <Box {...container}>
304 <Svg source={hero.source} alt="Design picker: no options waiting" />
305 <Text color={PAL.mute} wrap="wrap">{none}</Text>
306 </Box>
307 )
308 }
309
310 // The buttons and the note field are the app's own controls, drawn dark on light, so
311 // they sit on a light plate inside the dark container.
312 const plate = { flexDirection: 'column', backgroundColor: PAL.plate, borderStyle: 'round', borderColor: PAL.plateEdge, paddingX: 1 } as const
313
314 // The optional note sent with the pick ("this one, but with a darker header"). The
315 // mobile surface draws no text field, so it is left out there.
316 const { Input } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
317 const note = e.surface === 'mobile' ? null : (
318 <Input
319 key="note"
320 label="› "
321 placeholder="Optional note to send with your pick"
322 value={noteDraft}
323 onInput={value => {
324 noteDraft = value
325 }}
326 onSubmit={value => {
327 noteDraft = value
328 }}
329 />
330 )
331
332 return (
333 <Box {...container}>
334 <Svg source={hero.source} alt={`Design picker: ${req.options.length} options`} />
335 <Text bold color={PAL.text} wrap="wrap">{req.question}</Text>
336
337 {req.options.map((o, i) => {
338 const isRecommended = req.recommended === o.label
339 const isPicked = req.picked === o.label
340 return (
341 <Box key={`option${i + 1}`} flexDirection="column" gap={1} borderStyle="round" borderColor={isPicked ? PAL.green : isRecommended ? PAL.amber : PAL.frame} backgroundColor={PAL.card} paddingX={1} paddingY={1}>
342 <Text bold color={PAL.text} wrap="wrap">
343 {`${i + 1}. ${o.label}`}
344 {isPicked ? <Text color={PAL.green}>{' ✓ picked'}</Text> : isRecommended ? <Text color={PAL.amber}>{' ★ recommended'}</Text> : ''}
345 </Text>
346 <Svg
347 source={o.svg !== '' ? o.svg : missingSvg(px - 30)}
348 alt={`Mockup of option ${i + 1}, ${o.label}${o.description !== '' ? `: ${o.description}` : ''}`}
349 width={px - 30}
350 />
351 {o.description !== '' && <Text color={PAL.mute} wrap="wrap">{o.description}</Text>}
352 <Box {...plate}>
353 <Button key={`pick${i + 1}`} variant={isRecommended || isPicked ? 'primary' : undefined} onPress={() => choose(o.label)}>
354 {chooseLabel(o)}
355 </Button>
356 </Box>
357 </Box>
358 )
359 })}
360
361 <Box {...plate} gap={1}>
362 {note}
363 {actions}
364 </Box>
365 </Box>
366 )
367 })
368}
369hooks/art.ts 95 lines1// Pure drawing and text helpers for the Design picker pane: no `$`, no imports. The
2// palette and the title band are the ones the other panes (Answer sheet, Output Tray) draw.
3
4export const PAL = {
5 panel: '#0b0d1c',
6 card: '#12152b',
7 band: '#171b38',
8 raise: '#1d2246',
9 frame: '#2a2f5a',
10 dim: '#6f76a6',
11 mute: '#9ba1c9',
12 text: '#e8eaf7',
13 green: '#3ddc97',
14 amber: '#f5b041',
15 violet: '#a58cf2',
16 cyan: '#4fd6e8',
17 // The light plate the buttons and the note field sit on: the app draws those controls
18 // in its own dark-on-light, so they need a light ground.
19 plate: '#f4f5fc',
20 plateEdge: '#c9cdea',
21}
22
23const FONT = `font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"`
24
25export const esc = (s: string) =>
26 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
27
28// ---------------------------------------------------------------- the model's mockups
29
30// The longest mockup kept, in characters: well under what one Svg element takes, so
31// several options fit in the session's state.
32export const MAX_SVG = 60_000
33
34// The model's markup made ready to draw: the code fence, XML prolog and doctype taken
35// off, the namespace and a viewBox added when missing. `problem` says why it cannot be drawn.
36export function cleanSvg(raw: unknown): { svg: string; problem?: string } {
37 if (typeof raw !== 'string' || raw.trim() === '') return { svg: '', problem: 'no svg given' }
38 let s = raw.trim()
39 const fenced = /^```[a-z]*\s*\n([\s\S]*?)\n?```$/i.exec(s)
40 if (fenced) s = (fenced[1] ?? '').trim()
41 s = s.replace(/^<\?xml[\s\S]*?\?>\s*/i, '').replace(/^<!DOCTYPE[\s\S]*?>\s*/i, '')
42 if (!/^<svg[\s>]/i.test(s) || !/<\/svg>\s*$/i.test(s)) return { svg: '', problem: 'not an <svg>…</svg> document' }
43 if (s.length > MAX_SVG) return { svg: '', problem: `svg is ${s.length} characters, over the ${MAX_SVG} limit` }
44
45 const end = s.indexOf('>')
46 let tag = s.slice(0, end)
47 if (!/\sxmlns=/.test(tag)) tag = tag.replace(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"')
48 // Without a viewBox the drawing cannot scale to the pane's width.
49 if (!/\sviewBox=/i.test(tag)) {
50 const w = /\swidth="(\d+(?:\.\d+)?)(?:px)?"/i.exec(tag)?.[1]
51 const h = /\sheight="(\d+(?:\.\d+)?)(?:px)?"/i.exec(tag)?.[1]
52 if (w && h) tag += ` viewBox="0 0 ${w} ${h}"`
53 }
54 return { svg: tag + s.slice(end) }
55}
56
57// ---------------------------------------------------------------- title band
58
59export type HeroInput = { count: number; picked: string; width: number }
60
61// The title band: the mod's mark, its name, and how many options wait (or the pick).
62export const heroSvg = ({ count, picked, width }: HeroInput) => {
63 const W = Math.max(300, Math.round(width))
64 const H = 46
65 const PAD = 16
66 const accent = picked !== '' ? PAL.green : PAL.violet
67 const p: string[] = []
68 p.push(`<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="7" fill="${PAL.band}" stroke="${PAL.frame}"/>`)
69 // Three swatches: the mark.
70 ;[PAL.violet, PAL.cyan, PAL.amber].forEach((c, i) => {
71 p.push(`<rect x="${PAD + i * 9}" y="${13 + i * 2}" width="14" height="18" rx="3" fill="${c}" fill-opacity="0.85" stroke="${PAL.panel}"/>`)
72 })
73 p.push(`<text x="${PAD + 44}" y="28.5" fill="${PAL.text}" font-size="15" font-weight="700" letter-spacing="1.6" ${FONT}>DESIGN PICKER</text>`)
74
75 const pill = picked !== '' ? 'PICKED' : count === 0 ? 'IDLE' : `${count} OPTIONS`
76 const pw = pill.length * 8 + 34
77 const sx = W - PAD - pw
78 p.push(`<rect x="${sx}" y="13" width="${pw}" height="20" rx="10" fill="${accent}" fill-opacity="0.14" stroke="${accent}" stroke-opacity="0.5"/>`)
79 p.push(`<circle cx="${sx + 13}" cy="23" r="4" fill="${accent}"/>`)
80 p.push(`<text x="${sx + 23}" y="27.5" fill="${accent}" font-size="11" font-weight="700" letter-spacing="1.4" ${FONT}>${esc(pill)}</text>`)
81 return { height: H, source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${p.join('')}</svg>` }
82}
83
84// What an option with no drawable mockup shows in its place.
85export const missingSvg = (width: number) => {
86 const W = Math.max(260, Math.round(width))
87 const H = 70
88 return (
89 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
90 `<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="6" fill="${PAL.card}" stroke="${PAL.frame}" stroke-dasharray="5 5"/>` +
91 `<text x="${W / 2}" y="${H / 2 + 4}" text-anchor="middle" fill="${PAL.mute}" font-size="12" ${FONT}>no mockup was drawn for this option</text>` +
92 `</svg>`
93 )
94}
95types/index.d.ts 29 lines1export type DesignOption = {
2 /** The option's short name, which the pick is sent by. */
3 label: string
4 /** One or two sentences on what choosing it means, or ''. */
5 description: string
6 /** The mockup as an SVG document, or '' when the model gave none that can be drawn. */
7 svg: string
8}
9
10export type DesignRequest = {
11 /** What is being decided, as the model asked it. */
12 question: string
13 options: DesignOption[]
14 /** The label the model recommends, or ''. */
15 recommended: string
16 /** The label the person chose, or '' while the request waits. */
17 picked: string
18 askedAt: number
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'design-picker': {
24 /** The design options last shown; null before the first request and after a dismiss. */
25 request: DesignRequest | null
26 }
27 }
28}
29