SLOPSHOPPER

memes

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

rowsguardprompttoolprocess
v0.4.1MITupdated 2026-10-19minchenlee/memes-mod
A shopper browsing a rack in a slop shop
README

memes

A Claude Code mod that lets Claude post a reaction meme with an English caption.

In the Claude appsIn a terminal
Drake meme with the caption drawn on itThe same meme as braille art, with its caption on a line below
  • Where the surface can draw images (the Claude desktop and mobile apps, and terminals that show images), Claude shows the meme as a picture with the caption drawn on it.
  • In other terminals, Claude shows the meme as 80-column braille art. The caption is on the line below it.

58 classic templates are included. Each template has hand-placed caption boxes.

Install

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.

Use

Ask for a meme

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

See the commands and the current frequency

/memes:help

Shows the commands and the meme frequency set in /config. It answers at once and does not use a turn.

Claude posts memes on its own

You do not have to ask. When a moment fits, Claude posts a meme by itself, for example:

  • A fix finally works, or a bug turns out to be a typo.
  • Tests pass after several tries, or a build is slow.
  • A plan backfires, or there is a choice between two options.

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).

Choose how often

Open /config and find Meme frequency under the memes plugin. Pick one:

ValueWhat Claude does
offPosts no meme on its own. /memes:meme still works.
rarePosts 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.
oftenPosts 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.

Templates

All 58 templates, with the situation each one is for:

