SLOPSHOPPER

design-picker

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

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · design-picker
│ ┃ Design picker ✕ › fix the failing auth test and add an audit log call │ ┃ No design options waiting. They appear │ ┃ here when Claude asks you to choose a ⏺ Read(src/auth.ts) │ ┃ design direction. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /design-picker │ ⎿ design-picker: Design picker opened. No design options have been │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Design picker
No design options waiting. They appear here when Claude asks you to choose a design direction.
README

claude-mods-public

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.

The mods

ModWhat it doesCommandChanges behaviour
prompt-enhancerAn 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-pickerWhen 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-routerRoutes 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.

Install

1. Get cmods

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

2. Install the mods

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.

Without the CLI

Inside Claude Code:

/plugin marketplace add thalixinc/claude-mods-public
/plugin install prompt-enhancer@claude-mods-public

Everyday use

CommandWhat it does
cmods listEvery mod: the available version, the installed version and its scope
cmods updateUpgrades 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 doctorChecks that claude, git and the marketplace are set up
cmods versionShows 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.

Versioning

  • Each mod has its own version, in plugins/<name>/.claude-plugin/plugin.json, mirrored in .claude-plugin/marketplace.json. Claude Code only offers an update when that version goes up.
  • The CLI is versioned in cli/Cargo.toml and released as vX.Y.Z tags.
  • CI (cmods check) fails a change that touches a mod or the CLI without bumping its version, or when the two manifests disagree.

Releasing

  1. Bump the version: 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).
  2. Add a section to CHANGELOG.md and merge to main. Mod updates reach people as soon as they're on main.
  3. For a CLI release, tag it and push the tag:
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.

Developing a mod

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

License

MIT

Source 3 files
hooks/register.tsx 369 lines
1import { 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}
369
hooks/art.ts 95 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
95
types/index.d.ts 29 lines
1export 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