CodeSnap-style Polaroid snapshots of your Claude Code prompts: /polaroid

CodeSnap-style Polaroid snapshots of your Claude Code prompts, ready to share.

| Command | What it snaps |
|---|---|
/polaroid or /polaroid last | your last prompt |
/polaroid first | the first prompt of the session |
/polaroid 3 | your 3rd-last prompt |
/polaroid "any text" | whatever you type |
A Prompt Polaroid pane opens with a preview (desktop / VS Code show it live; the terminal shows it after saving). Buttons (with hotkeys while the pane has focus):
p / n): change the background (Claude Clay, Sunset, Ocean, Forest, Mono, Transparent)s): writes polaroid_<date>.png (2x, via headless Edge/Chrome) and .svgc): puts the PNG on your clipboard, so you can paste it straight into Slack/X/LinkedIno)In the config menu (or pluginConfigs["prompt-polaroid"].options in settings.json):
| Option | Default |
|---|---|
theme | clay |
outputDir | ~/Pictures/Claude Polaroids |
handle | (empty): e.g. @you, written on the caption |
showModel | true |
tilt | true |
redactSecrets | true: masks API keys, tokens, password=… values and e-mail addresses |
osascript), Linux (xclip).claude plugin validate .
claude plugin test .
claude plugin marketplace add Roberdinho/claude-mods
claude plugin install prompt-polaroid@rwoel-modshooks/register.tsx 229 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Saved, Snap } from '../types'
5import { BROWSERS, copyImageArgv, pageHtml, revealArgv, screenshotArgv, slashes } from './host'
6import type { Platform } from './host'
7import { choosePrompt, cleanPrompt, isCustomText, redact, renderSvg } from './polaroid'
8import { THEMES, themeAt, themeIndex } from './themes'
9
10const PANE = 'prompt-polaroid'
11const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
12
13const snapAtom = atom({ plugin: 'prompt-polaroid', key: 'snap' } as const, null)
14const themeAtom = atom({ plugin: 'prompt-polaroid', key: 'theme' } as const, 0)
15const savedAtom = atom({ plugin: 'prompt-polaroid', key: 'saved' } as const, null)
16
17type Settings = {
18 handle: string
19 showModel: boolean
20 tilt: boolean
21 isRedacting: boolean
22 outputDir: string
23}
24
25const pad = (n: number): string => String(n).padStart(2, '0')
26
27
28const isOwnWords = (text: string): boolean =>
29 text !== '' && !text.startsWith('/') && !text.startsWith('<command-') && !text.startsWith('<local-command-')
30
31async function platform($: EngineInterface): Promise<Platform> {
32 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
33 const home = (await $.env.get('HOME')) ?? ''
34
35 return home.startsWith('/Users/') ? 'mac' : 'linux'
36}
37
38async function expandHome($: EngineInterface, path: string): Promise<string> {
39 if (path !== '~' && !path.startsWith('~/') && !path.startsWith('~\\')) return slashes(path)
40 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
41
42 return `${slashes(home)}${slashes(path.slice(1))}`
43}
44
45async function tempDir($: EngineInterface): Promise<string> {
46 const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
47
48 return slashes(temp).replace(/\/$/, '')
49}
50
51/** The prompts the person typed this session, oldest first, cleaned. */
52async function listPrompts($: EngineInterface): Promise<string[]> {
53 const messages = await $.session.messages()
54 if ('deny' in messages) return []
55
56 return messages
57 .filter(message => message.role === 'user')
58 .map(message => cleanPrompt(message.text))
59 .filter(isOwnWords)
60}
61
62async function snapOf($: EngineInterface, text: string): Promise<Snap> {
63 const now = new Date(await $.clock.now())
64 const root = await $.session.root().catch(() => '')
65 const project = slashes(root).replace(/\/$/, '').split('/').at(-1) ?? ''
66
67 return {
68 text,
69 project: project === '' ? 'session' : project,
70 model: await $.session.model().catch(() => ''),
71 date: `${now.getDate()} ${MONTHS[now.getMonth()]} ${now.getFullYear()}`,
72 stamp: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`,
73 }
74}
75
76async function svgOf($: EngineInterface, snap: Snap, settings: Settings): Promise<string> {
77 const theme = themeAt(await read($, themeAtom))
78
79 return renderSvg(snap, { theme, handle: settings.handle, showModel: settings.showModel, tilt: settings.tilt })
80}
81
82/** Writes the SVG, then develops it into a PNG with a headless Chromium. */
83async function rasterize($: EngineInterface, svg: string, png: string): Promise<string | undefined> {
84 let browser: string | undefined
85 for (const candidate of BROWSERS[await platform($)]) {
86 if (browser === undefined && (await $.fs.exists(candidate))) browser = candidate
87 }
88 if (browser === undefined) return 'no Chrome, Edge or Chromium found'
89
90 const work = `${await tempDir($)}/prompt-polaroid`
91 const page = `${work}/snap.html`
92 await $.fs.write(page, pageHtml(svg))
93 // Chromium's helper processes keep its pipes open after the screenshot, so
94 // waiting for it to exit hangs: leave the stream (which ends it) once the
95 // file is reported written.
96 let log = ''
97 try {
98 const browserRun = $.process.spawn({ argv: screenshotArgv(browser, page, png, `${work}/profile`) })
99 for await (const { text } of browserRun) {
100 log += text
101 if (log.includes('bytes written to file')) break
102 }
103 } catch (error) {
104 log += String(error)
105 }
106
107 return (await $.fs.exists(png)) ? undefined : `browser said: ${log.trim().slice(-160)}`
108}
109
110async function save($: EngineInterface, settings: Settings): Promise<void> {
111 const snap = await read($, snapAtom)
112 if (snap === null) return
113 const folder = (await expandHome($, settings.outputDir)).replace(/\/$/, '')
114 const base = `${folder}/polaroid_${snap.stamp}`
115 const svg = await svgOf($, snap, settings)
116 // The SVG write also creates the folder the screenshot lands in.
117 await $.fs.write(`${base}.svg`, svg)
118 $.ui.toast('Developing your Polaroid…')
119 const failure = await rasterize($, svg, `${base}.png`)
120 const previous = await read($, savedAtom)
121 const saved: Saved = {
122 svg: `${base}.svg`,
123 png: failure === undefined ? `${base}.png` : undefined,
124 generation: (previous?.generation ?? 0) + 1,
125 }
126 await update($, savedAtom, () => saved)
127 $.ui.toast(failure === undefined ? `Saved → ${base}.png` : `Saved SVG only (${failure}) → ${base}.svg`)
128}
129
130async function copyImage($: EngineInterface, png: string): Promise<void> {
131 const run = await $.process.run(copyImageArgv(await platform($), png), { timeoutMs: 20_000 }).catch(() => undefined)
132 $.ui.toast(run?.exitCode === 0 ? 'Polaroid copied to the clipboard' : 'Could not copy the image')
133}
134
135async function revealFolder($: EngineInterface, settings: Settings): Promise<void> {
136 const folder = await expandHome($, settings.outputDir)
137 // explorer.exe exits 1 even when it opened the folder: nothing to check.
138 await $.process.run(revealArgv(await platform($), folder), { timeoutMs: 10_000 }).catch(() => undefined)
139}
140
141async function cycleTheme($: EngineInterface, step: number): Promise<void> {
142 await update($, themeAtom, index => (index + step + THEMES.length) % THEMES.length)
143}
144
145export const register: Register = (on, options) => {
146 const settings: Settings = {
147 handle: String(options.handle ?? ''),
148 showModel: options.showModel !== false,
149 tilt: options.tilt !== false,
150 isRedacting: options.redactSecrets !== false,
151 outputDir: String(options.outputDir ?? '~/Pictures/Claude Polaroids'),
152 }
153
154 on('session.start', async ($, e, next) => {
155 await $.command.register({
156 name: 'polaroid',
157 description: 'Snap a prompt as a Polaroid: /polaroid [last | first | n | "text"]',
158 argumentHint: '[last | first | n | "text"]',
159 })
160 await update($, themeAtom, () => themeIndex(options.theme))
161
162 return next(e)
163 })
164
165 on('command.run', { command: 'polaroid' }, async ($, e) => {
166 const args = e.args.trim()
167 const prompts = isCustomText(args) ? [] : await listPrompts($)
168 const picked = choosePrompt(prompts, args)
169 if ('error' in picked) return { text: picked.error }
170 const snap = await snapOf($, settings.isRedacting ? redact(picked.text) : picked.text)
171 await update($, snapAtom, () => snap)
172 await update($, savedAtom, () => null)
173 await $.ui.open({ id: PANE, title: 'Prompt Polaroid', focus: true })
174
175 return { text: 'Polaroid ready: save it, copy it, or flip through backgrounds in the pane.' }
176 })
177
178 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179 const ui = $.ui.resolve(e)
180 const { Box, Button, Text } = ui
181 const snap = await read($, snapAtom)
182 if (snap === null) {
183 return <Text dimColor>Type /polaroid to snap your last prompt.</Text>
184 }
185 const theme = await read($, themeAtom)
186 const saved = await read($, savedAtom)
187 const png = saved?.png
188 const body = e.props.bodyColumns
189
190 let preview
191 if (e.surface !== 'terminal' && 'Svg' in ui) {
192 const width = Math.min(440, Math.max(240, body * 7))
193 preview = <ui.Svg source={await svgOf($, snap, settings)} alt={`Polaroid of the prompt: ${snap.text}`} width={width} />
194 } else if (e.surface === 'terminal' && 'Image' in ui && png !== undefined) {
195 const columns = Math.max(20, Math.min(60, body - 2))
196 preview = (
197 <ui.Image
198 source={{ file: png, format: 'png', generation: saved?.generation }}
199 columns={columns}
200 rows={Math.round(columns * 0.6)}
201 alt={snap.text}
202 />
203 )
204 } else {
205 const shown = snap.text.length > 400 ? `${snap.text.slice(0, 400)}…` : snap.text
206 preview = <Text>{`> ${shown}
207
208(Save to develop the photo and preview it here.)`}</Text>
209 }
210
211 return (
212 <Box flexDirection="column" gap={1}>
213 {preview}
214 <Text dimColor>{`${snap.date} · ${snap.project} · background: ${themeAt(theme).label}`}</Text>
215 <Box flexDirection="row" gap={1} flexWrap="wrap">
216 <Button key="prev" label="◀" hotkey="p" onPress={() => cycleTheme($, -1)} />
217 <Button key="next" label="▶" hotkey="n" onPress={() => cycleTheme($, 1)} />
218 <Button key="save" label="Save" hotkey="s" variant="primary" onPress={() => save($, settings)} />
219 {png !== undefined && <Button key="copy" label="Copy image" hotkey="c" onPress={() => copyImage($, png)} />}
220 {saved !== null && (
221 <Button key="folder" label="Open folder" hotkey="o" dimColor onPress={() => revealFolder($, settings)} />
222 )}
223 </Box>
224 {saved !== null && <Text dimColor>{png ?? saved.svg}</Text>}
225 </Box>
226 )
227 })
228}
229hooks/host.ts 80 lines1import { HEIGHT, WIDTH } from './polaroid'
2
3// Pure helpers for the host side: which programs to run and with what. The
4// hooks module runs them, since only it holds `$`.
5
6export type Platform = 'windows' | 'mac' | 'linux'
7
8export const slashes = (path: string): string => path.replaceAll('\\', '/')
9
10export const BROWSERS: Record<Platform, readonly string[]> = {
11 windows: [
12 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
13 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
14 'C:/Program Files/Google/Chrome/Application/chrome.exe',
15 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
16 ],
17 mac: [
18 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
19 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
20 '/Applications/Chromium.app/Contents/MacOS/Chromium',
21 ],
22 linux: [
23 '/usr/bin/google-chrome',
24 '/usr/bin/chromium',
25 '/usr/bin/chromium-browser',
26 '/usr/bin/microsoft-edge',
27 '/snap/bin/chromium',
28 ],
29}
30
31export const pageHtml = (svg: string): string =>
32 `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;background:transparent}</style></head><body>${svg}</body></html>`
33
34export const fileUrl = (path: string): string =>
35 `file:///${slashes(path).replace(/^\//, '')}`.replaceAll(' ', '%20')
36
37/** A headless Chromium screenshot of the page at 2x, transparent where the SVG is. */
38export const screenshotArgv = (browser: string, page: string, png: string, profile: string): string[] => [
39 browser,
40 '--headless=new',
41 '--disable-gpu',
42 '--hide-scrollbars',
43 '--no-first-run',
44 '--no-default-browser-check',
45 '--disable-extensions',
46 '--disable-component-update',
47 '--disable-background-networking',
48 '--disable-sync',
49 `--user-data-dir=${profile}`,
50 '--default-background-color=00000000',
51 '--force-device-scale-factor=2',
52 `--window-size=${WIDTH},${HEIGHT}`,
53 `--screenshot=${png}`,
54 fileUrl(page),
55]
56
57/** Puts the PNG itself (not its path) on the system clipboard. */
58export const copyImageArgv = (os: Platform, png: string): string[] => {
59 if (os === 'windows') {
60 const path = png.replaceAll('/', '\\').replaceAll("'", "''")
61
62 return [
63 'powershell.exe',
64 '-NoProfile',
65 '-STA',
66 '-Command',
67 `Add-Type -AssemblyName System.Windows.Forms,System.Drawing; [System.Windows.Forms.Clipboard]::SetImage([System.Drawing.Image]::FromFile('${path}'))`,
68 ]
69 }
70 if (os === 'mac') return ['osascript', '-e', `set the clipboard to (read (POSIX file "${png}") as «class PNGf»)`]
71
72 return ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-i', png]
73}
74
75export const revealArgv = (os: Platform, folder: string): string[] => {
76 if (os === 'windows') return ['explorer.exe', folder.replaceAll('/', '\\')]
77
78 return [os === 'mac' ? 'open' : 'xdg-open', folder]
79}
80hooks/polaroid.ts 215 lines1import type { Snap } from '../types'
2import type { Theme } from './themes'
3
4export const WIDTH = 880
5export const HEIGHT = 1060
6
7const MONO = "'Cascadia Code','JetBrains Mono','SF Mono',Consolas,Menlo,monospace"
8const HAND = "'Segoe Print','Bradley Hand','Ink Free','Comic Sans MS',cursive"
9const SANS = "'Segoe UI',-apple-system,'Helvetica Neue',Arial,sans-serif"
10
11// Card and photo geometry; everything else is laid out from these.
12const CARD = { x: 80, y: 70, w: 720, h: 900 }
13const PHOTO = { x: CARD.x + 36, y: CARD.y + 36, w: 648, h: 620 }
14const TITLE_BAR = 44
15const TEXT = {
16 x: PHOTO.x + 28,
17 y: PHOTO.y + TITLE_BAR + 30,
18 w: PHOTO.w - 56,
19 h: PHOTO.h - TITLE_BAR - 30 - 28,
20}
21const FONT_SIZES = [34, 30, 26, 22, 19, 16] as const
22
23export const escapeXml = (text: string): string =>
24 text
25 .replaceAll('&', '&')
26 .replaceAll('<', '<')
27 .replaceAll('>', '>')
28 .replaceAll('"', '"')
29 .replaceAll("'", ''')
30
31const MASK = '••••••'
32const SECRET_PATTERNS: readonly RegExp[] = [
33 /sk-(?:ant-)?[A-Za-z0-9_-]{16,}/g,
34 /gh[pousr]_[A-Za-z0-9]{20,}/g,
35 /github_pat_[A-Za-z0-9_]{20,}/g,
36 /xox[abprs]-[A-Za-z0-9-]{10,}/g,
37 /AKIA[0-9A-Z]{16}/g,
38 /AIza[0-9A-Za-z_-]{30,}/g,
39 /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
40 /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
41]
42const ASSIGNED_SECRET = /\b(password|passwd|pwd|secret|token|api[_-]?key)(\s*[:=]\s*)(\S+)/gi
43
44/** Masks API keys, tokens, JWTs, `password=...` values and e-mail addresses. */
45export const redact = (text: string): string =>
46 SECRET_PATTERNS.reduce(
47 (acc, pattern) => acc.replace(pattern, MASK),
48 text.replace(ASSIGNED_SECRET, (_, key: string, sep: string) => `${key}${sep}${MASK}`),
49 )
50
51/** Drops harness wrappers and normalizes whitespace so only the person's words remain. */
52export const cleanPrompt = (text: string): string =>
53 text
54 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
55 .replace(/<\/?(?:command-[a-z-]+|local-command-[a-z-]+)>/g, '')
56 .replace(/\r\n?/g, '\n')
57 .replace(/\t/g, ' ')
58 .replace(/\n{3,}/g, '\n\n')
59 .trim()
60
61const unquote = (text: string): string => text.replace(/^(["'])([\s\S]*)\1$/, '$2')
62
63/** Whether `/polaroid <args>` is literal text rather than a pick (`last`, `first`, a number). */
64export const isCustomText = (args: string): boolean => !/^(|last|first|\d+)$/i.test(args.trim())
65
66/**
67 * Which text `/polaroid <args>` snaps, from the person's prompts oldest first:
68 * empty or `last` the newest, `first` the oldest, `n` the nth-last, anything
69 * else is the text itself (surrounding quotes dropped).
70 */
71export const choosePrompt = (prompts: readonly string[], args: string): { text: string } | { error: string } => {
72 const pick = args.trim()
73 if (isCustomText(pick)) {
74 const text = unquote(pick).trim()
75
76 return text === '' ? { error: 'Nothing to snap: the quotes were empty.' } : { text }
77 }
78 if (prompts.length === 0) return { error: 'No prompt to snap yet. Send one first, or try /polaroid "your text".' }
79 const word = pick.toLowerCase()
80 const index = word === 'first' ? 0 : prompts.length - (word === '' || word === 'last' ? 1 : Number(pick))
81 const text = prompts[index]
82 // `0` and numbers past the oldest prompt land outside the list.
83 if (text === undefined) {
84 const count = prompts.length === 1 ? '1 prompt' : `${prompts.length} prompts`
85
86 return { error: `Only ${count} in this session so far: try /polaroid first, or a number from 1 to ${prompts.length}.` }
87 }
88
89 return { text }
90}
91
92const length = (text: string): number => Array.from(text).length
93
94const hardSplit = (word: string, width: number): string[] => {
95 const chars = Array.from(word)
96 const parts: string[] = []
97 for (let i = 0; i < chars.length; i += width) parts.push(chars.slice(i, i + width).join(''))
98
99 return parts
100}
101
102/** Word-wraps by character count (the prompt is drawn in a monospace font). */
103export const wrap = (text: string, width: number): string[] => {
104 const lines: string[] = []
105 for (const paragraph of text.split('\n')) {
106 let line = ''
107 for (const word of paragraph.split(' ')) {
108 const pieces = length(word) > width ? hardSplit(word, width) : [word]
109 for (const piece of pieces) {
110 const candidate = line === '' ? piece : `${line} ${piece}`
111 if (length(candidate) <= width) {
112 line = candidate
113 } else {
114 lines.push(line)
115 line = piece
116 }
117 }
118 }
119 lines.push(line)
120 }
121
122 return lines
123}
124
125export type Layout = { fontSize: number; lineHeight: number; lines: string[]; isTruncated: boolean }
126
127/** Picks the largest font size whose wrapped lines fit the photo, truncating at the smallest. */
128export const layout = (text: string): Layout => {
129 for (const fontSize of FONT_SIZES) {
130 const lineHeight = Math.round(fontSize * 1.45)
131 // Two columns go to the "> " glyph and the hanging indent under it.
132 const columns = Math.floor(TEXT.w / (fontSize * 0.6)) - 2
133 const rows = Math.floor(TEXT.h / lineHeight)
134 const lines = wrap(text, columns)
135 const isLast = fontSize === FONT_SIZES[FONT_SIZES.length - 1]
136 if (lines.length <= rows) return { fontSize, lineHeight, lines, isTruncated: false }
137 if (isLast) {
138 const kept = lines.slice(0, rows)
139 const tail = Array.from(kept[rows - 1] ?? "")
140 kept[rows - 1] = `${tail.slice(0, Math.max(0, columns - 1)).join('')}…`
141
142 return { fontSize, lineHeight, lines: kept, isTruncated: true }
143 }
144 }
145
146 return { fontSize: 16, lineHeight: 23, lines: [], isTruncated: false }
147}
148
149export type RenderOptions = {
150 theme: Theme
151 handle: string
152 showModel: boolean
153 tilt: boolean
154}
155
156/** The whole Polaroid as one self-contained SVG document. */
157export const renderSvg = (snap: Snap, options: RenderOptions): string => {
158 const { theme } = options
159 const { fontSize, lineHeight, lines } = layout(snap.text)
160 const angle = options.tilt ? -1.6 : 0
161 const cx = CARD.x + CARD.w / 2
162 const cy = CARD.y + CARD.h / 2
163
164 const background =
165 theme.stops.length === 0
166 ? ''
167 : `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">${theme.stops
168 .map(
169 (stop, i) =>
170 `<stop offset="${theme.stops.length === 1 ? 0 : i / (theme.stops.length - 1)}" stop-color="${stop}"/>`,
171 )
172 .join('')}</linearGradient></defs><rect width="${WIDTH}" height="${HEIGHT}" fill="url(#bg)"/>`
173
174 const promptLines = lines
175 .map((line, i) => {
176 const y = TEXT.y + fontSize + i * lineHeight
177 const glyph = i === 0 ? `<tspan fill="${theme.accent}" font-weight="700">> </tspan>` : ' '
178
179 return `<text x="${TEXT.x}" y="${y}" xml:space="preserve">${glyph}${escapeXml(line)}</text>`
180 })
181 .join('')
182
183 const title = escapeXml(`claude — ${snap.project}`)
184 const model = options.showModel && snap.model !== '' ? escapeXml(snap.model) : ''
185 const handle = escapeXml(options.handle.trim())
186 const captionY = PHOTO.y + PHOTO.h
187
188 return [
189 `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">`,
190 background,
191 `<defs><filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000" flood-opacity="0.35"/></filter></defs>`,
192 `<g transform="rotate(${angle} ${cx} ${cy})">`,
193 // The card
194 `<rect x="${CARD.x}" y="${CARD.y}" width="${CARD.w}" height="${CARD.h}" rx="6" fill="#FBFAF7" filter="url(#shadow)"/>`,
195 // The photo: a dark terminal window, CodeSnap style
196 `<rect x="${PHOTO.x}" y="${PHOTO.y}" width="${PHOTO.w}" height="${PHOTO.h}" rx="10" fill="#1E1E2A"/>`,
197 `<path d="M${PHOTO.x} ${PHOTO.y + TITLE_BAR}V${PHOTO.y + 10}a10 10 0 0 1 10 -10H${PHOTO.x + PHOTO.w - 10}a10 10 0 0 1 10 10V${PHOTO.y + TITLE_BAR}Z" fill="#2A2A38"/>`,
198 ...['#FF5F57', '#FEBC2E', '#28C840'].map(
199 (fill, i) => `<circle cx="${PHOTO.x + 22 + i * 22}" cy="${PHOTO.y + TITLE_BAR / 2}" r="7" fill="${fill}"/>`,
200 ),
201 `<text x="${PHOTO.x + PHOTO.w / 2}" y="${PHOTO.y + TITLE_BAR / 2 + 5}" text-anchor="middle" font-family="${MONO}" font-size="14" fill="#8C8CA0">${title}</text>`,
202 `<g font-family="${MONO}" font-size="${fontSize}" fill="#ECECF4" style="white-space:pre">${promptLines}</g>`,
203 // The caption strip
204 `<text x="${PHOTO.x + 6}" y="${captionY + 92}" font-family="${HAND}" font-size="38" fill="#2B2B2B">${escapeXml(snap.date)}</text>`,
205 handle === ''
206 ? ''
207 : `<text x="${PHOTO.x + PHOTO.w - 6}" y="${captionY + 92}" text-anchor="end" font-family="${HAND}" font-size="32" fill="#2B2B2B">${handle}</text>`,
208 model === ''
209 ? ''
210 : `<text x="${PHOTO.x + 8}" y="${captionY + 140}" font-family="${SANS}" font-size="20" fill="#8A8580">${model}</text>`,
211 `<text x="${PHOTO.x + PHOTO.w - 6}" y="${CARD.y + CARD.h - 34}" text-anchor="end" font-family="${SANS}" font-size="15" fill="#B8B2AA"><tspan fill="${theme.accent}">✳</tspan> Claude Code</text>`,
212 `</g></svg>`,
213 ].join('')
214}
215hooks/themes.ts 27 lines1export type Theme = {
2 id: string
3 label: string
4 /** Gradient stops behind the card; empty means transparent. */
5 stops: readonly string[]
6 /** Accent for the prompt glyph. */
7 accent: string
8}
9
10export const THEMES: readonly Theme[] = [
11 { id: 'clay', label: 'Claude Clay', stops: ['#D97757', '#F0C7A8', '#F5EEE6'], accent: '#D97757' },
12 { id: 'sunset', label: 'Sunset', stops: ['#FF5F6D', '#FFC371'], accent: '#FF8A65' },
13 { id: 'ocean', label: 'Ocean', stops: ['#2E3192', '#1BFFFF'], accent: '#4FC3F7' },
14 { id: 'forest', label: 'Forest', stops: ['#134E5E', '#71B280'], accent: '#81C784' },
15 { id: 'mono', label: 'Mono', stops: ['#232526', '#5A5F63'], accent: '#BDBDBD' },
16 { id: 'transparent', label: 'Transparent', stops: [], accent: '#D97757' },
17]
18
19export const themeIndex = (id: unknown): number => {
20 const found = THEMES.findIndex(theme => theme.id === id)
21
22 return found < 0 ? 0 : found
23}
24
25export const themeAt = (index: number): Theme =>
26 THEMES[((index % THEMES.length) + THEMES.length) % THEMES.length] ?? THEMES[0]!
27types/index.d.ts 28 lines1export type Snap = {
2 /** The prompt as drawn (already redacted when that is on). */
3 text: string
4 project: string
5 model: string
6 /** Caption date, e.g. "7 Oct 2026". */
7 date: string
8 /** File-name stamp, e.g. "2026-10-07_1432". */
9 stamp: string
10}
11
12export type Saved = {
13 png?: string
14 svg?: string
15 /** Bumped on each PNG write so the terminal Image re-reads the file. */
16 generation: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'prompt-polaroid': {
22 snap: Snap | null
23 theme: number
24 saved: Saved | null
25 }
26 }
27}
28