SLOPSHOPPER

grammar-police

Blocks a prompt with a typo or a grammar error until you fix it

newbandcommandtoaststatusprompt
v0.1.0MITupdated 2026-10-04vutran-tvg/grammar-police
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · grammar-police
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ grammar-police │ ⏺ Read(src/auth.ts) │ ☕ Sgt. Apostrophe is on a coffee break. │ ⎿ Read 6 lines │ You may pass. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /grammar ⎿ grammar-police: 🍩 Sgt. Apostrophe is off duty. ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ _[ ]_ Sgt. Apostrophe │ │ /_____\ “Off duty. Nobody saw anything. 😴” │ │ ( -_- ) z [ Bye! ] │ │ /| |o │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ _[ ]_ Sgt. Apostrophe │ │ /_____\ “Off duty. Nobody saw anything. 😴” │ │ ( -_- ) z [ Bye! ] │ │ /| |o │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

grammar-police 👮

A Claude Code mod for people who want their English to get better while they work.

When you send a prompt with a typo or a grammar error, Sgt. Apostrophe stops it before Claude sees it. The officer lists each error with its fix, puts your text back in the prompt box with the errors underlined in red, and waits for you to fix it.

<img src="docs/moods/arrest.svg" width="128" alt="Sgt. Apostrophe, angry, blowing a whistle"> <img src="docs/moods/shocked.svg" width="128" alt="Sgt. Apostrophe, shocked"> <img src="docs/moods/happy.svg" width="128" alt="Sgt. Apostrophe, happy"> <img src="docs/moods/sleepy.svg" width="128" alt="Sgt. Apostrophe, asleep with a donut"> <img src="docs/moods/medal.svg" width="128" alt="Sgt. Apostrophe with a medal">

MoodWhen
ArrestYour prompt has 1 to 3 errors
ShockedYour prompt has 4 errors or more
HappyYour fixed prompt is clean, or the patrol comes back on duty
SleepyYou sent the patrol off duty
MedalEvery 10 clean prompts in a row

The officer has lines for each mood, and different lines when you are a repeat offender.

Install

From the marketplace in this repository:

claude plugin marketplace add vutran-tvg/grammar-police
claude plugin install grammar-police@vutran-tvg

Or from a local clone, for one session:

claude --plugin-dir /path/to/grammar-police

To load a local clone in the Claude desktop app, add its path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Requirements

  • Claude Code 2.1.286 or later. Mods (function hooks) are an early-access feature, and their API can change between releases.
  • The animated mascot shows in the Code tab of the Claude desktop app. The terminal shows a small text face instead.

Use

When a prompt is stopped, the band above the prompt box shows the charges and these buttons:

ButtonKeyWhat it does
Not a mistake1, 2, 3…Adds the word to your dictionary, so it is never flagged again. When no charge is left, your prompt is sent.
Send anywaySSends the prompt once, unchecked. If you edited the text in the box, your edit is sent.
Off dutyOTurns the patrol off.
DismissDCloses the band.

Commands

CommandWhat it does
/grammarTurns the patrol on or off
/grammar on, /grammar offTurns it on, or off
/grammar chillThe default: only clear typos and clear grammar errors
/grammar strictAlso articles, prepositions, capital letters, and apostrophes
/grammar statsYour arrests and your clean streak
/grammar ignore <word>Adds a word to your dictionary, such as a name
/grammar unignore <word>Removes a word from your dictionary
/grammar ignoredLists your dictionary

On or off, the patrol level, the dictionary, and your record are kept across sessions.

Settings

SettingDefaultWhat it is
modelhaikuThe model that checks each prompt: an alias, or a full model id. Change it if your organization or cloud provider does not offer Haiku.

Privacy and cost

  • Each prompt you send is checked with 1 extra model call, through your own Claude Code account, on the model in the model setting.
  • Only your prose is sent: code blocks and quoted lines (>) are removed, and at most 2,000 characters are checked.
  • Slash commands, bash input (!), and prompts from scripts (claude -p, the Agent SDK) are never checked.
  • If the check fails or takes more than 10 seconds, your prompt goes through.

What it cannot do

The Claude desktop app shows its own "A hook blocked your prompt" card and keeps a copy of the stopped prompt in the chat. A mod cannot remove either, so the mod keeps the card's text to a single 👮 and puts everything else in the band.

Development

npm install
ScriptWhat it does
npm run checkType-checks the mod and the tests
npm testRuns the tests (claude plugin test)
npm run validateValidates the plugin and the marketplace manifests
npm run moodsDraws the mood pictures in docs/moods/ from the mascot code

npm run check needs the API types, which Claude Code writes into .claude-plugin/types/ the first time it loads the mod. On a fresh clone, load the mod once (claude --plugin-dir .) before you run it.

PathWhat it holds
hooks/register.tsxThe hooks: the prompt check, the /grammar command, the band
hooks/logic.tsThe pure parts: the reply parser, the prose filter, the underlines, the dictionary match
hooks/mascot.tsSgt. Apostrophe, as 1 animated SVG per mood
types/index.d.tsThe types of the mod's state
tests/The tests, with a fake host in tests/host.ts