TemplateName in the toolUse it for
Drake Hotline Blingdrake-hotline-blingrejecting one thing for another: "worse / better"
Two Buttonstwo-buttonstorn between two bad options: "option A / option B"
Distracted Boyfrienddistracted-boyfriendditching the current thing for a shiny new one: "new shiny / me / current thing"
UNO Draw 25uno-draw-25-cardswould rather suffer than do the obvious thing (the card already says "or draw 25"): "the obvious thing / me"
Bernie Asking Againbernie-i-am-once-again-asking-for-your-supportasking for the same thing yet again (the picture already says "I am once again asking"): "for …"
Left Exit 12left-exit-12-off-rampswerving from the sensible path: "sensible path / tempting exit / me"
Running Away Balloonrunning-away-balloonpulled away from what you want: "me / the goal / the thing in the way"
Anakin and Padméanakin-padme-4-panela confident plan, then an ominous silence: "plan / you will do it right, right? / … / right?"
Epic Handshakeepic-handshaketwo unlikely sides agree on something: "side A / side B / what they agree on"
Always Has Beenalways-has-beena realisation that it was always so: "wait, it was X all along? / always has been"
Waiting Skeletonwaiting-skeletonwaiting forever (CI, a build, a reply): "me waiting for …"
Gru's Plangru-s-plana plan whose last step backfires: "step 1 / step 2 / oops / oops"
Disaster Girldisaster-girlcalmly causing chaos: "the disaster / me"
Sad Pablo Escobarsad-pablo-escobarwaiting alone, nothing happens: "waiting for …"
X, X Everywherex-x-everywheresomething shows up everywhere: "X / X everywhere"
This Is Finethis-is-fineeverything is on fire but carrying on (the picture already says "this is fine"): "what is on fire"
Surprised Pikachusurprised-pikachushocked by an entirely predictable result: "did the risky thing / it broke / :O"
Mocking Spongebobmocking-spongebobmocking a claim by repeating it: "the claim / tHe ClAiM"
Change My Mindchange-my-minda hot take (the sign already says "change my mind"): "the hot take"
Woman Yelling At Catwoman-yelling-at-catan angry accusation met with a smug reply: "accusation / smug reply"
Batman Slapping Robinbatman-slapping-robinshutting down a bad idea mid-sentence: "bad idea… / NO"
Is This A Pigeonis-this-a-pigeonconfidently misnaming something: "me / the thing / is this …?"
Buff Doge vs. Cheemsbuff-doge-vs-cheemsthe strong past vs the weak present: "then / now"
Expanding Brainexpanding-brainever more absurd "smarter" solutions: "idea 1 / idea 2 / idea 3 / idea 4"
They're The Same Picturetheyre-the-same-picturetwo things that are identical (the picture already says "they're the same picture"): "A / B"
Marked Safe Frommarked-safe-fromescaped a disaster (the picture already says "marked safe from"): "the disaster"
0 Days Withoutzero-days-withoutthe same mistake happened again (the sign already says "0 days without"): "the mistake"
Squidward Windowsquidward-windowwatching others have fun while stuck: "others / me stuck with …"
Megamind Peekingmegamind-peekingsly "no X?" question: "no …?"
Tuxedo Winnie The Poohtuxedo-winnie-the-poohthe same thing, but with a fancy name: "plain name / fancy name"
One Does Not Simplyone-does-not-simplysomething that is never simple: "one does not simply …"
Roll Saferoll-safegalaxy-brained loophole: "can't have bugs if …"
Hide the Pain Haroldhide-the-pain-haroldsmiling through pain: "when … / and you smile anyway"
Monkey Puppetmonkey-puppetawkward look away after getting caught: "when someone asks about …"
Clown Applying Makeupclown-applying-makeupstep-by-step self-delusion: "belief 1 / belief 2 / belief 3 / clown"
Spider-Man Pointingspiderman-pointingtwo of the same blaming each other: "A / B"
Panik Kalm Panikpanik-kalm-panikpanic, relief, then worse panic: "bad news / it's fine / it's not fine"
Scooby-Doo Mask Revealscooby-doo-mask-revealroot cause revealed: "the mystery bug / let's see who this really is / it was …"
Flex Tapeflex-tapea quick patch over a big problem: "the problem / the patch"
Evil Kermitevil-kermittempted by the bad idea: "me / me: do the bad thing"
Bike Fallbike-fallsabotaging yourself, then blaming something else: "me / my own mistake / blaming the tool"
Trade Offertrade-offera lopsided deal (the picture already says "I receive / you receive"): "what I receive / what you receive"
Y'all Got Any Moreyall-got-any-morecraving more of something: "y'all got any more of that …"
Absolute Cinemaabsolute-cinemasomething went perfectly: "absolute cinema"
Bell Curvebell-curvenovice and expert agree, the midwit overthinks: "simple / overthought / simple"
Leonardo Cheersleonardo-cheersa toast to something good: "cheers to …"
Futurama Fryfuturama-fryunsure between two readings: "not sure if … / or …"
Grim Reaper Knockinggrim-reaper-knockingone by one things die: "thing 1 / thing 2 / next victim"
Domino Effectdomino-effecta tiny cause, a huge effect: "tiny cause / huge effect"
Ancient Aliensancient-aliensblaming an absurd cause: "…? aliens"
Sleeping Shaqsleeping-shaqignoring a real problem, alarmed by a trivial one: "real problem / trivial thing"
Laughing Leolaughing-leosmug laughter at a situation: "when …"
Two Guys On A Bustwo-guys-on-a-busthe gloomy view next to the cheerful one: "gloomy side / bright side"
Scroll Of Truthscroll-of-truthan unwelcome truth rejected (the picture already says "NYEH"): "the truth"
I'm The Captain Nowim-the-captain-nowtaking over control: "I'm the … now"
We Are Not The Samewe-are-not-the-samea smug comparison: "you … / I … / we are not the same"
Mother Ignoring Kidmother-ignoring-kiddoting on the new thing while the old one drowns: "new thing / old thing"
Boardroom Suggestionboardroom-suggestionthe one sensible idea gets thrown out: "question / idea / idea / the sensible idea"

First run

The meme images are not in this repository. On the first session start, the mod downloads these files into its assets/ folder:

  • The 58 template images, from i.imgflip.com.
  • The resvg v0.47.0 binary for your platform, checked against its sha256.
  • The Anton font (SIL Open Font License), from google/fonts, checked against its sha256.

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.

Uninstall

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.

Notes

  • Captions are English only. Claude rewrites non-ASCII captions in English.
  • Tested on macOS. Windows and Linux are not tested yet.
  • The template images belong to their owners. This repository does not include them, except in the two example pictures in docs/.

Credits

Thanks to @chungchihhan. The idea for this mod grew out of discussions with him.

License

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.

Source 5 files
hooks/register.tsx 324 lines
1import 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}
324
hooks/compose.ts 77 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
77
hooks/install.ts 119 lines
1import 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}
119
hooks/layout.ts 67 lines
1// 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}
67
hooks/sources.ts 62 lines
1// 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