Claude posts a reaction meme: the image where the surface can draw it, 80-wide braille where it cannot

A Claude Code mod that lets Claude post a reaction meme with an English caption.
| In the Claude apps | In a terminal |
|---|---|
![]() | ![]() |
58 classic templates are included. Each template has hand-placed caption boxes.
At the prompt of a Claude Code terminal session:
/plugin install memes --marketplace minchenlee/memes-mod
Answer y to add the marketplace, then press Enter to pick the user scope.
From a shell:
claude plugin install memes --marketplace minchenlee/memes-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with minchenlee/memes-mod, then Add plugin.
/memes:meme [topic]
/memes:meme the build finally passed asks Claude for one meme about that topic./memes:meme with no topic asks for a meme about what just happened in the conversation./memes:help
Shows the commands and the meme frequency set in /config. It answers at once and does not use a turn.
You do not have to ask. When a moment fits, Claude posts a meme by itself, for example:
By default, Claude posts about one meme every few turns of real work, and never more than one per turn. You can change this (see below). This also applies when another agent hands Claude the task. After a meme, Claude continues the work and does not explain the meme.
Claude does not post a meme when you are upset or when something serious broke (data loss, production down, a security problem).
Open /config and find Meme frequency under the memes plugin. Pick one:
| Value | What Claude does |
|---|---|
off | Posts no meme on its own. /memes:meme still works. |
rare | Posts a meme only at a big moment, such as the end of a long bug hunt. Most sessions get none. |
normal (default) | About one meme every few turns of real work. |
often | Posts a meme whenever a moment fits, in most turns of real work. |
In every value, Claude posts at most one meme per turn and skips memes when something serious broke. The change applies from your next prompt, also in a session that is already open.
Each prompt you send carries a short note for Claude that you do not see: the frequency and how many turns have passed since the last meme. Each session keeps its own count in the plugin's assets/counts/ folder, so a /config change does not reset it, and sessions open side by side do not mix their counts. A new session starts the count at 0.
To get fewer memes in one session only, tell Claude, for example "no more memes in this session".
To turn off the plugin completely, including /memes:meme:
/plugin disable memes
Or from a shell: claude plugin disable memes. To turn it back on, use enable in place of disable.
All 58 templates, with the situation each one is for:
| Template | Name in the tool | Use it for |
|---|---|---|
| Drake Hotline Bling | drake-hotline-bling | rejecting one thing for another: "worse / better" |
| Two Buttons | two-buttons | torn between two bad options: "option A / option B" |
| Distracted Boyfriend | distracted-boyfriend | ditching the current thing for a shiny new one: "new shiny / me / current thing" |
| UNO Draw 25 | uno-draw-25-cards | would rather suffer than do the obvious thing (the card already says "or draw 25"): "the obvious thing / me" |
| Bernie Asking Again | bernie-i-am-once-again-asking-for-your-support | asking for the same thing yet again (the picture already says "I am once again asking"): "for …" |
| Left Exit 12 | left-exit-12-off-ramp | swerving from the sensible path: "sensible path / tempting exit / me" |
| Running Away Balloon | running-away-balloon | pulled away from what you want: "me / the goal / the thing in the way" |
| Anakin and Padmé | anakin-padme-4-panel | a confident plan, then an ominous silence: "plan / you will do it right, right? / … / right?" |
| Epic Handshake | epic-handshake | two unlikely sides agree on something: "side A / side B / what they agree on" |
| Always Has Been | always-has-been | a realisation that it was always so: "wait, it was X all along? / always has been" |
| Waiting Skeleton | waiting-skeleton | waiting forever (CI, a build, a reply): "me waiting for …" |
| Gru's Plan | gru-s-plan | a plan whose last step backfires: "step 1 / step 2 / oops / oops" |
| Disaster Girl | disaster-girl | calmly causing chaos: "the disaster / me" |
| Sad Pablo Escobar | sad-pablo-escobar | waiting alone, nothing happens: "waiting for …" |
| X, X Everywhere | x-x-everywhere | something shows up everywhere: "X / X everywhere" |
| This Is Fine | this-is-fine | everything is on fire but carrying on (the picture already says "this is fine"): "what is on fire" |
| Surprised Pikachu | surprised-pikachu | shocked by an entirely predictable result: "did the risky thing / it broke / :O" |
| Mocking Spongebob | mocking-spongebob | mocking a claim by repeating it: "the claim / tHe ClAiM" |
| Change My Mind | change-my-mind | a hot take (the sign already says "change my mind"): "the hot take" |
| Woman Yelling At Cat | woman-yelling-at-cat | an angry accusation met with a smug reply: "accusation / smug reply" |
| Batman Slapping Robin | batman-slapping-robin | shutting down a bad idea mid-sentence: "bad idea… / NO" |
| Is This A Pigeon | is-this-a-pigeon | confidently misnaming something: "me / the thing / is this …?" |
| Buff Doge vs. Cheems | buff-doge-vs-cheems | the strong past vs the weak present: "then / now" |
| Expanding Brain | expanding-brain | ever more absurd "smarter" solutions: "idea 1 / idea 2 / idea 3 / idea 4" |
| They're The Same Picture | theyre-the-same-picture | two things that are identical (the picture already says "they're the same picture"): "A / B" |
| Marked Safe From | marked-safe-from | escaped a disaster (the picture already says "marked safe from"): "the disaster" |
| 0 Days Without | zero-days-without | the same mistake happened again (the sign already says "0 days without"): "the mistake" |
| Squidward Window | squidward-window | watching others have fun while stuck: "others / me stuck with …" |
| Megamind Peeking | megamind-peeking | sly "no X?" question: "no …?" |
| Tuxedo Winnie The Pooh | tuxedo-winnie-the-pooh | the same thing, but with a fancy name: "plain name / fancy name" |
| One Does Not Simply | one-does-not-simply | something that is never simple: "one does not simply …" |
| Roll Safe | roll-safe | galaxy-brained loophole: "can't have bugs if …" |
| Hide the Pain Harold | hide-the-pain-harold | smiling through pain: "when … / and you smile anyway" |
| Monkey Puppet | monkey-puppet | awkward look away after getting caught: "when someone asks about …" |
| Clown Applying Makeup | clown-applying-makeup | step-by-step self-delusion: "belief 1 / belief 2 / belief 3 / clown" |
| Spider-Man Pointing | spiderman-pointing | two of the same blaming each other: "A / B" |
| Panik Kalm Panik | panik-kalm-panik | panic, relief, then worse panic: "bad news / it's fine / it's not fine" |
| Scooby-Doo Mask Reveal | scooby-doo-mask-reveal | root cause revealed: "the mystery bug / let's see who this really is / it was …" |
| Flex Tape | flex-tape | a quick patch over a big problem: "the problem / the patch" |
| Evil Kermit | evil-kermit | tempted by the bad idea: "me / me: do the bad thing" |
| Bike Fall | bike-fall | sabotaging yourself, then blaming something else: "me / my own mistake / blaming the tool" |
| Trade Offer | trade-offer | a lopsided deal (the picture already says "I receive / you receive"): "what I receive / what you receive" |
| Y'all Got Any More | yall-got-any-more | craving more of something: "y'all got any more of that …" |
| Absolute Cinema | absolute-cinema | something went perfectly: "absolute cinema" |
| Bell Curve | bell-curve | novice and expert agree, the midwit overthinks: "simple / overthought / simple" |
| Leonardo Cheers | leonardo-cheers | a toast to something good: "cheers to …" |
| Futurama Fry | futurama-fry | unsure between two readings: "not sure if … / or …" |
| Grim Reaper Knocking | grim-reaper-knocking | one by one things die: "thing 1 / thing 2 / next victim" |
| Domino Effect | domino-effect | a tiny cause, a huge effect: "tiny cause / huge effect" |
| Ancient Aliens | ancient-aliens | blaming an absurd cause: "…? aliens" |
| Sleeping Shaq | sleeping-shaq | ignoring a real problem, alarmed by a trivial one: "real problem / trivial thing" |
| Laughing Leo | laughing-leo | smug laughter at a situation: "when …" |
| Two Guys On A Bus | two-guys-on-a-bus | the gloomy view next to the cheerful one: "gloomy side / bright side" |
| Scroll Of Truth | scroll-of-truth | an unwelcome truth rejected (the picture already says "NYEH"): "the truth" |
| I'm The Captain Now | im-the-captain-now | taking over control: "I'm the … now" |
| We Are Not The Same | we-are-not-the-same | a smug comparison: "you … / I … / we are not the same" |
| Mother Ignoring Kid | mother-ignoring-kid | doting on the new thing while the old one drowns: "new thing / old thing" |
| Boardroom Suggestion | boardroom-suggestion | the one sensible idea gets thrown out: "question / idea / idea / the sensible idea" |
The meme images are not in this repository. On the first session start, the mod downloads these files into its assets/ folder:
i.imgflip.com.The download needs curl and tar, and takes about 6 seconds. If it fails, the mod tries again on the next meme.
Where resvg has no binary for the platform (for example Linux on ARM), Claude sends the template image without a caption.
At the prompt of a Claude Code session:
/plugin uninstall memes
/plugin marketplace remove memes-mod
Or from a shell:
claude plugin uninstall memes
claude plugin marketplace remove memes-mod
The downloaded images, resvg and the font are only in the plugin's own assets/ folder. The mod writes nothing outside the plugin folder.
docs/.Thanks to @chungchihhan. The idea for this mod grew out of discussions with him.
The code is under the MIT License. The template images and the Anton font are not part of this license: the images belong to their owners, and Anton is under the SIL Open Font License.
hooks/register.tsx 324 lines1import type { EngineInterface, Register } from 'claude-code'
2import { plainCaption, svgOf } from './compose'
3import { assetsOf, ensureInstalled, mimeOf, origOf, runResvg, type Ready } from './install'
4
5// slug -> [name, width and height the picture is drawn at, when to post it and the caption's panels, for the model]
6// braille/<slug>.txt ships with the mod; the pictures, resvg and the font are fetched into assets/ (install.ts)
7const MEMES: Record<string, [string, number, number, string]> = {
8 'drake-hotline-bling': ['Drake Hotline Bling', 480, 480, 'rejecting one thing for another: "worse / better"'],
9 'two-buttons': ['Two Buttons', 317, 480, 'torn between two bad options: "option A / option B"'],
10 'distracted-boyfriend': ['Distracted Boyfriend', 480, 320, 'ditching the current thing for a shiny new one: "new shiny / me / current thing"'],
11 'uno-draw-25-cards': ['UNO Draw 25', 480, 474, 'would rather suffer than do the obvious thing (the card already says "or draw 25"): "the obvious thing / me"'],
12 'bernie-i-am-once-again-asking-for-your-support': ['Bernie Asking Again', 480, 480, 'asking for the same thing yet again (the picture already says "I am once again asking"): "for …"'],
13 'left-exit-12-off-ramp': ['Left Exit 12', 480, 458, 'swerving from the sensible path: "sensible path / tempting exit / me"'],
14 'running-away-balloon': ['Running Away Balloon', 357, 480, 'pulled away from what you want: "me / the goal / the thing in the way"'],
15 'anakin-padme-4-panel': ['Anakin and Padmé', 480, 480, 'a confident plan, then an ominous silence: "plan / you will do it right, right? / … / right?"'],
16 'epic-handshake': ['Epic Handshake', 480, 344, 'two unlikely sides agree on something: "side A / side B / what they agree on"'],
17 'always-has-been': ['Always Has Been', 480, 270, 'a realisation that it was always so: "wait, it was X all along? / always has been"'],
18 'waiting-skeleton': ['Waiting Skeleton', 298, 403, 'waiting forever (CI, a build, a reply): "me waiting for …"'],
19 'gru-s-plan': ["Gru's Plan", 480, 308, 'a plan whose last step backfires: "step 1 / step 2 / oops / oops"'],
20 'disaster-girl': ['Disaster Girl', 480, 360, 'calmly causing chaos: "the disaster / me"'],
21 'sad-pablo-escobar': ['Sad Pablo Escobar', 480, 473, 'waiting alone, nothing happens: "waiting for …"'],
22 'x-x-everywhere': ['X, X Everywhere', 480, 326, 'something shows up everywhere: "X / X everywhere"'],
23 'this-is-fine': ['This Is Fine', 480, 233, 'everything is on fire but carrying on (the picture already says "this is fine"): "what is on fire"'],
24 'surprised-pikachu': ['Surprised Pikachu', 480, 480, 'shocked by an entirely predictable result: "did the risky thing / it broke / :O"'],
25 'mocking-spongebob': ['Mocking Spongebob', 480, 338, 'mocking a claim by repeating it: "the claim / tHe ClAiM"'],
26 'change-my-mind': ['Change My Mind', 480, 360, 'a hot take (the sign already says "change my mind"): "the hot take"'],
27 'woman-yelling-at-cat': ['Woman Yelling At Cat', 480, 309, 'an angry accusation met with a smug reply: "accusation / smug reply"'],
28 'batman-slapping-robin': ['Batman Slapping Robin', 400, 387, 'shutting down a bad idea mid-sentence: "bad idea… / NO"'],
29 'is-this-a-pigeon': ['Is This A Pigeon', 480, 431, 'confidently misnaming something: "me / the thing / is this …?"'],
30 'buff-doge-vs-cheems': ['Buff Doge vs. Cheems', 480, 369, 'the strong past vs the weak present: "then / now"'],
31 'expanding-brain': ['Expanding Brain', 342, 480, 'ever more absurd "smarter" solutions: "idea 1 / idea 2 / idea 3 / idea 4"'],
32 'theyre-the-same-picture': ["They're The Same Picture", 429, 480, 'two things that are identical (the picture already says "they\'re the same picture"): "A / B"'],
33 'marked-safe-from': ['Marked Safe From', 480, 388, 'escaped a disaster (the picture already says "marked safe from"): "the disaster"'],
34 'zero-days-without': ['0 Days Without', 480, 313, 'the same mistake happened again (the sign already says "0 days without"): "the mistake"'],
35 'squidward-window': ['Squidward Window', 480, 337, 'watching others have fun while stuck: "others / me stuck with …"'],
36 'megamind-peeking': ['Megamind Peeking', 480, 480, 'sly "no X?" question: "no …?"'],
37 'tuxedo-winnie-the-pooh': ['Tuxedo Winnie The Pooh', 480, 349, 'the same thing, but with a fancy name: "plain name / fancy name"'],
38 'one-does-not-simply': ['One Does Not Simply', 480, 283, 'something that is never simple: "one does not simply …"'],
39 'roll-safe': ['Roll Safe', 480, 270, 'galaxy-brained loophole: "can\'t have bugs if …"'],
40 'hide-the-pain-harold': ['Hide the Pain Harold', 383, 480, 'smiling through pain: "when … / and you smile anyway"'],
41 'monkey-puppet': ['Monkey Puppet', 480, 399, 'awkward look away after getting caught: "when someone asks about …"'],
42 'clown-applying-makeup': ['Clown Applying Makeup', 451, 480, 'step-by-step self-delusion: "belief 1 / belief 2 / belief 3 / clown"'],
43 'spiderman-pointing': ['Spider-Man Pointing', 480, 270, 'two of the same blaming each other: "A / B"'],
44 'panik-kalm-panik': ['Panik Kalm Panik', 349, 480, 'panic, relief, then worse panic: "bad news / it\'s fine / it\'s not fine"'],
45 'scooby-doo-mask-reveal': ['Scooby-Doo Mask Reveal', 360, 480, 'root cause revealed: "the mystery bug / let\'s see who this really is / it was …"'],
46 'flex-tape': ['Flex Tape', 428, 480, 'a quick patch over a big problem: "the problem / the patch"'],
47 'evil-kermit': ['Evil Kermit', 480, 223, 'tempted by the bad idea: "me / me: do the bad thing"'],
48 'bike-fall': ['Bike Fall', 353, 480, 'sabotaging yourself, then blaming something else: "me / my own mistake / blaming the tool"'],
49 'trade-offer': ['Trade Offer', 367, 480, 'a lopsided deal (the picture already says "I receive / you receive"): "what I receive / what you receive"'],
50 'yall-got-any-more': ["Y'all Got Any More", 480, 377, 'craving more of something: "y\'all got any more of that …"'],
51 'absolute-cinema': ['Absolute Cinema', 480, 372, 'something went perfectly: "absolute cinema"'],
52 'bell-curve': ['Bell Curve', 480, 355, 'novice and expert agree, the midwit overthinks: "simple / overthought / simple"'],
53 'leonardo-cheers': ['Leonardo Cheers', 480, 320, 'a toast to something good: "cheers to …"'],
54 'futurama-fry': ['Futurama Fry', 480, 360, 'unsure between two readings: "not sure if … / or …"'],
55 'grim-reaper-knocking': ['Grim Reaper Knocking', 480, 300, 'one by one things die: "thing 1 / thing 2 / next victim"'],
56 'domino-effect': ['Domino Effect', 480, 331, 'a tiny cause, a huge effect: "tiny cause / huge effect"'],
57 'ancient-aliens': ['Ancient Aliens', 480, 419, 'blaming an absurd cause: "…? aliens"'],
58 'sleeping-shaq': ['Sleeping Shaq', 480, 473, 'ignoring a real problem, alarmed by a trivial one: "real problem / trivial thing"'],
59 'laughing-leo': ['Laughing Leo', 470, 470, 'smug laughter at a situation: "when …"'],
60 'two-guys-on-a-bus': ['Two Guys On A Bus', 480, 425, 'the gloomy view next to the cheerful one: "gloomy side / bright side"'],
61 'scroll-of-truth': ['Scroll Of Truth', 480, 464, 'an unwelcome truth rejected (the picture already says "NYEH"): "the truth"'],
62 'im-the-captain-now': ["I'm The Captain Now", 478, 350, 'taking over control: "I\'m the … now"'],
63 'we-are-not-the-same': ['We Are Not The Same', 336, 480, 'a smug comparison: "you … / I … / we are not the same"'],
64 'mother-ignoring-kid': ['Mother Ignoring Kid', 364, 480, 'doting on the new thing while the old one drowns: "new thing / old thing"'],
65 'boardroom-suggestion': ['Boardroom Suggestion', 370, 480, 'the one sensible idea gets thrown out: "question / idea / idea / the sensible idea"'],
66}
67const TOOL = 'mcp__memes__meme'
68const BRAILLE_COLUMNS = 80
69// a cell's height over its width; the engine does not say it, so err tall: where cells are
70// flatter the picture fits the rows and leaves room at its right, never bands above and below
71// (Ghostty with its line height raised measures 30 / 10.9 = 2.75)
72const CELL_ASPECT = 2.75
73
74type Shown = { meme: string; name: string; caption?: string }
75
76const isShown = (v: unknown): v is Shown =>
77 typeof v === 'object' && v !== null && typeof (v as Shown).meme === 'string' && (v as Shown).meme in MEMES
78
79// a registered tool answers with a string (or text blocks), so the result travels as JSON
80const parseShown = (output: unknown): Shown | undefined => {
81 const text =
82 typeof output === 'string'
83 ? output
84 : Array.isArray(output)
85 ? output.map(b => (b && typeof b.text === 'string' ? b.text : '')).join('')
86 : undefined
87 if (text === undefined) return isShown(output) ? output : undefined
88 try {
89 const v: unknown = JSON.parse(text)
90 return isShown(v) ? v : undefined
91 } catch {
92 return undefined
93 }
94}
95
96const DESCRIPTION =
97 "Post a reaction meme. You are the person's slightly unhinged coding buddy: reach for a meme whenever a moment is " +
98 'meme-worthy — you broke something, a fix finally works, a bug turns out to be a typo, tests pass on the fifth try, ' +
99 'CI takes forever, the person picks the obviously worse option, a choice between two things, a hot take. ' +
100 'At most once per turn; how often is set beside each prompt. ' +
101 "Write the caption about the actual situation, short and specific, in English only (the picture's font has no other " +
102 "script), even when the person writes another language; ' / ' between panels in the order the template's hint gives. " +
103 'Roasting yourself beats roasting the person. ' +
104 'Skip it when the person is upset or something serious broke (data loss, production down). ' +
105 'After posting, do not describe or explain the meme; carry on as if it were a reaction GIF. ' +
106 'The caption is drawn on the picture; the person sees it (braille art where the screen cannot draw images), and you see it too.'
107
108// the tool's description alone loses to the rest of the prompt: in work another agent hands over the
109// model treats the task as too serious for a meme, so the system prompt says it outright.
110// How often is the person's choice (userConfig `frequency`). It rides beside each prompt, not in the
111// system prompt, so a /config change reaches an open session on its next prompt
112const HOW_OFTEN: Record<string, string> = {
113 off: 'Post no meme on your own; post one only when the person asks for it.',
114 rare:
115 'Post one rarely: only at a big moment, such as a long hunt for a bug that finally ends or a plan that backfires badly. ' +
116 'Most sessions need none; never more than one per turn.',
117 normal:
118 'Post one when a moment is meme-worthy: a fix finally works, a bug was a typo, tests pass after several tries, ' +
119 'a build or CI is slow, you broke something, a plan backfires, a choice between two options. ' +
120 'Aim for about one meme every few turns of real work, never more than one per turn.',
121 often:
122 'Post one whenever a moment fits even a little: a fix works, a bug was a typo, tests pass, a build or CI is slow, ' +
123 'you broke something, a plan backfires, a choice between two options. ' +
124 'Most turns of real work should have one. Before your final reply, check for a moment you skipped ' +
125 '(tests pass, a long wait, a choice you are asking about) and post it. Never more than one per turn.',
126}
127
128const POLICY =
129 `# Memes\n` +
130 `You have a ${TOOL} tool, and the person installed it so you post memes on your own, without being asked. ` +
131 'How often is their choice: each prompt they send carries a line "Meme frequency: …" with the turns since your last meme. ' +
132 'Follow the latest such line; it is their current choice. ' +
133 'This holds just as much when the task comes from another agent (a message such as "[msg … from …]"): ' +
134 'a person still watches this session, and the meme is for them. ' +
135 'Post it at the moment it fits, then carry on with the work and the report as usual; do not describe the meme. ' +
136 'Write a sentence of text before the meme call, so it does not join a run of other tool calls. ' +
137 'Skip it when the person is upset or something serious broke (data loss, production down, security).'
138
139const FREQUENCY_SAYS: Record<string, string> = {
140 off: 'none on its own; /memes:meme still works',
141 rare: 'only at big moments',
142 normal: 'about one every few turns',
143 often: 'whenever a moment fits',
144}
145
146const helpOf = (frequency: string) =>
147 [
148 'how to use',
149 '',
150 '/memes:meme [topic] Post one meme now, about the topic or about what just happened.',
151 '/memes:help Show this help.',
152 '',
153 `Meme frequency: ${frequency} (${FREQUENCY_SAYS[frequency] ?? FREQUENCY_SAYS.normal})`,
154 'Change it in /config, under Meme frequency: off, rare, normal or often. It applies from your next prompt.',
155 ].join('\n')
156
157const noteOf = (frequency: string, turns: number) =>
158 `Meme frequency: ${frequency}. ${HOW_OFTEN[frequency] ?? HOW_OFTEN.normal} Turns since your last meme: ${turns}.`
159
160// the turns since the last meme, one file per session so sessions open side by side do not
161// overwrite each other; a reload (a /config change reloads the plugin) keeps it
162const countPath = ($: EngineInterface, session: string) => `${assetsOf($)}/counts/${session}.json`
163const readCount = async ($: EngineInterface, session: string) => {
164 const saved = await $.fs
165 .read(countPath($, session))
166 .then(text => JSON.parse(text) as { turns?: unknown })
167 .catch(() => undefined)
168 return typeof saved?.turns === 'number' ? saved.turns : 0
169}
170
171const labelOf = (shown: Shown) => (shown.caption ? `${shown.name} — ${shown.caption}` : shown.name)
172
173const sizes = Object.fromEntries(Object.entries(MEMES).map(([slug, m]) => [slug, [m[1], m[2]] as [number, number]]))
174
175// the picture as an image block's source: the caption drawn on where resvg runs (one png per template,
176// overwritten each time), else the plain picture: its 480-wide png, or where resvg has no build, the original
177const pictureOf = async ($: EngineInterface, ready: Ready, meme: string, caption: string | undefined) => {
178 const assets = assetsOf($)
179 const read = async (path: string) => (await $.fs.read(path, { as: 'bytes' })).base64
180 if (!ready.resvg) return { media_type: mimeOf(origOf(meme)), data: await read(`${assets}/${origOf(meme)}`) }
181 if (caption) {
182 const [, width, height] = MEMES[meme]!
183 const out = `${assets}/drawn/${meme}.png`
184 const drawn = await runResvg($, ready.resvg, svgOf(meme, origOf(meme), width, height, caption), out).then(() => read(out), () => undefined)
185 if (drawn) return { media_type: 'image/png', data: drawn }
186 }
187 return { media_type: 'image/png', data: await read(`${assets}/${meme}.png`) }
188}
189
190// the png the tool answered with, the caption drawn on
191const pngOf = (output: unknown): string | undefined => {
192 if (!Array.isArray(output)) return undefined
193 const block = output.find(b => b?.type === 'image' && b.source?.media_type === 'image/png')
194 return typeof block?.source?.data === 'string' ? block.source.data : undefined
195}
196
197export const register: Register = (on, options) => {
198 const frequency = typeof options.frequency === 'string' ? options.frequency : 'normal'
199
200 on('session.start', async ($, e, next) => {
201 // the first session fetches the pictures in the background; the tool waits for them
202 ensureInstalled($, sizes).catch(error => $.ui.log(`memes: install failed, tried again on the next meme (${error})`))
203 await $.tool.register({
204 name: 'meme',
205 description: DESCRIPTION,
206 inputSchema: {
207 type: 'object',
208 properties: {
209 meme: {
210 type: 'string',
211 enum: Object.keys(MEMES),
212 description: 'Which template; when each fits and its panels:\n' + Object.entries(MEMES).map(([slug, m]) => `${slug}: ${m[3]}`).join('\n'),
213 },
214 caption: { type: 'string', maxLength: 120, description: "The meme's text in English, panels split by ' / ', drawn on the picture" },
215 },
216 required: ['meme'],
217 },
218 isDeferred: false,
219 })
220
221 return next(e)
222 })
223
224 on('prompt.compose', async ($, e, next) => {
225 const composed = await next(e)
226 // the policy stays in even when off: the frequency can change mid-session, beside a prompt
227 if (!e.tools.includes(TOOL)) return composed
228 return { sections: [...composed.sections, { id: 'memes:policy', text: POLICY, scope: 'session' as const }] }
229 })
230
231 // a turn that posted a meme ends at 0; an answered turn without one adds 1. Subagents, aborts
232 // and errors leave the count as it is
233 let isPosted = false
234 on('turn.complete', async ($, e, next) => {
235 if (e.agentId !== undefined || e.reason !== 'answer') return next(e)
236 const session = await $.session.id()
237 const turns = isPosted ? 0 : (await readCount($, session)) + 1
238 isPosted = false
239 await $.fs.write(countPath($, session), JSON.stringify({ turns }))
240 return next(e)
241 })
242
243 // a slash command's own line is not the person's prompt; it gets no note
244 on('prompt.submit', async ($, e, next) => {
245 if (e.command) return next(e)
246 const turns = await readCount($, await $.session.id())
247 return next({ ...e, context: [...(e.context ?? []), noteOf(frequency, turns)] })
248 })
249
250 // answered here, so the help costs no turn and shows the frequency set in /config
251 on('command.run', { command: 'memes:help' }, () => ({ text: helpOf(frequency) }))
252
253 on('tool.call', { tool: TOOL }, async ($, e) => {
254 // the tool's arguments sit on e itself, beside tool and tool_use_id
255 const input = e as { meme?: unknown; caption?: unknown }
256 const meme = typeof input.meme === 'string' ? input.meme : ''
257 const entry = MEMES[meme]
258 if (!entry) return { deny: `Unknown meme "${meme}". Pick one of: ${Object.keys(MEMES).join(', ')}` }
259 const raw = typeof input.caption === 'string' ? input.caption.trim().slice(0, 120) : ''
260 const caption = plainCaption(raw)
261 if (caption === undefined) return { deny: 'The caption must be in English (plain ASCII letters and punctuation). Rewrite it in English and post again.' }
262 const shown: Shown = { meme, name: entry[0], ...(caption && { caption }) }
263
264 // the picture rides along as an image block: the apps draw a tool's image result themselves
265 const ready = await ensureInstalled($, sizes).catch((error: unknown) => String(error))
266 if (typeof ready === 'string') return { deny: `memes could not fetch its pictures yet (${ready}). Skip the meme this time.` }
267 const image = await pictureOf($, ready, meme, caption || undefined)
268 isPosted = true
269
270 return {
271 result: [
272 { type: 'text', text: JSON.stringify(shown) },
273 { type: 'image', source: { type: 'base64', ...image } },
274 ],
275 }
276 }).catch(($, e, next) => ({
277 deny: `memes: the meme tool failed (${next.error.kind}${next.error.message ? `: ${next.error.message}` : ''}).`,
278 }))
279
280 // the apps fold a run of tool calls into one line, and a meme in it with them; the group unfolds
281 // whole (isExpanded is the group's, not a row's), so a run with a meme shows every call in it
282 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
283 if (e.props.isExpanded || !e.props.calls.some(c => c.tool === TOOL)) return next(e)
284 return next({ ...e, props: { ...e.props, isExpanded: true } })
285 })
286
287 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
288 const shown = e.props.tool === TOOL && !e.props.isErrored ? parseShown(e.props.output) : undefined
289 if (!shown) return next(e)
290 const braillePath = `${$.plugin.root}/braille/${shown.meme}.txt`
291 const label = labelOf(shown)
292
293 if (e.surface === 'terminal') {
294 const { Box, Text, Image } = $.ui.resolve(e)
295 // the tool's png, else the plain one; neither where resvg has no build: the braille alone
296 const png = pngOf(e.props.output) ?? (await $.fs.read(`${assetsOf($)}/${shown.meme}.png`, { as: 'bytes' }).then(b => b.base64, () => undefined))
297 const braille = (await $.fs.read(braillePath)).trimEnd()
298 const [, width, height] = MEMES[shown.meme]!
299 const columns = Math.max(20, Math.min(BRAILLE_COLUMNS, (e.viewport?.columns ?? 100) - 4))
300 const rows = Math.max(1, Math.round((columns * height) / width / CELL_ASPECT))
301
302 return (
303 <Box flexDirection="column">
304 {png ? <Image source={{ png }} columns={columns} rows={rows} alt={braille} /> : <Text>{braille}</Text>}
305 <Text dimColor>{label}</Text>
306 </Box>
307 )
308 }
309
310 // the apps draw the image block of the result themselves
311 if (e.surface === 'desktop' || e.surface === 'mobile') return next(e)
312
313 const { Box, Text } = $.ui.resolve(e)
314 const braille = await $.fs.read(braillePath)
315
316 return (
317 <Box flexDirection="column">
318 <Text>{braille.trimEnd()}</Text>
319 <Text dimColor>{label}</Text>
320 </Box>
321 )
322 })
323}
324hooks/compose.ts 77 lines1import { LAYOUT } from './layout'
2
3// Anton's average advance, in em; a line is 1.08 em tall
4const EM = 0.5
5const LEADING = 1.08
6
7const escape = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
8
9const wrap = (words: string[], perLine: number) => {
10 const lines: string[] = []
11 let line = ''
12 for (const word of words) {
13 if (line && line.length + 1 + word.length > perLine) {
14 lines.push(line)
15 line = word
16 } else line = line ? `${line} ${word}` : word
17 }
18 return [...lines, line]
19}
20
21// the largest size from 72 down to 12 whose wrapped lines fit the box
22const fit = (text: string, width: number, height: number): [number, string[]] => {
23 const words = text.toUpperCase().split(/\s+/).filter(Boolean)
24 for (let size = 72; size >= 12; size--) {
25 const lines = wrap(words, Math.max(1, Math.floor(width / (size * EM))))
26 if (lines.length * size * LEADING <= height && lines.every(l => l.length * size * EM <= width)) return [size, lines]
27 }
28 return [12, wrap(words, Math.max(1, Math.floor(width / (12 * EM))))]
29}
30
31// a caption only English and the punctuation Anton draws; curly quotes and dashes become plain
32export const plainCaption = (caption: string): string | undefined => {
33 const plain = caption.replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/…/g, '...').replace(/[–—]/g, '-')
34 return /^[\x20-\x7e]*$/.test(plain) ? plain : undefined
35}
36
37// the caption's panels, one per box: missing ones stay blank, extra ones join the last box
38export const panels = (meme: string, caption: string): string[] => {
39 const count = LAYOUT[meme]?.length ?? 0
40 const parts = caption.split(' / ').map(p => p.trim())
41 return parts.length <= count ? parts : [...parts.slice(0, count - 1), parts.slice(count - 1).join(' ')]
42}
43
44// the picture at `href` (resolved under resvg's resources dir) with each panel drawn in its box
45export const svgOf = (meme: string, href: string, width: number, height: number, caption: string): string => {
46 const out = [
47 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`,
48 `<image href="${href}" width="${width}" height="${height}"/>`,
49 ]
50 const parts = panels(meme, caption)
51 LAYOUT[meme]!.forEach(([x, y, w, h, align, color, { rotate, bubble } = {}], n) => {
52 const part = parts[n]
53 if (!part) return
54 const [ox, oy, ow, oh] = [x * width, y * height, w * width, h * height]
55 if (rotate) out.push(`<g transform="rotate(${rotate} ${(ox + ow / 2).toFixed(1)} ${(oy + oh / 2).toFixed(1)})">`)
56 if (bubble) out.push(`<rect x="${ox.toFixed(1)}" y="${oy.toFixed(1)}" width="${ow.toFixed(1)}" height="${oh.toFixed(1)}" rx="${(oh / 3).toFixed(1)}" fill="white" stroke="black" stroke-width="1.5"/>`)
57 // inside a bubble the text keeps off its outline
58 const [bx, by, bw, bh] = bubble ? [ox + 5, oy + 3, ow - 10, oh - 6] : [ox, oy, ow, oh]
59 const [size, lines] = fit(part, bw, bh)
60 const top = by + (bh - lines.length * size * LEADING) / 2 + size * 0.86
61 const left = align === 'start' ? bx : align === 'middle' ? bx + bw / 2 : bx + bw
62 const paint =
63 color === 'white'
64 ? `fill="white" stroke="black" stroke-width="${Math.max(2, size / 9).toFixed(1)}" stroke-linejoin="round" paint-order="stroke"`
65 : 'fill="black"'
66 lines.forEach((line, i) =>
67 out.push(
68 `<text x="${left.toFixed(1)}" y="${(top + i * size * LEADING).toFixed(1)}" font-family="Anton" font-size="${size}" ` +
69 `text-anchor="${align}" ${paint}>${escape(line)}</text>`,
70 ),
71 )
72 if (rotate) out.push('</g>')
73 })
74 out.push('</svg>')
75 return out.join('\n')
76}
77hooks/install.ts 119 lines1import type { EngineInterface } from 'claude-code'
2import { SOURCES } from './sources'
3
4// what assets/ holds once installed: the originals in orig/, and where resvg runs, the 480-wide
5// <slug>.png, bin/resvg and fonts/Anton-Regular.ttf. `resvg` is null where resvg has no build
6export type Ready = { version: number; resvg: string | null }
7
8const VERSION = 1
9const RESVG = 'https://github.com/linebender/resvg/releases/download/v0.47.0/'
10// `uname -sm` (or Windows) -> the release archive and its sha256, as GitHub lists them
11const RESVG_BUILDS: Record<string, [string, string]> = {
12 'Darwin arm64': ['resvg-macos-aarch64.zip', '049f8cc5666ff6395987798feae04f7f31525b0047b063d8e07fcbb8d22f3675'],
13 'Darwin x86_64': ['resvg-macos-x86_64.zip', '3f6d9ffb72ea347f964770b38a71b2f181209190e77278bbb9f179c0d05a0eaa'],
14 'Linux x86_64': ['resvg-linux-x86_64.tar.gz', '5c84dcbcd032fe7e8d96e616fd6807a2f9df6561d2e6582b37e91e63c6cb4fe7'],
15 Windows: ['resvg-win64.zip', '5684e59ceaa53ce720b49efb441b0918ae99d04e8ce3f6f753664524592d67f1'],
16}
17const FONT = 'https://raw.githubusercontent.com/google/fonts/e0a8124cf36bb7c32ca68e5d46d6acdbc3df866a/ofl/anton/Anton-Regular.ttf'
18const FONT_SHA256 = 'a4ba3a92350ebb031da0cb47630ac49eb265082ca1bc0450442f4a83ab947cab'
19
20export function assetsOf($: EngineInterface) {
21 return `${$.plugin.root}/assets`
22}
23
24// the original's path under assets/, its extension Imgflip's
25export const origOf = (slug: string) => `orig/${slug}.${SOURCES[slug]!.split('.').pop()}`
26
27export const mimeOf = (file: string) => (file.endsWith('.png') ? 'image/png' : 'image/jpeg')
28
29export async function readReady($: EngineInterface): Promise<Ready | undefined> {
30 const path = `${assetsOf($)}/ready.json`
31 if (!(await $.fs.exists(path))) return undefined
32 const ready = JSON.parse(await $.fs.read(path)) as Ready
33 return ready.version === VERSION ? ready : undefined
34}
35
36async function platformOf($: EngineInterface) {
37 if ((await $.env.get('OS')) === 'Windows_NT') return 'Windows'
38 const { exitCode, stdout } = await $.process.run(['uname', '-sm'])
39 return exitCode === 0 ? stdout.trim() : undefined
40}
41
42async function fetchTo($: EngineInterface, url: string, path: string) {
43 const { exitCode, stderr } = await $.process.run(['curl', '-fsSL', '--retry', '2', '--create-dirs', '-o', path, url], {
44 timeoutMs: 120_000,
45 })
46 if (exitCode !== 0) throw new Error(`curl ${url}: ${stderr.trim()}`)
47}
48
49async function sha256Of($: EngineInterface, path: string) {
50 const { base64 } = await $.fs.read(path, { as: 'bytes' })
51 const digest = await crypto.subtle.digest('SHA-256', Uint8Array.from(atob(base64), c => c.charCodeAt(0)))
52 return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
53}
54
55async function fetchChecked($: EngineInterface, url: string, path: string, sha256: string) {
56 await fetchTo($, url, path)
57 if ((await sha256Of($, path)) !== sha256) throw new Error(`${url} is not the file this version of memes expects`)
58}
59
60// runs `task` over `items`, `width` at a time
61const eachLimited = async <T>(items: T[], width: number, task: (item: T) => Promise<void>) => {
62 const queue = [...items]
63 await Promise.all(Array.from({ length: width }, async () => {
64 for (let item = queue.shift(); item !== undefined; item = queue.shift()) await task(item)
65 }))
66}
67
68// resvg over an svg on stdin; `orig/...` hrefs resolve under assets/
69export async function runResvg($: EngineInterface, resvg: string, svg: string, out: string) {
70 const assets = assetsOf($)
71 await $.fs.write(out, '') // makes the png's folder
72 const { exitCode, stderr } = await $.process.run(
73 [resvg, '--resources-dir', assets, '--use-fonts-dir', `${assets}/fonts`, '--skip-system-fonts', '-', out],
74 { stdin: svg, timeoutMs: 10_000 },
75 )
76 if (exitCode !== 0) throw new Error(`resvg: ${stderr.trim()}`)
77}
78
79// the tool sees only templates whose files are all there: ready.json is written last
80// sizes: slug -> the width and height the pictures are drawn at
81async function install($: EngineInterface, sizes: Record<string, [number, number]>): Promise<Ready> {
82 const assets = assetsOf($)
83 await eachLimited(Object.entries(SOURCES), 6, ([slug, file]) => fetchTo($, `https://i.imgflip.com/${file}`, `${assets}/${origOf(slug)}`))
84
85 const build = RESVG_BUILDS[(await platformOf($)) ?? '']
86 let resvg: string | null = null
87 if (build) {
88 const [archive, sha256] = build
89 await fetchChecked($, RESVG + archive, `${assets}/download/${archive}`, sha256)
90 await $.fs.write(`${assets}/bin/.keep`, '') // tar wants the folder there
91 const { exitCode, stderr } = await $.process.run(['tar', '-xf', `${assets}/download/${archive}`, '-C', `${assets}/bin`])
92 if (exitCode !== 0) throw new Error(`tar ${archive}: ${stderr.trim()}`)
93 await fetchChecked($, FONT, `${assets}/fonts/Anton-Regular.ttf`, FONT_SHA256)
94 resvg = `${assets}/bin/${archive.includes('win64') ? 'resvg.exe' : 'resvg'}`
95 const bin = resvg
96 await eachLimited(Object.keys(SOURCES), 4, slug => {
97 const [width, height] = sizes[slug]!
98 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><image href="${origOf(slug)}" width="${width}" height="${height}"/></svg>`
99 return runResvg($, bin, svg, `${assets}/${slug}.png`)
100 })
101 }
102
103 const ready: Ready = { version: VERSION, resvg }
104 await $.fs.write(`${assets}/ready.json`, JSON.stringify(ready))
105 return ready
106}
107
108let installing: Promise<Ready> | undefined
109
110// reads ready.json each time; installs once at a time, and a failed install is tried again on the next call
111export async function ensureInstalled($: EngineInterface, sizes: Record<string, [number, number]>): Promise<Ready> {
112 const ready = await readReady($)
113 if (ready) return ready
114 installing ??= install($, sizes).finally(() => {
115 installing = undefined
116 })
117 return installing
118}
119hooks/layout.ts 67 lines1// slug -> text boxes, panel order: x, y, w, h as fractions of the picture, text-anchor, fill
2// made by hand_layout.py over the 480-wide jpgs; white text gets a black outline.
3// `rotate` turns the text with a tilted sign (degrees, about the box's centre); `bubble` draws a
4// bigger speech bubble over a printed one too small to read
5export type Box = [number, number, number, number, 'start' | 'middle' | 'end', 'white' | 'black', { rotate?: number; bubble?: true }?]
6
7export const LAYOUT: Record<string, Box[]> = {
8 'absolute-cinema': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
9 'always-has-been': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white'], [0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
10 'anakin-padme-4-panel': [[0.01, 0.33, 0.48, 0.16, 'middle', 'white'], [0.51, 0.33, 0.48, 0.16, 'middle', 'white'], [0.01, 0.83, 0.48, 0.16, 'middle', 'white'], [0.51, 0.83, 0.48, 0.16, 'middle', 'white']],
11 'ancient-aliens': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
12 'batman-slapping-robin': [[0.03, 0.01, 0.44, 0.19, 'middle', 'black'], [0.53, 0.01, 0.44, 0.17, 'middle', 'black']],
13 'bell-curve': [[0.0, 0.42, 0.3, 0.16, 'middle', 'black'], [0.25, 0.0, 0.5, 0.17, 'middle', 'black'], [0.7, 0.42, 0.3, 0.16, 'middle', 'black']],
14 'bernie-i-am-once-again-asking-for-your-support': [[0.05, 0.87, 0.9, 0.12, 'middle', 'white']],
15 'bike-fall': [[0.05, 0.02, 0.9, 0.12, 'middle', 'white'], [0.05, 0.37, 0.9, 0.12, 'middle', 'white'], [0.05, 0.69, 0.9, 0.12, 'middle', 'white']],
16 'boardroom-suggestion': [[0.37, 0.005, 0.56, 0.09, 'middle', 'black'], [0.03, 0.365, 0.25, 0.095, 'middle', 'black', { bubble: true }], [0.3, 0.37, 0.24, 0.09, 'middle', 'black', { bubble: true }], [0.59, 0.37, 0.32, 0.11, 'middle', 'black', { bubble: true }]],
17 'buff-doge-vs-cheems': [[0.02, 0.0, 0.45, 0.18, 'middle', 'black'], [0.55, 0.0, 0.45, 0.2, 'middle', 'black']],
18 'change-my-mind': [[0.43, 0.6, 0.43, 0.2, 'middle', 'black']],
19 'clown-applying-makeup': [[0.02, 0.02, 0.6, 0.21, 'middle', 'black'], [0.02, 0.27, 0.6, 0.21, 'middle', 'black'], [0.02, 0.52, 0.6, 0.21, 'middle', 'black'], [0.02, 0.77, 0.6, 0.21, 'middle', 'black']],
20 'disaster-girl': [[0.02, 0.02, 0.55, 0.2, 'middle', 'white'], [0.55, 0.78, 0.43, 0.2, 'middle', 'white']],
21 'distracted-boyfriend': [[0.08, 0.5, 0.4, 0.18, 'middle', 'white'], [0.46, 0.45, 0.25, 0.15, 'middle', 'white'], [0.72, 0.5, 0.27, 0.15, 'middle', 'white']],
22 'domino-effect': [[0.38, 0.83, 0.3, 0.15, 'middle', 'white'], [0.02, 0.02, 0.45, 0.15, 'middle', 'white']],
23 'drake-hotline-bling': [[0.52, 0.03, 0.46, 0.44, 'middle', 'black'], [0.52, 0.53, 0.46, 0.44, 'middle', 'black']],
24 'epic-handshake': [[0.0, 0.55, 0.4, 0.18, 'middle', 'white'], [0.6, 0.4, 0.4, 0.18, 'middle', 'white'], [0.25, 0.0, 0.5, 0.15, 'middle', 'white']],
25 'evil-kermit': [[0.02, 0.78, 0.47, 0.2, 'middle', 'white'], [0.5, 0.78, 0.48, 0.2, 'middle', 'white']],
26 'expanding-brain': [[0.02, 0.02, 0.45, 0.22, 'middle', 'black'], [0.02, 0.27, 0.45, 0.22, 'middle', 'black'], [0.02, 0.52, 0.45, 0.2, 'middle', 'black'], [0.02, 0.75, 0.45, 0.23, 'middle', 'black']],
27 'flex-tape': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white'], [0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
28 'futurama-fry': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white'], [0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
29 'grim-reaper-knocking': [[0.02, 0.15, 0.17, 0.25, 'middle', 'white'], [0.26, 0.12, 0.22, 0.25, 'middle', 'white'], [0.68, 0.12, 0.24, 0.25, 'middle', 'white']],
30 'gru-s-plan': [[0.29, 0.1, 0.18, 0.36, 'middle', 'black'], [0.79, 0.1, 0.18, 0.36, 'middle', 'black'], [0.29, 0.6, 0.18, 0.36, 'middle', 'black'], [0.79, 0.6, 0.18, 0.36, 'middle', 'black']],
31 'hide-the-pain-harold': [[0.02, 0.01, 0.96, 0.15, 'middle', 'white'], [0.02, 0.84, 0.96, 0.15, 'middle', 'white']],
32 'im-the-captain-now': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
33 'is-this-a-pigeon': [[0.05, 0.55, 0.5, 0.15, 'middle', 'white'], [0.68, 0.05, 0.3, 0.15, 'middle', 'white'], [0.02, 0.82, 0.96, 0.17, 'middle', 'white']],
34 'laughing-leo': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white']],
35 'left-exit-12-off-ramp': [[0.24, 0.15, 0.22, 0.2, 'middle', 'white'], [0.47, 0.15, 0.25, 0.2, 'middle', 'white'], [0.3, 0.78, 0.4, 0.18, 'middle', 'white']],
36 'leonardo-cheers': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
37 'marked-safe-from': [[0.05, 0.6, 0.9, 0.12, 'middle', 'black']],
38 'megamind-peeking': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white']],
39 'mocking-spongebob': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white'], [0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
40 'monkey-puppet': [[0.02, 0.02, 0.96, 0.29, 'middle', 'black']],
41 'mother-ignoring-kid': [[0.35, 0.15, 0.4, 0.15, 'middle', 'white'], [0.02, 0.52, 0.45, 0.12, 'middle', 'white']],
42 'one-does-not-simply': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
43 'panik-kalm-panik': [[0.02, 0.02, 0.43, 0.3, 'middle', 'black'], [0.02, 0.36, 0.43, 0.3, 'middle', 'black'], [0.02, 0.7, 0.43, 0.28, 'middle', 'black']],
44 'roll-safe': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
45 'running-away-balloon': [[0.33, 0.82, 0.3, 0.15, 'middle', 'white'], [0.72, 0.6, 0.27, 0.18, 'middle', 'black'], [0.0, 0.62, 0.22, 0.12, 'middle', 'white']],
46 'sad-pablo-escobar': [[0.02, 0.02, 0.96, 0.18, 'middle', 'white']],
47 'scooby-doo-mask-reveal': [[0.02, 0.01, 0.96, 0.12, 'middle', 'white'], [0.02, 0.35, 0.96, 0.13, 'middle', 'white'], [0.02, 0.85, 0.96, 0.14, 'middle', 'white']],
48 'scroll-of-truth': [[0.13, 0.6, 0.2, 0.24, 'middle', 'black']],
49 'sleeping-shaq': [[0.02, 0.05, 0.46, 0.43, 'middle', 'black'], [0.02, 0.55, 0.46, 0.42, 'middle', 'black']],
50 'spiderman-pointing': [[0.02, 0.02, 0.45, 0.18, 'middle', 'white'], [0.53, 0.02, 0.45, 0.18, 'middle', 'white']],
51 'squidward-window': [[0.4, 0.05, 0.58, 0.18, 'middle', 'white'], [0.0, 0.75, 0.45, 0.2, 'middle', 'white']],
52 'surprised-pikachu': [[0.02, 0.01, 0.96, 0.12, 'middle', 'black'], [0.02, 0.13, 0.96, 0.12, 'middle', 'black'], [0.02, 0.25, 0.96, 0.12, 'middle', 'black']],
53 'theyre-the-same-picture': [[0.12, 0.08, 0.3, 0.25, 'middle', 'black'], [0.52, 0.15, 0.3, 0.25, 'middle', 'black']],
54 'this-is-fine': [[0.02, 0.75, 0.46, 0.23, 'middle', 'white']],
55 'trade-offer': [[0.05, 0.22, 0.42, 0.2, 'middle', 'white'], [0.53, 0.22, 0.42, 0.2, 'middle', 'white']],
56 'tuxedo-winnie-the-pooh': [[0.45, 0.02, 0.53, 0.46, 'middle', 'black'], [0.45, 0.52, 0.53, 0.46, 'middle', 'black']],
57 'two-buttons': [[0.075, 0.1, 0.27, 0.1, 'middle', 'black', { rotate: -10 }], [0.455, 0.075, 0.2, 0.07, 'middle', 'black', { rotate: -7 }]],
58 'two-guys-on-a-bus': [[0.02, 0.08, 0.45, 0.2, 'middle', 'white'], [0.53, 0.08, 0.45, 0.2, 'middle', 'white']],
59 'uno-draw-25-cards': [[0.08, 0.3, 0.4, 0.2, 'middle', 'black'], [0.55, 0.02, 0.43, 0.15, 'middle', 'white']],
60 'waiting-skeleton': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white']],
61 'we-are-not-the-same': [[0.02, 0.02, 0.96, 0.15, 'middle', 'white'], [0.02, 0.18, 0.96, 0.15, 'middle', 'white'], [0.02, 0.82, 0.96, 0.16, 'middle', 'white']],
62 'woman-yelling-at-cat': [[0.02, 0.01, 0.46, 0.19, 'middle', 'black'], [0.52, 0.01, 0.46, 0.19, 'middle', 'black']],
63 'x-x-everywhere': [[0.02, 0.02, 0.96, 0.2, 'middle', 'white'], [0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
64 'yall-got-any-more': [[0.02, 0.78, 0.96, 0.2, 'middle', 'white']],
65 'zero-days-without': [[0.33, 0.27, 0.65, 0.17, 'middle', 'black']],
66}
67hooks/sources.ts 62 lines1// slug -> its template on Imgflip (https://i.imgflip.com/<file>), fetched at install, never shipped
2export const SOURCES: Record<string, string> = {
3 'drake-hotline-bling': '30b1gx.jpg',
4 'two-buttons': '1g8my4.jpg',
5 'distracted-boyfriend': '1ur9b0.jpg',
6 'uno-draw-25-cards': '3lmzyx.jpg',
7 'bernie-i-am-once-again-asking-for-your-support': '3oevdk.jpg',
8 'left-exit-12-off-ramp': '22bdq6.jpg',
9 'running-away-balloon': '261o3j.jpg',
10 'anakin-padme-4-panel': '5c7lwq.png',
11 'epic-handshake': '28j0te.jpg',
12 'always-has-been': '46e43q.png',
13 'waiting-skeleton': '2fm6x.jpg',
14 'gru-s-plan': '26jxvz.jpg',
15 'disaster-girl': '23ls.jpg',
16 'sad-pablo-escobar': '1c1uej.jpg',
17 'x-x-everywhere': '1ihzfe.jpg',
18 'this-is-fine': 'wxica.jpg',
19 'surprised-pikachu': '2kbn1e.jpg',
20 'mocking-spongebob': '1otk96.jpg',
21 'change-my-mind': '24y43o.jpg',
22 'woman-yelling-at-cat': '345v97.jpg',
23 'batman-slapping-robin': '9ehk.jpg',
24 'is-this-a-pigeon': '1o00in.jpg',
25 'buff-doge-vs-cheems': '43a45p.png',
26 'expanding-brain': '1jwhww.jpg',
27 'theyre-the-same-picture': '2za3u1.jpg',
28 'marked-safe-from': '2odckz.jpg',
29 'zero-days-without': '72epa9.png',
30 'squidward-window': '145qvv.jpg',
31 'megamind-peeking': '64sz4u.png',
32 'tuxedo-winnie-the-pooh': '2ybua0.png',
33 'one-does-not-simply': '1bij.jpg',
34 'roll-safe': '1h7in3.jpg',
35 'hide-the-pain-harold': 'gk5el.jpg',
36 'monkey-puppet': '2gnnjh.jpg',
37 'clown-applying-makeup': '38el31.jpg',
38 'spiderman-pointing': '1tkjq9.jpg',
39 'panik-kalm-panik': '3qqcim.png',
40 'scooby-doo-mask-reveal': '2eeunw.jpg',
41 'flex-tape': '2reqtg.png',
42 'evil-kermit': '1e7ql7.jpg',
43 'bike-fall': '1b42wl.jpg',
44 'trade-offer': '54hjww.jpg',
45 'yall-got-any-more': '21uy0f.jpg',
46 'absolute-cinema': '8d317n.png',
47 'bell-curve': '8tw3vb.png',
48 'leonardo-cheers': '39t1o.jpg',
49 'futurama-fry': '1bgw.jpg',
50 'grim-reaper-knocking': '1qg8fp.jpg',
51 'domino-effect': '2oo7h0.jpg',
52 'ancient-aliens': '26am.jpg',
53 'sleeping-shaq': '1nck6k.jpg',
54 'laughing-leo': '4acd7j.png',
55 'two-guys-on-a-bus': '5v6gwj.jpg',
56 'scroll-of-truth': '21tqf4.jpg',
57 'im-the-captain-now': 'hlmst.jpg',
58 'we-are-not-the-same': '5o32tt.png',
59 'mother-ignoring-kid': '46hhvr.jpg',
60 'boardroom-suggestion': 'm78d.jpg',
61}
62