SLOPSHOPPER

prompt-polaroid

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

newpanecommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-polaroid
│ ┃ Prompt Polaroid ✕ › fix the failing auth test and add an audit log call │ ┃ > fix the failing auth test (Save to develop │ ┃ the photo and preview it here.) ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 9 Oct 2025 · app · background: Claude Clay ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ ◀ ] [ ▶ ] [ Save ] ⏺ 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 │ │ › /polaroid │ ⎿ prompt-polaroid: Polaroid ready: save it, copy it, or flip throu │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt Polaroid
> fix the failing auth test (Save to develop the photo and preview it here.) 9 Oct 2025 · app · background: Claude Clay [ ◀ ] [ ▶ ] [ Save ]
README

Prompt Polaroid 📸

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

example

Use

CommandWhat it snaps
/polaroid or /polaroid lastyour last prompt
/polaroid firstthe first prompt of the session
/polaroid 3your 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)
  • Save (s): writes polaroid_<date>.png (2x, via headless Edge/Chrome) and .svg
  • Copy image (c): puts the PNG on your clipboard, so you can paste it straight into Slack/X/LinkedIn
  • Open folder (o)

Settings

In the config menu (or pluginConfigs["prompt-polaroid"].options in settings.json):

OptionDefault
themeclay
outputDir~/Pictures/Claude Polaroids
handle(empty): e.g. @you, written on the caption
showModeltrue
tilttrue
redactSecretstrue: masks API keys, tokens, password=… values and e-mail addresses

Requirements

  • Claude Code with function-hook plugins (mods).
  • PNG export needs Microsoft Edge (built into Windows), Google Chrome or Chromium. Without one you still get the SVG.
  • Copy image: Windows (PowerShell), macOS (osascript), Linux (xclip).

Develop

claude plugin validate .
claude plugin test .

Install

claude plugin marketplace add Roberdinho/claude-mods
claude plugin install prompt-polaroid@rwoel-mods
Source 5 files
hooks/register.tsx 229 lines
1import { 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}
229
hooks/host.ts 80 lines
1import { 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}
80
hooks/polaroid.ts 215 lines
1import 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('&', '&amp;')
26    .replaceAll('<', '&lt;')
27    .replaceAll('>', '&gt;')
28    .replaceAll('"', '&quot;')
29    .replaceAll("'", '&apos;')
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">&gt; </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}
215
hooks/themes.ts 27 lines
1export 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]!
27
types/index.d.ts 28 lines
1export 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