SLOPSHOPPER

phone-check-nudge

After a turn that changed UI files but never looked at them on a simulator, phone or browser, shows a band with a Run check button

bandguard
v0.1.0no licenseupdated 2026-10-18Tatendaz/T-mods/phone-check-nudge
A shopper browsing a rack in a slop shop
README

phone-check-nudge

After a turn that changed UI files and never looked at them, a band above the prompt says so and offers a Run check button.

You asked "Did you check the rest of the app on simulators?" and "make sure to test on the simulators that everything looks good and no issues" in past sessions. This catches the turn before you have to ask.

What counts

  • A UI file: .tsx .jsx .vue .svelte .css .scss .sass .less .html .swift .kt, or an Android res/layout/*.xml, edited with Edit, Write or NotebookEdit. Files under tests/, e2e/, .claude/ or node_modules/, and *.test.*, *.spec.*, *.stories.* and .d.ts files are left out.
  • A look: a Bash command with xcrun simctl, idb, adb, maestro, playwright, screenshot, codeandconfirm, agent-browser or --headless, or any Claude in Chrome, computer-use or Claude browser tool.

When a turn ends with UI edits and no look, the band appears: UI changed, not checked on a phone (landing.tsx, hero.css). It stays until a turn runs a look, you press Dismiss, or you press Run check. It hides while a turn runs.

Run check sends one prompt: check the changed UI on the iOS simulator and Android emulator (or a phone-width browser for web), in light and dark mode, and show the screenshots.

Usage cost

The band costs nothing. Run check starts one turn.

Tests

tests/phone-check-nudge.test.tsx covers the file and look rules, a UI edit raising the band on the terminal and desktop surfaces, Run check sending the prompt, a turn with a simulator screenshot raising nothing, and Dismiss.

claude plugin test ~/.claude/mods/phone-check-nudge
Source 2 files
hooks/register.tsx 76 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Unchecked } from '../types'
5
6// The owner kept asking "Did you check the rest of the app on simulators?".
7// This notices a turn that edited UI files and ran nothing that looks at them,
8// and offers the check above the prompt. Showing the band costs nothing; the
9// Run check button starts one turn.
10
11const unchecked = atom({ plugin: 'phone-check-nudge', key: 'unchecked' } as const, null)
12
13const UI_FILE = /\.(tsx|jsx|vue|svelte|css|scss|sass|less|html|swift|kt)$|\/res\/layout\/[^/]+\.xml$/
14const NOT_UI = /(^|\/)(tests?|__tests__|e2e|spec|\.claude|node_modules)\/|\.(test|spec|stories)\.[jt]sx?$|\.d\.ts$/
15const LOOKS = /\bxcrun\s+simctl\b|\bidb\b|\badb\b|\bmaestro\b|\bplaywright\b|screenshot|\bcodeandconfirm\b|agent-browser|--headless/i
16const LOOK_TOOL = /claude-in-chrome|computer-use|Claude_Browser|Claude_Preview/
17
18export const CHECK_PROMPT =
19  'Check the UI you just changed the way a person would see it: the iOS simulator and the Android emulator for app code, or a phone-width browser for web pages. ' +
20  'Look at light and dark mode, and scroll the screens you touched. Show me the screenshots and list anything that looks wrong.'
21
22export const isUiFile = (path: string) => UI_FILE.test(path) && !NOT_UI.test(path)
23export const looksAtUi = (tool: string, command?: string) => LOOK_TOOL.test(tool) || (tool === 'Bash' && LOOKS.test(command ?? ''))
24
25export const register: Register = on => {
26  let touched = new Set<string>()
27  let looked = false
28
29  on('tool.call', async ($, e, next) => {
30    const ran = await next(e)
31    if (ran.deny !== undefined || ran.isError === true) return ran
32    const input = e as { file_path?: string; notebook_path?: string; command?: string }
33    const path = input.file_path ?? input.notebook_path
34    if ((e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') && path && isUiFile(path)) touched.add(path)
35    if (looksAtUi(String(e.tool), input.command)) looked = true
36    return ran
37  }).catch(($, e, next) => next(e))
38
39  on('turn.complete', async ($, e, next) => {
40    if (e.agentId === undefined && !e.isAborted) {
41      if (looked) await update($, unchecked, () => null)
42      else if (touched.size > 0) {
43        const files = [...touched]
44        await update($, unchecked, old => ({ files: [...new Set([...(old?.files ?? []), ...files])].slice(-20) }))
45      }
46      touched = new Set()
47      looked = false
48    }
49    return next(e)
50  })
51
52  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
53    const pending = await read($, unchecked)
54    if (pending === null || e.props.isWorking || e.props.hasSurvey) return next(e)
55    const { Box, Button, Text } = $.ui.resolve(e)
56    const names = pending.files.map(f => f.split('/').pop())
57    const shown = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3}` : '')
58    return (
59      <Box flexDirection="row" columnGap={1}>
60        <Text color="warning">UI changed, not checked on a phone</Text>
61        <Text dimColor>({shown})</Text>
62        <Button
63          key="run-check"
64          label="Run check"
65          variant="primary"
66          onPress={async () => {
67            await update($, unchecked, () => null)
68            await $.prompt.submit({ text: CHECK_PROMPT })
69          }}
70        />
71        <Button key="dismiss" label="Dismiss" onPress={() => update($, unchecked, () => null)} />
72      </Box>
73    )
74  })
75}
76
types/index.d.ts 8 lines
1export type Unchecked = { files: string[] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'phone-check-nudge': { unchecked: Unchecked | null }
6  }
7}
8