License

MIT

Source 4 files
hooks/register.tsx 409 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GrammarError, GrammarLevel, GrammarMood, GrammarScene } from '../types'
5import { isIgnored, marks, parseErrors, pick, proseOf } from './logic'
6import { ASCII, mascot } from './mascot'
7
8const isOn = atom({ plugin: 'grammar-police', key: 'isOn' } as const, true)
9const level = atom({ plugin: 'grammar-police', key: 'level' } as const, 'chill')
10const scene = atom({ plugin: 'grammar-police', key: 'scene' } as const, null)
11
12const NAME = 'Sgt. Apostrophe'
13
14const LINES = {
15  one: [
16    'Freeze! Hands where I can see your keyboard.',
17    'One tiny crime. I saw it. I always see it.',
18    'Ahem. Fix this one, and we never speak of it again.',
19    'Halt, citizen! Your sentence needs a sentence.',
20  ],
21  many: [
22    '{n} crimes in one prompt? Bold. Very bold.',
23    'My whistle is tired. So am I.',
24    'Step away from the Enter key. Slowly.',
25  ],
26  shocked: [
27    'This is not a prompt. This is a crime scene. 🚧',
28    'Backup! I need backup! 📻',
29    'I have to sit down for this one.',
30  ],
31  repeat: [
32    'You again?! I am starting a file on you. 📁',
33    'Strike {rap}. My donut is getting cold.',
34    'We have to stop meeting like this.',
35  ],
36  cleared: [
37    'Clean as a whistle. You may pass! ✨',
38    'Beautiful. I might cry. 🥲',
39    'Case closed. Good citizen!',
40    'Now THAT is English. Carry on.',
41  ],
42  offDuty: ['Fine. Donut break. 🍩 Call me back with /grammar.', 'Off duty. Nobody saw anything. 😴'],
43  onDuty: ['Sgt. Apostrophe, reporting for duty! 🫡', 'I am back. And I brought my whistle.'],
44  medal: ['{n} clean prompts in a row! The Medal of Grammar is yours! 🏅'],
45}
46
47const RULES: Record<GrammarLevel, string> = {
48  chill:
49    'Flag only clear spelling typos, wrong words, a missing or wrong verb form, ' +
50    'subject-verb agreement, a missing apostrophe in a contraction, and a lowercase "i" as a pronoun. ' +
51    'Do not flag capital letters at the start of a sentence, punctuation, or a missing article.',
52  strict:
53    'Flag every spelling typo, grammar error, wrong or missing article, wrong preposition, ' +
54    'missing capital letter, and missing apostrophe.',
55}
56
57const SYSTEM =
58  'You are a strict but fair English proofreader for a non-native speaker who wants to improve. ' +
59  "You check only the writer's own English prose. " +
60  'Ignore code, shell commands, file paths, identifiers, URLs, names of products and tools, ' +
61  'names of people, projects, companies, and places, even when you do not know them, ' +
62  'text inside backticks or quotes, and pasted logs or documents. ' +
63  'Do not flag casual tone, informal words, chat style, a sentence fragment, or a missing final period. ' +
64  'Answer with JSON only, no prose and no code fence: ' +
65  '{"errors":[{"wrong":"<the smallest exact substring of the text that is wrong>",' +
66  '"right":"<the fix for that substring>","why":"<at most 8 words>"}]}. ' +
67  'Each "wrong" value must appear in the text exactly, character for character. ' +
68  'When the text has no error, answer {"errors":[]}.'
69
70// The person's own prompt, from the terminal, the desktop app, or a phone.
71const PERSON = new Set(['composer', 'bridge', 'sdk'])
72
73// How long a scene stays before it leaves by itself. An arrest stays until the prompt is clean.
74const SCENE_MS: Record<GrammarMood, number> = {
75  arrest: 0,
76  shocked: 0,
77  happy: 5000,
78  sleepy: 5000,
79  medal: 7000,
80}
81
82const BORDER: Record<GrammarMood, string> = {
83  arrest: 'red',
84  shocked: 'red',
85  happy: 'green',
86  sleepy: 'gray',
87  medal: 'yellow',
88}
89
90async function readIgnored($: EngineInterface): Promise<string[]> {
91  const value = await $.store.get('ignored')
92  return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : []
93}
94
95async function addIgnored($: EngineInterface, word: string) {
96  const one = word.trim().toLowerCase()
97  const list = await readIgnored($)
98  if (one !== '' && !list.includes(one)) await $.store.set('ignored', [...list, one].sort())
99}
100
101
102async function showStatus($: EngineInterface) {
103  const isOnDuty = await read($, isOn)
104  $.ui.status(isOnDuty ? `👮 ${NAME}: on duty (${await read($, level)})` : undefined)
105}
106
107async function play(
108  $: EngineInterface,
109  mood: GrammarMood,
110  line: string,
111  errors: GrammarError[] = [],
112  arrest = 0,
113  ms = SCENE_MS[mood],
114  text = '',
115) {
116  const id = Date.now() * 1000 + Math.floor(Math.random() * 1000)
117  await update($, scene, () => ({ id, mood, line, errors, arrest, text }))
118  if (ms > 0) {
119    $.clock.after(ms, () => {
120      void update($, scene, now => (now?.id === id ? null : now))
121    })
122  }
123}
124
125// Send the blocked prompt with no check: the plugin's own prompt skips the check.
126// The box wins over the stored text, so an edit the person made is kept.
127async function sendAnyway($: EngineInterface, stored: string) {
128  const box = await $.prompt.read()
129  const text = box.text.trim() !== '' ? box.text : stored
130  await update($, scene, () => null)
131  await $.prompt.fill({ text: '', mode: 'replace' })
132  await $.prompt.submit({ text, asUser: true })
133}
134
135// "Not a mistake": the word goes in the dictionary. With no charge left, the prompt goes.
136async function notMistake($: EngineInterface, wrong: string) {
137  await addIgnored($, wrong)
138  const now = await read($, scene)
139  if (now === null) return
140  const left = now.errors.filter(one => one.wrong !== wrong)
141  if (left.length > 0) {
142    await update($, scene, current => (current === null ? null : { ...current, errors: left }))
143    return
144  }
145  await sendAnyway($, now.text)
146}
147
148async function setOn($: EngineInterface, value: boolean) {
149  await update($, isOn, () => value)
150  await $.store.set('isOn', value)
151  await showStatus($)
152  await play($, value ? 'happy' : 'sleepy', pick(value ? LINES.onDuty : LINES.offDuty))
153}
154
155export const register: Register = (on, options) => {
156  const model = typeof options.model === 'string' && options.model.trim() !== '' ? options.model : 'haiku'
157
158  on('session.start', async ($, e, next) => {
159    const storedOn = await $.store.get('isOn')
160    const storedLevel = await $.store.get('level')
161    if (typeof storedOn === 'boolean') await update($, isOn, () => storedOn)
162    if (storedLevel === 'chill' || storedLevel === 'strict') {
163      await update($, level, () => storedLevel)
164    }
165    await $.command.register({
166      name: 'grammar',
167      description: `${NAME}: toggle, set the patrol, or keep your own dictionary`,
168      argumentHint: '[on | off | chill | strict | stats | ignore <word> | unignore <word> | ignored]',
169      immediate: true,
170    })
171    await showStatus($)
172    return next(e)
173  })
174
175  on('command.run', { command: 'grammar' }, async ($, e) => {
176    const arg = e.args.trim().toLowerCase()
177    const [verb = '', ...rest] = arg.split(/\s+/)
178    const word = rest.join(' ')
179
180    if (verb === 'ignore' || verb === 'unignore') {
181      if (word === '') return { text: `👮 Name the word: /grammar ${verb} <word>` }
182      if (verb === 'ignore') await addIgnored($, word)
183      else await $.store.set('ignored', (await readIgnored($)).filter(one => one !== word))
184      return {
185        text:
186          verb === 'ignore'
187            ? `👮 "${word}" is in your dictionary. ${NAME} will not flag it again.`
188            : `👮 "${word}" left your dictionary.`,
189      }
190    }
191
192    if (verb === 'ignored') {
193      const list = await readIgnored($)
194      return {
195        text:
196          list.length === 0
197            ? '👮 Your dictionary is empty.'
198            : `👮 Your dictionary (${list.length}): ${list.join(', ')}`,
199      }
200    }
201
202    if (arg === 'stats') {
203      const arrests = Number((await $.store.get('arrests')) ?? 0)
204      const streak = Number((await $.store.get('streak')) ?? 0)
205      const best = Number((await $.store.get('best')) ?? 0)
206      return {
207        text: `👮 Your file: ${arrests} arrests. Clean streak: ${streak} prompts (best ${best}).`,
208      }
209    }
210
211    if (arg === 'chill' || arg === 'strict') {
212      await update($, level, () => arg)
213      await $.store.set('level', arg)
214      await update($, isOn, () => true)
215      await $.store.set('isOn', true)
216      await showStatus($)
217      await play(
218        $,
219        arg === 'strict' ? 'arrest' : 'happy',
220        arg === 'strict'
221          ? 'Strict patrol. Every typo, article, capital letter, and apostrophe. 🧐'
222          : 'Chill patrol. Only the clear crimes. 😎',
223        [],
224        0,
225        5000,
226      )
227      return { text: `👮 ${NAME}: ${arg} patrol.` }
228    }
229
230    const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
231    await setOn($, value)
232    return { text: value ? `👮 ${NAME} is on duty.` : `🍩 ${NAME} is off duty.` }
233  })
234
235  on('prompt.submit', async ($, e, next) => {
236    const text = e.text.trim()
237    const isPerson = PERSON.has(e.origin.kind)
238    const isCommand = text.startsWith('/') || text.startsWith('!')
239    if (!isPerson || isCommand || !(await read($, isOn))) return next(e)
240
241    // No surface means no person to fix the prompt: a `claude -p` script or an SDK tool.
242    if ((await $.session.surfaces()).length === 0) return next(e)
243
244    const prose = proseOf(e.text)
245    if (prose === '') return next(e)
246
247    $.ui.status(`🔍 ${NAME} inspects your prompt…`)
248    const ignored = await readIgnored($)
249    const dictionary =
250      ignored.length > 0
251        ? `\n\nNever flag these words or phrases. They are correct for this writer: ${ignored.join(', ')}.`
252        : ''
253    let found: GrammarError[] | null = null
254    try {
255      const reply = await $.model.complete({
256        model,
257        system: SYSTEM,
258        prompt: `${RULES[await read($, level)]}${dictionary}\n\nThe text:\n<<<\n${prose}\n>>>`,
259        maxTokens: 1200,
260        timeoutMs: 10000,
261      })
262      found = reply.isAnswered ? parseErrors(reply.text, e.text) : null
263    } catch {
264      // The engine refused the request (a blocked model, for one): the prompt passes.
265      found = null
266    } finally {
267      await showStatus($)
268    }
269
270    const errors = found === null ? null : found.filter(one => !isIgnored(one, ignored))
271    if (errors === null) {
272      $.ui.toast(`☕ ${NAME} is on a coffee break. You may pass.`)
273      return next(e)
274    }
275
276    if (errors.length === 0) {
277      const now = await read($, scene)
278      const wasBlocked = now !== null && now.errors.length > 0
279      const streak = Number((await $.store.get('streak')) ?? 0) + 1
280      const best = Math.max(streak, Number((await $.store.get('best')) ?? 0))
281      await $.store.set('streak', streak)
282      await $.store.set('best', best)
283      await $.store.set('rap', 0)
284      if (streak % 10 === 0) await play($, 'medal', pick(LINES.medal, { n: streak }))
285      else if (wasBlocked) await play($, 'happy', pick(LINES.cleared))
286      return next(e)
287    }
288
289    const arrest = Number((await $.store.get('arrests')) ?? 0) + 1
290    const rap = Number((await $.store.get('rap')) ?? 0) + 1
291    await $.store.set('arrests', arrest)
292    await $.store.set('rap', rap)
293    await $.store.set('streak', 0)
294
295    const n = errors.length
296    const mood: GrammarMood = n >= 4 ? 'shocked' : 'arrest'
297    const line =
298      rap >= 3
299        ? pick(LINES.repeat, { rap })
300        : n >= 4
301          ? pick(LINES.shocked)
302          : n >= 2
303            ? pick(LINES.many, { n })
304            : pick(LINES.one)
305    await play($, mood, line, errors, arrest, SCENE_MS[mood], e.text)
306
307    // Give the prompt back, with each crime underlined in red.
308    $.clock.after(120, () => {
309      void $.prompt.fill({ text: e.text, mode: 'replace', decorations: marks(e.text, errors) })
310    })
311
312    // The app always shows its own card for a block; the band below holds the rest.
313    return { drop: '👮' }
314  })
315
316  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
317    const now = await read($, scene)
318    if (e.props.hasSurvey || now === null) return next(e)
319
320    const isCrime = now.errors.length > 0
321    const charges = now.errors.map(one => ({ ...one }))
322    const close = () => update($, scene, () => null)
323
324    if (e.surface === 'desktop') {
325      const { Box, Button, Svg, Text } = $.ui.resolve(e)
326      return (
327        <Box flexDirection="row" gap={2} borderStyle="round" borderColor={BORDER[now.mood]} paddingX={1}>
328          <Svg source={mascot(now.mood, now.id)} alt={`${NAME}, ${now.mood}`} width={112} height={105} isInteractive />
329          <Box flexDirection="column" flexShrink={1} gap={0}>
330            <Text dimColor>
331              👮 {NAME}
332              {isCrime ? ` · arrest #${now.arrest}` : ''}
333            </Text>
334            <Text bold color={BORDER[now.mood] === 'gray' ? undefined : BORDER[now.mood]}>
335              “{now.line}”
336            </Text>
337            {charges.map((one, i) => (
338              <Box gap={1} alignItems="center">
339                <Text color="red" strikethrough>
340                  {one.wrong}
341                </Text>
342                <Text>→</Text>
343                <Text color="green" bold>
344                  {one.right}
345                </Text>
346                <Text dimColor>{one.why}</Text>
347                <Button
348                  key={`ignore-${i}`}
349                  label="Not a mistake"
350                  hotkey={i < 9 ? String(i + 1) : undefined}
351                  onPress={() => notMistake($, one.wrong)}
352                />
353              </Box>
354            ))}
355            <Box gap={2}>
356              <Button key="dismiss" label={isCrime ? 'Dismiss' : 'Bye!'} hotkey="d" role="dismiss" onPress={close} />
357              {isCrime && <Button key="send" label="Send anyway" hotkey="s" onPress={() => sendAnyway($, now.text)} />}
358              {isCrime && (
359                <Button key="off" label="Off duty (I am in a rush)" hotkey="o" onPress={() => setOn($, false)} />
360              )}
361            </Box>
362          </Box>
363        </Box>
364      )
365    }
366
367    const { Box, Button, Text } = $.ui.resolve(e)
368    return (
369      <Box flexDirection="row" gap={2} borderStyle="round" borderColor={BORDER[now.mood]} paddingX={1}>
370        <Box flexDirection="column">
371          {ASCII[now.mood].map(row => (
372            <Text color={BORDER[now.mood]}>{row}</Text>
373          ))}
374        </Box>
375        <Box flexDirection="column" flexShrink={1}>
376          <Text dimColor>
377            {NAME}
378            {isCrime ? ` · arrest #${now.arrest}` : ''}
379          </Text>
380          <Text bold>“{now.line}”</Text>
381          {charges.map((one, i) => (
382            <Box gap={1}>
383              <Text color="red" strikethrough>
384                {one.wrong}
385              </Text>
386              <Text>→</Text>
387              <Text color="green" bold>
388                {one.right}
389              </Text>
390              <Text dimColor>{one.why}</Text>
391              <Button
392                key={`ignore-${i}`}
393                label="Not a mistake"
394                hotkey={i < 9 ? String(i + 1) : undefined}
395                onPress={() => notMistake($, one.wrong)}
396              />
397            </Box>
398          ))}
399          <Box gap={2}>
400            <Button key="dismiss" label={isCrime ? 'Dismiss' : 'Bye!'} hotkey="d" onPress={close} />
401            {isCrime && <Button key="send" label="Send anyway" hotkey="s" onPress={() => sendAnyway($, now.text)} />}
402            {isCrime && <Button key="off" label="Off duty" hotkey="o" onPress={() => setOn($, false)} />}
403          </Box>
404        </Box>
405      </Box>
406    )
407  })
408}
409
hooks/logic.ts 61 lines
1import type { GrammarError } from '../types'
2
3// The pure parts of the mod: no `$`, so the tests import them directly.
4
5// The model reads at most this much prose of a prompt.
6export const PROSE_LIMIT = 2000
7
8// The writer's own prose: no code blocks, no quoted lines, no more than PROSE_LIMIT characters.
9export function proseOf(text: string): string {
10  return text
11    .replace(/```[\s\S]*?(```|$)/g, ' ')
12    .split('\n')
13    .filter(row => !row.trimStart().startsWith('>'))
14    .join('\n')
15    .trim()
16    .slice(0, PROSE_LIMIT)
17}
18
19export function pick(list: string[], values: Record<string, number> = {}): string {
20  const line = list[Math.floor(Math.random() * list.length)] ?? ''
21  return line.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ''))
22}
23
24export function parseErrors(reply: string, text: string): GrammarError[] | null {
25  const start = reply.indexOf('{')
26  const end = reply.lastIndexOf('}')
27  if (start < 0 || end <= start) return null
28  try {
29    const parsed = JSON.parse(reply.slice(start, end + 1)) as { errors?: unknown }
30    if (!Array.isArray(parsed.errors)) return null
31    return (parsed.errors as Partial<GrammarError>[]).flatMap(one => {
32      const { wrong, right, why } = one ?? {}
33      const isValid =
34        typeof wrong === 'string' &&
35        typeof right === 'string' &&
36        wrong.length > 0 &&
37        wrong !== right &&
38        text.includes(wrong)
39      return isValid ? [{ wrong, right, why: String(why ?? '') }] : []
40    })
41  } catch {
42    return null
43  }
44}
45
46// The words the writer marked as correct, in lowercase.
47export function isIgnored(one: GrammarError, ignored: readonly string[]): boolean {
48  return ignored.includes(one.wrong.trim().toLowerCase())
49}
50
51export function marks(text: string, errors: GrammarError[]) {
52  let from = 0
53  return errors.flatMap(one => {
54    let at = text.indexOf(one.wrong, from)
55    if (at < 0) at = text.indexOf(one.wrong)
56    if (at < 0) return []
57    from = at + one.wrong.length
58    return [{ start: at, end: at + one.wrong.length, color: 'red', underline: true, bold: true }]
59  })
60}
61
hooks/mascot.ts 245 lines
1import type { GrammarMood } from '../types'
2
3// Sgt. Apostrophe: a round officer with a siren on the hat. 1 SVG per mood.
4
5const INK = '#3a2e1f'
6const SKIN = '#FFD166'
7const NAVY = '#2B3A67'
8const MOUTH = '#8b2e3c'
9
10function siren(isOn: boolean): string {
11  const flash = isOn
12    ? '<animate attributeName="fill" values="#ff4d4d;#4d7cff;#ff4d4d" dur="0.6s" repeatCount="indefinite"/>'
13    : ''
14  const glow = isOn
15    ? `<circle cx="80" cy="13" r="12" fill="#ff4d4d" opacity="0.35">${flash}
16         <animate attributeName="r" values="9;16;9" dur="0.6s" repeatCount="indefinite"/></circle>`
17    : ''
18  return `${glow}
19    <path d="M73 21 Q80 4 87 21 Z" fill="${isOn ? '#ff4d4d' : '#9aa0aa'}" stroke="${INK}" stroke-width="2">${flash}</path>
20    <rect x="71" y="20" width="18" height="6" rx="2" fill="#5b6070" stroke="${INK}" stroke-width="2"/>`
21}
22
23function hat(tilt: number, isSirenOn: boolean): string {
24  return `<g transform="rotate(${tilt} 80 56)">
25    <path d="M50 60 L54 30 Q80 20 106 30 L110 60 Z" fill="${NAVY}" stroke="${INK}" stroke-width="3" stroke-linejoin="round"/>
26    <rect x="49" y="52" width="62" height="9" rx="3" fill="#1b2547"/>
27    <path d="M80 33 L82.4 38.6 L88.5 39 L83.8 42.8 L85.3 48.7 L80 45.4 L74.7 48.7 L76.2 42.8 L71.5 39 L77.6 38.6 Z" fill="#FFC300" stroke="#a87b00" stroke-width="1"/>
28    <path d="M42 60 Q80 74 118 60 Q118 66 112 67 Q80 78 48 67 Q42 66 42 60 Z" fill="#141a33" stroke="${INK}" stroke-width="2"/>
29    ${siren(isSirenOn)}
30  </g>`
31}
32
33function face(mood: GrammarMood): string {
34  const blush = `<ellipse cx="57" cy="104" rx="7" ry="4" fill="#ff8fa3" opacity="0.6"/>
35    <ellipse cx="103" cy="104" rx="7" ry="4" fill="#ff8fa3" opacity="0.6"/>`
36  const smile = `<path d="M68 105 Q80 122 92 105 Z" fill="${MOUTH}" stroke="${INK}" stroke-width="2" stroke-linejoin="round"/>`
37  const happyEyes = `<path d="M59 93 Q66 82 73 93 M87 93 Q94 82 101 93" stroke="${INK}" stroke-width="4" fill="none" stroke-linecap="round"/>`
38
39  switch (mood) {
40    case 'arrest':
41      return `
42        <ellipse cx="66" cy="90" rx="7" ry="8" fill="#fff" stroke="${INK}" stroke-width="2"/>
43        <ellipse cx="94" cy="90" rx="7" ry="8" fill="#fff" stroke="${INK}" stroke-width="2"/>
44        <circle cx="67" cy="92" r="3.5" fill="${INK}"/>
45        <circle cx="93" cy="92" r="3.5" fill="${INK}"/>
46        <path d="M56 76 L74 83 M104 76 L86 83" stroke="${INK}" stroke-width="4" stroke-linecap="round"/>
47        ${blush}
48        <rect x="72" y="106" width="15" height="8" rx="3" fill="#c9d1e0" stroke="${INK}" stroke-width="2"/>
49        <circle cx="90" cy="110" r="5.5" fill="#c9d1e0" stroke="${INK}" stroke-width="2"/>
50        <path d="M100 104 q4 6 0 12 M106 100 q6 10 0 20" stroke="${INK}" stroke-width="2" fill="none" stroke-linecap="round">
51          <animate attributeName="opacity" values="0;1;0" dur="0.5s" repeatCount="indefinite"/>
52        </path>`
53    case 'shocked':
54      return `
55        <circle cx="66" cy="89" r="10" fill="#fff" stroke="${INK}" stroke-width="2"/>
56        <circle cx="94" cy="89" r="10" fill="#fff" stroke="${INK}" stroke-width="2"/>
57        <circle cx="66" cy="89" r="2.5" fill="${INK}"/>
58        <circle cx="94" cy="89" r="2.5" fill="${INK}"/>
59        <path d="M56 74 Q66 68 75 74 M85 74 Q94 68 104 74" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
60        ${blush}
61        <ellipse cx="80" cy="113" rx="5" ry="7" fill="${MOUTH}" stroke="${INK}" stroke-width="2"/>`
62    case 'sleepy':
63      return `
64        <path d="M59 90 Q66 97 73 90 M87 90 Q94 97 101 90" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
65        ${blush}
66        <ellipse cx="80" cy="110" rx="3.5" ry="3" fill="${MOUTH}"/>`
67    case 'happy':
68    case 'medal':
69      return `${happyEyes}${blush}${smile}`
70  }
71}
72
73const restArm = `<path d="M36 104 Q26 112 28 121" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
74  <circle cx="28" cy="124" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
75
76const waveArm = `<g>
77  <animateTransform attributeName="transform" type="rotate" values="-14 124 100;14 124 100;-14 124 100" dur="0.5s" repeatCount="indefinite"/>
78  <path d="M124 100 Q138 86 138 72" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
79  <circle cx="138" cy="66" r="7" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>
80</g>`
81
82const saluteArm = `<path d="M124 100 Q134 78 116 66" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
83  <circle cx="113" cy="64" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
84
85const donutArm = `<path d="M124 104 Q134 112 132 120" stroke="${INK}" stroke-width="3" fill="none" stroke-linecap="round"/>
86  <circle cx="141" cy="117" r="7" fill="none" stroke="#e8a87c" stroke-width="6"/>
87  <circle cx="141" cy="117" r="7" fill="none" stroke="#ff8fb1" stroke-width="4" stroke-dasharray="30 14"/>
88  <path d="M137 112 l2 1 M144 113 l1 2 M140 121 l2 0" stroke="#4d7cff" stroke-width="1.5" stroke-linecap="round"/>
89  <circle cx="132" cy="122" r="6" fill="${SKIN}" stroke="${INK}" stroke-width="2"/>`
90
91function exclaim(): string {
92  return [
93    [16, 52, 0],
94    [30, 34, 0.3],
95  ]
96    .map(
97      ([x, y, begin]) => `<text x="${x}" y="${y}" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="22" fill="#ff4d4d" stroke="${INK}" stroke-width="1">!
98      <animate attributeName="opacity" values="1;0.15;1" dur="0.6s" begin="${begin}s" repeatCount="indefinite"/></text>`,
99    )
100    .join('')
101}
102
103const sweat = `<path d="M112 72 q5 8 0 11 q-5 -3 0 -11 Z" fill="#7cc7ff" stroke="${INK}" stroke-width="1.5">
104  <animateTransform attributeName="transform" type="translate" values="0 0;0 14" dur="1.1s" repeatCount="indefinite"/>
105  <animate attributeName="opacity" values="1;0" dur="1.1s" repeatCount="indefinite"/>
106</path>`
107
108function sparkles(): string {
109  return [
110    [14, 70, 0],
111    [134, 40, 0.4],
112    [20, 132, 0.8],
113    [146, 100, 1.1],
114  ]
115    .map(
116      ([x, y, begin]) => `<text x="${x}" y="${y}" font-size="15" fill="#FFC300" opacity="0">✦
117      <animate attributeName="opacity" values="0;1;0" dur="1.2s" begin="${begin}s" repeatCount="indefinite"/></text>`,
118    )
119    .join('')
120}
121
122function zzz(): string {
123  return [0, 0.8, 1.6]
124    .map(
125      begin => `<text x="112" y="56" font-family="Arial, sans-serif" font-weight="700" font-size="14" fill="#8aa0c8" opacity="0">z
126      <animate attributeName="opacity" values="0;1;0" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/>
127      <animateTransform attributeName="transform" type="translate" values="0 0;14 -36" dur="2.4s" begin="${begin}s" repeatCount="indefinite"/></text>`,
128    )
129    .join('')
130}
131
132const medal = `<path d="M56 110 L63 124 L70 110" stroke="#4d7cff" stroke-width="5" fill="none" stroke-linejoin="round"/>
133  <circle cx="63" cy="129" r="8" fill="#FFC300" stroke="#a87b00" stroke-width="2"/>
134  <path d="M63 124 L64.4 127.3 L68 127.6 L65.3 129.9 L66.1 133.4 L63 131.5 L59.9 133.4 L60.7 129.9 L58 127.6 L61.6 127.3 Z" fill="#fff6c2"/>`
135
136function confetti(): string {
137  const colors = ['#ff4d4d', '#4d7cff', '#FFC300', '#3ecf8e', '#ff8fb1']
138  return [12, 34, 58, 96, 118, 140, 150, 24]
139    .map((x, i) => {
140      const dur = (1.6 + (i % 3) * 0.4).toFixed(1)
141      const begin = (i * 0.25).toFixed(2)
142      return `<rect x="${x}" y="-8" width="5" height="8" rx="1" fill="${colors[i % colors.length]}">
143        <animate attributeName="y" values="-8;150" dur="${dur}s" begin="${begin}s" repeatCount="indefinite"/></rect>`
144    })
145    .join('')
146}
147
148function motion(mood: GrammarMood): string {
149  switch (mood) {
150    case 'arrest':
151    case 'shocked':
152      return '<animateTransform attributeName="transform" type="translate" values="0 0;-3 0;3 0;-3 0;3 0;0 0" dur="0.5s" repeatCount="3"/>'
153    case 'happy':
154    case 'medal':
155      return '<animateTransform attributeName="transform" type="translate" values="0 0;0 -7;0 0" dur="0.45s" repeatCount="5"/>'
156    case 'sleepy':
157      return '<animateTransform attributeName="transform" type="translate" values="0 0;0 1.5;0 0" dur="3s" repeatCount="indefinite"/>'
158  }
159}
160
161// The frame of an animated SVG has a white background, so each mood fills it with a scene.
162function backdrop(mood: GrammarMood): string {
163  switch (mood) {
164    case 'arrest':
165    case 'shocked': {
166      const lines = [30, 52, 74, 96, 118]
167        .map(
168          (y, i) => `<line x1="0" y1="${y}" x2="160" y2="${y}" stroke="#9c9381" stroke-width="1.5"/>
169          <text x="4" y="${y - 3}" font-family="Arial, sans-serif" font-size="8" fill="#7d7464">${7 - i}'</text>`,
170        )
171        .join('')
172      return `<rect width="160" height="150" fill="#e9e3d3"/>${lines}
173        <rect x="0" y="136" width="160" height="14" fill="#cfc6b0"/>`
174    }
175    case 'happy':
176      return `<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
177          <stop offset="0" stop-color="#9fd8ff"/><stop offset="1" stop-color="#e6f6ff"/></linearGradient></defs>
178        <rect width="160" height="150" fill="url(#sky)"/>
179        <g fill="#fff" opacity="0.9">
180          <ellipse cx="26" cy="30" rx="16" ry="7"/><ellipse cx="36" cy="25" rx="10" ry="7"/>
181          <ellipse cx="134" cy="88" rx="16" ry="7"/><ellipse cx="142" cy="83" rx="9" ry="6"/>
182        </g>
183        <rect x="0" y="138" width="160" height="12" fill="#8fd17a"/>`
184    case 'sleepy':
185      return `<defs><linearGradient id="night" x1="0" y1="0" x2="0" y2="1">
186          <stop offset="0" stop-color="#1b2445"/><stop offset="1" stop-color="#34436f"/></linearGradient></defs>
187        <rect width="160" height="150" fill="url(#night)"/>
188        <circle cx="134" cy="26" r="11" fill="#fff3c4"/><circle cx="139" cy="22" r="10" fill="#222c50"/>
189        <g fill="#fff">
190          <circle cx="20" cy="22" r="1.4"><animate attributeName="opacity" values="1;0.2;1" dur="2s" repeatCount="indefinite"/></circle>
191          <circle cx="44" cy="12" r="1"/><circle cx="104" cy="16" r="1.2"/>
192          <circle cx="150" cy="58" r="1"><animate attributeName="opacity" values="0.2;1;0.2" dur="2.6s" repeatCount="indefinite"/></circle>
193          <circle cx="12" cy="70" r="1"/>
194        </g>
195        <rect x="0" y="138" width="160" height="12" fill="#2a3558"/>`
196    case 'medal':
197      return `<defs><linearGradient id="stage" x1="0" y1="0" x2="0" y2="1">
198          <stop offset="0" stop-color="#3b2a6b"/><stop offset="1" stop-color="#6b3fa0"/></linearGradient></defs>
199        <rect width="160" height="150" fill="url(#stage)"/>
200        <path d="M66 0 L94 0 L136 150 L24 150 Z" fill="#fff6c2" opacity="0.22">
201          <animate attributeName="opacity" values="0.14;0.3;0.14" dur="1.6s" repeatCount="indefinite"/>
202        </path>
203        <rect x="0" y="138" width="160" height="12" fill="#2b1d4f"/>`
204  }
205}
206
207export function mascot(mood: GrammarMood, id: number): string {
208  const isSirenOn = mood === 'arrest' || mood === 'shocked' || mood === 'medal'
209  const arm =
210    mood === 'sleepy' ? donutArm : mood === 'happy' || mood === 'medal' ? saluteArm : waveArm
211  const before = mood === 'medal' ? confetti() : ''
212  const after =
213    mood === 'arrest'
214      ? exclaim()
215      : mood === 'shocked'
216        ? exclaim() + sweat
217        : mood === 'sleepy'
218          ? zzz()
219          : sparkles() + (mood === 'medal' ? medal : '')
220
221  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 150" width="160" height="150" preserveAspectRatio="xMidYMid slice"><!-- scene ${id} -->
222  ${backdrop(mood)}
223  ${before}
224  <g>${motion(mood)}
225    <ellipse cx="66" cy="141" rx="10" ry="4.5" fill="${INK}"/>
226    <ellipse cx="94" cy="141" rx="10" ry="4.5" fill="${INK}"/>
227    <path d="M80 48 C114 48 126 74 126 98 C126 124 106 140 80 140 C54 140 34 124 34 98 C34 74 46 48 80 48 Z" fill="${SKIN}" stroke="${INK}" stroke-width="3"/>
228    ${restArm}
229    ${face(mood)}
230    ${hat(mood === 'sleepy' ? -14 : 0, isSirenOn)}
231    ${arm}
232    ${after}
233  </g>
234</svg>`
235}
236
237// The terminal has no SVG: a small face in text.
238export const ASCII: Record<GrammarMood, string[]> = {
239  arrest: ['  _[!]_ ', ' /_____\\', ' ( >_< )', '  /| |\\ '],
240  shocked: ['  _[!]_ ', ' /_____\\', ' ( O_O )', '  \\| |/ '],
241  happy: ['  _[*]_ ', ' /_____\\', ' ( ^_^ )', '  /| |\\ '],
242  sleepy: ['   _[ ]_', '  /_____\\', ' ( -_- ) z', '  /| |o  '],
243  medal: ['  _[*]_ ', ' /_____\\', ' ( ^o^ )', '  /|@|\\ '],
244}
245
types/index.d.ts 26 lines
1export type GrammarError = { wrong: string; right: string; why: string }
2
3export type GrammarLevel = 'chill' | 'strict'
4
5export type GrammarMood = 'arrest' | 'shocked' | 'happy' | 'sleepy' | 'medal'
6
7export type GrammarScene = {
8  id: number
9  mood: GrammarMood
10  line: string
11  errors: GrammarError[]
12  arrest: number
13  /** The blocked prompt, so that a press can send it without a check. */
14  text: string
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'grammar-police': {
20      isOn: boolean
21      level: GrammarLevel
22      scene: GrammarScene | null
23    }
24  }
25}
26