@saastisfaction/gotcha
v0.3.2
Published
Annotated screenshots of any web app, driven from the command line. Frames and annotates by CSS selector, so an AI agent can document a UI it cannot see.
Maintainers
Readme
Gotcha
Annotated screenshots of any web app, driven from the command line. An agent writing documentation cannot see the screen — so it frames and annotates by CSS selector, and Gotcha turns that into pixels.
npm install -g @saastisfaction/gotcha # needs Node 20+ and Google Chrome
gotcha open https://app.example.com/invoices --session doc
gotcha shot --session doc --selector "#invoices" \
--step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
--out doc/01-invoices.png
gotcha close --session docThe session keeps a real Chrome alive between commands, so the login, the
scroll position and the open menu survive. --blur is destructive: the
pixels are gone from the delivered file, which is what you want before a
screenshot leaves your organisation. gotcha help lists everything.
Teach it to a coding agent — the package ships a skill for Claude Code:
ln -s "$(npm root -g)/@saastisfaction/gotcha/skills/gotcha" ~/.claude/skills/gotchaThere is also a Chrome extension for humans, same annotation engine, same output: capture a region, annotate it, paste it. Install it from the Chrome Web Store; see saastisfaction.com.
Free for image capture, annotation and export, without limit. Recording,
high-volume CLI use and screenshots behind a login need a licence —
pricing, or gotcha license to see
where you stand.
Documentation en français : README.fr.md — le même fichier, livré dans le paquet.
Chrome extension that captures an area of a tab, as an image or a video, with annotation and export in a format that pastes anywhere.
For people who take screenshots all day long — development, testing, maintenance, support: grab what is in front of you, annotate it, paste it. Nothing leaves the browser.
The journey is four gestures: frame an area, capture or record, annotate, deliver.
Getting started
npm install
npm run icons # regenerates the icons (needs Python + Pillow)
npm run locales # regenerates _locales/ from src/i18n (called by build)
npm run build # produces dist/ (the extension)
npm run build:cli # produces bin/gotcha.mjs (the CLI for agents)
npm run dev # incremental build with extension reload
npm test # checks the pure logic (naming, cursor, framing)
npm run dev:mail # a local Mailpit (ports 1026 / 8026) to read the service's mail
npm run license:serve # the activation and purchase service, locally, mail sent to that MailpitThen in Chrome: chrome://extensions → enable Developer mode → Load
unpacked → pick the dist/ folder.
Default shortcuts, changeable in chrome://extensions/shortcuts:
| Shortcut | Action |
|---|---|
| Alt+Shift+D | Capture an area |
| Alt+Shift+G | Capture an area (second shortcut) |
| Alt+Shift+S | Stop recording |
The first two do the same thing: open the selection. Chrome assigns a key only at install time and leaves the command empty, without warning, if it is already taken by another extension — two proposals are two chances of getting one.
These letters are not accidental. Chrome interprets extension accelerators
according to the US layout whatever the system's layout: on an AZERTY
keyboard, Alt+Shift+A is typed on the key marked Q. D, G and S sit in the
same position on both layouts. As for C and R, competing capture extensions
reserve them through a keyboard listener without declaring them as commands —
Chrome believes they are free and warns of no conflict.
A single entry point
The capture type — image or video — is not chosen at launch but in the overlay, once the area is framed. That is the only moment when you know whether the subject is still or animated: deciding beforehand forced you to start over as soon as the bug turned out to be an animation.
The bar that appears under the selection therefore offers both on an equal
footing. The last type used moves to the front, carries the accent colour and
the ⏎ badge — Enter never does anything other than what that badge
announces.
The whole visible screen
Ctrl+A (⌘A on macOS) frames the whole visible screen — from the veil as
well as over a frame already drawn, where Enter confirms and therefore cannot
serve. The welcome panel carries the Whole screen button, badge ⏎, and
the bar its icon: the key is never the only path, because the keyboard is
sometimes missing — on a touch screen, or in DevTools device mode, whose focus
readily stays in the panel.
By finger
The overlay can also be drawn by finger: a tablet, or DevTools device mode, which converts the mouse into touch. Chrome does three things with a touch that it does not do with a click, and each of them broke the drawing:
- It claims the first movement to scroll the page and withdraws the
pointer (
pointercancel): the rectangle stopped at its first pixel, and no release ever arrived.touch-action: noneon the overlay root denies it every gesture. - A long press opens the context menu and cancels the touch — precisely the gesture of someone settling a finger on a corner before pulling. The overlay refuses the menu, which has nothing to offer on a veil.
- A gesture can still be taken away from it — the palm, a system gesture.
pointercancelthen closes the drawing like a release: kept if it is big enough, dropped without reproach otherwise.
A second finger placed during the drawing neither moves nor closes it: only the pointer that started it holds it. And the release position counts as much as the last movement — by finger, Chrome coalesces movements and sometimes holds back the last one, which never arrives.
One Chrome behaviour remains that nothing here works around: a finger lifted while still moving makes it compute inertia, and for about a second the first tap only serves to stop it — on the overlay bar as on any page. Lifting the finger while still is enough.
Copying without opening the editor
The same bar carries a Copy button, badge Ctrl+C (⌘C on macOS): the
image goes to the clipboard and the frame stays in place. No tab opens,
nothing changes in the foreground.
This is the paste-it-right-now gesture: an image in a ticket, in a message, in a prompt. Going through the editor only to take it straight back out made a tab flash for nothing.
The frame is not consumed for all that. After the copy you can start again,
open the editor with Enter, or close with Escape — three possible
continuations on the same selection, and the copy closes none of them.
The feedback is threefold, each part for a different question: a flash on the area says what was taken, the button turns green and says where the gesture happened, a banner says that it is in the clipboard. The flash only plays when the capture comes back: the image is already made when it appears, so it cannot end up in it.
The drop is done in the page, the only context already in the foreground
that can write to the clipboard. Two pages where it fails: the insecure
context — an http:// by IP address, on which Chrome gives no clipboard at
all — and the tab that lost focus during a delay. The gesture is not lost
then: the editor tab takes over, copies and closes itself, and the page says
so before it appears.
The delay armed in the bar applies to the copy too: what is displayed holds for everything triggered from that bar.
Picking a colour
I, or the Eyedropper button — in the overlay's welcome panel as in the
bar that follows a frame. The click drops the hex code of the pixel under the
pointer into the clipboard, as upper-case #RRGGBB.
A magnifier follows the pointer: eleven pixels by eleven, magnified eleven times, with the centre pixel ringed and the code written on the colour — black or white, whichever of the two reads on it, computed from WCAG relative luminance. The hue is judged and the code read in a single glance.
The arrow keys move the target by one pixel of the snapshot, not of the
viewport: on a double-density screen, one CSS pixel covers four, and aiming
with the mouse gives no way to choose which. Shift moves by ten. Enter
picks like the click; Escape gives the frame back without copying anything.
Three details that reading the code does not give:
- The overlay hides itself before picking.
captureVisibleTabphotographs the tab as it is, crosshair included: a cyan line left on screen would end up in the pixels read, and the eyedropper would return the colour of the overlay instead of the page's. So nothing appears until the snapshot has come back, and the request waits two frames. - The page is held for the duration of the gesture. The snapshot is taken
once; a page scrolling underneath would make every colour wrong by as much,
silently. Retaking a snapshot at each notch of the wheel is not possible —
captureVisibleTabis rate-limited to two calls per second. A window resize, on the other hand, invalidates the snapshot: the eyedropper lets go and says so. - The CSS pixels / snapshot pixels ratio is measured, not inferred.
devicePixelRatioand the real ratio diverge at the slightest page zoom, and a wrong factor shifts the pick by as much.
The same eyedropper exists in both editors, on the I key there too. There it
is a palette tool and not a mode: the tool stays armed after a pick,
because you rarely lift a single hue from a mock-up. The image editor picks
from the working bitmap — not from the display canvas, which also carries the
annotations and the veil of the area to deliver. The video editor, by
contrast, picks from the preview, which shows exactly what the export will
produce, layers included; the reading follows the current frame, including
during playback.
The drop goes through navigator.clipboard.writeText, with a fallback on
execCommand: the modern clipboard only exists in a secure context, and a
recipe served over http:// by IP address is precisely the kind of page where
you lift a colour.
Delaying a capture
The same bar carries a delay: immediate, 3, 5 or 10 seconds. It applies to whichever button you press next, and each type remembers its own value — you do not delay an image and a recording for the same reasons. Zero by default: nobody suffers a delay they did not ask for.
Delaying an image serves to document what vanishes at the slightest click
elsewhere: a drop-down menu, a submenu, a tooltip, a hovered state. During the
countdown, the overlay hands control back to the page — no more veil, no
more captured mouse, no more intercepted keys except Escape. All that
remains on screen is a dotted border around the area and a counter placed
outside it, both erased two frames before the shot.
Three consequences to know about:
- The browser's native menus will not be captured. An open
<select>, the context menu, autocompletion: these are system windows, outside the tab's rendering, andcaptureVisibleTabdoes not see them. The delay works for any menu built in HTML/CSS, which is the vast majority of web interfaces. - The tab must stay in the foreground.
captureVisibleTabtakes the window's active tab, not the one that requested the capture: switching during the countdown would deliver the image of another page under this one's title. The case is detected and the capture abandoned, with a message in the page. - The countdown follows an absolute deadline, not an accumulation of ticks: Chrome throttles the timers of a tab that loses visibility.
Permissions
The manifest declares no host permission — no <all_urls>, no
host_permissions, no declarative content_scripts. The overlay is injected
on demand by chrome.scripting.executeScript under activeTab, which only
grants access after an explicit user gesture.
Consequence: Chrome does not display "read and change all your data on all websites" at install time, and the extension costs nothing on the pages where it is not used.
Architecture
Four contexts, imposed by Manifest V3.
service worker ── orchestrates, holds no binary, dies between two messages
│ durable state → chrome.storage.session
│
├── content script ── framing overlay (Shadow DOM), cursor trace
│ injected on demand, never declared in the manifest
│
├── offscreen document ── MediaRecorder, audio mixing
│ the only context with a DOM AND a long lifetime
│
└── extension pages ── popup, editor, library, settings
recording control windowFive constraints that explain most of the choices
1. An MV3 service worker has neither a DOM nor a MediaRecorder.
Hence the offscreen document, which exists for that sole reason and closes as
soon as the recording is over. It also keeps time for the stopwatch: a
setInterval in the service worker would not survive its suspension.
2. chrome.runtime.sendMessage serialises to JSON.
No Blob can travel between contexts. Binaries go through IndexedDB and only
the identifier circulates — that is also why the editor receives an ?id=
and reads the blob back itself.
3. tabCapture films the tab's composited rendering.
Anything injected into the page ends up in the stream, even in
position: fixed at the maximum z-index. Two consequences:
- the recording controls are placed outside the filmed rectangle, in the page. The export crops to that rectangle: everything outside is cut off, hence absent from the delivered file. They thus stay under the agent's eyes without ever entering the video;
- the red recording border is drawn as an outer overhang of the rectangle
(
outline-offset), never on its edge.
The countdown, for its part, runs before the stream opens and is erased two frames before the first image: it never appears in the video. It fills the whole screen, unlike the delay before an image — there, the agent precisely needs to see and handle the page.
4. Whether the cursor is in the stream depends on the system. On some configurations Chrome includes the native pointer in the tab capture, on others it does not — that comes down to the compositor and the platform. The trace is therefore always recorded (trajectory and timestamped clicks, a few tens of kilobytes), and two independent settings decide what is drawn at export:
| Setting | Default | Why | |---|---|---| | Click halo | on | No native capture shows where you click, even when the native cursor is filmed | | Drawn cursor | off | Would duplicate the pointer where the native one already appears |
Recording the trace unconditionally leaves the choice open at editing time
rather than frozen at recording time. The trace starts at the record:started
message, sent just after the stream opens and before the control window
is created: the reverse order shifted it by several hundred milliseconds.
5. The stream may contain black bars. Chrome never stretches content: it fits the viewport into the stream's frame while preserving its proportions and pads with letterboxing. A 2560 × 1267 viewport (ratio 2.02) captured in a 3840 × 2160 frame (ratio 1.78) occupies 3840 × 1900, with a 130-pixel bar at the top. Every point of the content is shifted by as much.
Hence streamGeometry(): a uniform scale factor, taken on the most
constraining axis, and an offset. Deriving one factor per axis would be a
modelling error — it would treat the bars as stretching, throwing off both the
crop and the cursor position.
The maxWidth/maxHeight constraints on the capture were removed, for that
matter: they were what imposed a fixed-ratio frame. The letterbox computation
stays in place for captures already recorded and for any frame Chrome might
impose of its own accord.
When in doubt, the video editor logs a calibration reading to the console:
bandeHaut gives the amplitude of the offset directly.
Where the recording controls live
In the page, just outside the filmed rectangle: below by preference, above otherwise, to the right or left failing that. They follow the tab, stay visible whatever else you do, and the export's crop erases them from the file.
A separate Chrome window used to serve for this. It slipped behind the tab as
soon as you returned to it, and nothing in the API allows keeping it in the
foreground: chrome.windows.create exposes no alwaysOnTop, and the only
other way would be to steal focus in a loop for the duration of the take.
It remains the fallback for the one case where the page can host nothing: a frame covering the whole viewport leaves no margin, and placing the controls inside would bring them into the video. The choice is made again on every page traversed, since the available room can change with it.
Navigating during a recording
tabCapture films the tab, not the page: changing address mid-recording does
not interrupt it. The content script, on the other hand, dies with the page —
and with it the red frame and the cursor trace.
Three measures make them follow:
chrome.tabs.onUpdatedreinjects the content script on every page loaded in the filmed tab, and sends it back the frame to display;- the trace is flushed to the service worker every 5 seconds and one last time
on
pagehide, then concatenated at stop — otherwise only the last page traversed would have been kept; - its timestamps are pinned to
traceOrigin, an absolute origin carried by the recording state. A document time (performance.now()) would restart from zero on every page, and the cursor would replay at the wrong moment.
Under activeTab, the permission to inject survives a same-origin navigation
and falls at a change of origin. Beyond that, the frame can no longer be put
back without asking for a host permission, which this project refuses: the
recording goes on, the failure is logged, and the trace resumes on the next
injectable page.
Video export: two paths
| Case | Path | Why | |---|---|---| | Silent (default) | WebCodecs → muxer | Faster than real time, lossless | | With sound | canvas + MediaRecorder | Re-encoding through a canvas would lose the audio track, which no browser demuxer can recover |
Cropping requires re-encoding in any case: tabCapture films the whole
viewport, not the chosen area. Recording is therefore done in
MediaRecorder's most reliable format (VP9/WebM), and the output format is
decided at export.
MP4 comes out through mp4-muxer (~10 KB) and not through ffmpeg.wasm
(~30 MB).
Data model
Everything is local: chrome.storage.local for preferences, IndexedDB for
captures. No server, no account, no network egress.
Capture {
blob // bitmap or video — the destructive blur is already applied to it
thumbnail // 320 px preview, so as not to load a video blob per thumbnail
annotations[] // vector layer, re-editable indefinitely
cursor[] // timestamped pointer trace
rect, viewport // frame in CSS pixels + reference for conversion to the stream
trim // video cut bounds
}Blur is destructive: it modifies the bitmap as it is drawn and never
enters annotations[]. Its undo within the session is carried by a history
entry that keeps the region of pixels from before; once the capture is saved,
the masked data can no longer be recovered. That is intentional — in customer
support, you capture screens that carry personal data.
Annotations, on the other hand, stay vector and re-editable: a typo gets corrected, not redone.
Annotation tools
V select · T text · P pencil · R rectangle · O ellipse ·
A arrow · B blur · N step number · C area to copy ·
I eyedropper
Two of them place no annotation: C delimits the portion to deliver, I
picks the colour of a pixel. They live in the palette because they are pointed
at with the mouse like the others, but neither enters the file — see
"Picking a colour".
Each tool carries a tooltip of ten words at most, on hover as on keyboard
focus: a sixteen-pixel icon does not tell the difference between an arrow that
points and a rectangle that frames, still less that a blur cannot be undone.
It is home-made and not native — the system tooltip takes a second to come,
too late for someone hesitating between two tools — and the same text serves
as the label for screen readers. npm test refuses any explanation beyond ten
words.
The blur icon is a ghost. The grid of dots that preceded it described the means, a pixelation; what matters is the intent — removing a piece of personal data before sending the capture to the customer.
It is made of a single solid mass pierced by two hollows, and that is the lesson of several attempts (hat and glasses, face with glasses, mask): at sixteen pixels, the only size at which the icon is really seen, any line drawing closes up into a blot and its thin strokes vanish as hollows on the accent colour. A solid silhouette keeps its outline in both states, and the ghost's is recognised by its profile alone — dome and wavy skirt — where a face demanded more detail than there are pixels.
Text input
Two focus details, invisible when reading, decide whether the tool works:
- the
pointerdownthat places the input area suppresses the compatibility mouse events. Without that, themousedownof the same click moved focus to the canvas, which is not focusable; the area'sonBlurimmediately validated an empty text and unmounted it. Clicking with the text tool visibly did nothing; - the colour swatches and stroke widths do not take focus
(
preventDefaulton theirmousedown). Without that, changing colour mid-typing validated the text in the old colour and lost the rest of the input. The field now keeps focus, changes colour before your eyes, and typing continues.
A validated text stays selected, like any freshly drawn shape: the colour chosen right after applies to it.
In the video editor: S trim, C crop, Space play and pause.
Six fixed hues plus a free picker, whose last four chosen colours are remembered.
A freshly drawn shape stays selected: its corner handle resizes it at
once, without going back through the selection tool — that is the moment you
notice a frame is too tight. It is sized in screen pixels and not image
pixels, otherwise it would disappear on a high-resolution capture shrunk to
fit the window. The free stroke and the step number carry none: nothing
resizes there, and a displayed control must respond. V picks up any
annotation afterwards, to move or resize it.
Delivering: clipboard and area
An image capture is made to be pasted: it goes to the clipboard as soon as the shot is done, without being asked. The "after an image capture" setting now only decides whether the editor opens. Reopening an old capture from the library, on the other hand, touches nothing: overwriting the clipboard of someone who only wanted to look at an image again would be a confiscation.
The copy happens in the editor tab, the only context that can write to the clipboard — an MV3 service worker has no access to it. Chrome refuses it as long as the document does not have focus: the editor retries the moment it receives it, and only reports if that second attempt fails.
In the editor, Ctrl+C does exactly what the Copy button does: the image
in its current state, annotations included. A banner appears at the bottom
centre on every drop, including the one that happens by itself on opening:
the "Copied" in the right-hand panel went unnoticed by anyone looking at their
image, and the automatic copy announced itself nowhere.
Copying without opening the editor is done from the overlay, before the shot — see Copying without opening the editor.
The second button says Save, never "download", and carries a floppy disk rather than a downward arrow. Nothing went to a server: the file already exists in the browser, and this button only writes it to disk. The vocabulary of downloading would suggest that a screenshot — often full of customer data — made a round trip through a third party. That is false, and worrying for nothing.
The Area tool (C) delimits a portion to deliver: the button becomes
"Copy area", Ctrl+C copies it, and saving the file follows it. This is not a
crop — the image stays whole, the area is redone or cancelled (Escape, or a
click without dragging) as many times as you like, and it enters neither the
annotation layer nor the library thumbnail.
File naming
Tab title — type — timestamp.ext, for example
Billing — screenshot — 2026-08-04 14h32m10.png, and
Facturation — capture — 2026-08-04 14h32m10.png in French.
The title comes first because it is the only segment you read in a downloads list; the timestamp closes the name so that a folder's alphabetical sort stays chronological for equal titles. Application suffixes ("Ticket #4821 — Zendesk") are cut off.
The middle word follows the interface language — a file named "enregistrement" in the middle of an English folder tells nobody anything. The timestamp, for its part, keeps the same form everywhere: it is a sorting mark, not a sentence.
Driving Gotcha from an agent
The extension serves a human who sees their page: they drag a frame, place an arrow with the mouse. An agent tasked with writing user documentation sees none of that — it knows the URL and the selectors, because it has just read the HTML. That is the only material the CLI asks of it.
npm run build:cli # produces bin/gotcha.mjs
gotcha open https://app.acme.com/invoices --session doc
gotcha shot --session doc --selector "#invoices" --padding 16 \
--step ".btn-new" --arrow ".btn-save" --blur ".customer-email" \
--out doc/01-invoices.png
gotcha close --session docEvery annotation target is a CSS selector. That is the fundamental
difference from the mouse interface, and it matters beyond convenience: an
arrow anchored on .save-btn stays right after a layout redesign, where
620,410 points at the button one day and at the empty space beside it the
next. A --blur on .customer-email masks every match — the forty rows
of a table, not just the first.
A --text label is not placed at a fixed offset: the CLI measures the page's
actual footprint — everything that carries text, an image or a control
border — and keeps the position that covers none of it, without leaving the
frame. Without that, a label "above the field" invariably lands on the field's
label, which is precisely above it.
What the CLI shares with the extension
src/editor/render.ts depends neither on the DOM nor on chrome.*: it takes
a canvas context. The CLI reuses it as is, by running it in the browser it
is already driving rather than installing a native canvas in Node. Two
benefits, and the second matters more: no binary dependency to compile per
platform, and an arrow produced on the command line comes out pixel for pixel
identical to an arrow placed with the mouse — today, and after the next
touch-up to the rendering. File naming and translations are shared the same
way.
Sessions
A CLI dies at the end of each command, yet documenting a journey takes ten of them on the same authenticated page. The solution requires no daemon: Chrome is the daemon. It is launched detached once, writes its debugging port into its profile, and each command reattaches to it for the duration of its work. The page's state — login, scroll, open menu — survives from one command to the next.
For applications behind authentication, gotcha login opens a visible
window, the human signs in once, and gotcha save-login freezes the state for
every run that follows, including headless ones.
The CLI works on the visible tab. It opens only one, but an extension loaded into the session opens others — an editor, a popup — and CDP does not enumerate them in creation order: taking the first in the list meant photographing a tab other than the one you were looking at, without an error. The visible tab is the only reference that coincides with what a human would call "the page".
gotcha open --extension <folder> loads an unpacked extension into the
session: its pages and whatever it injects become ordinary targets for
gotcha shot. That is what makes it possible to document an extension with
Gotcha — its own included, see The site. The option requires a Chrome for
Testing, looked for in Playwright's cache when no binary is designated: Chrome
stable has neutralised --load-extension since version 137.
A --selector's margin is clamped to the document on both sides. Clamping
the top-left corner to zero without reducing the width by as much produced a
frame wider than the page as soon as the element touched an edge; Playwright
cropped the image without saying so, the image/frame ratio was wrong, and
every annotation — blur included — slid by half the margin. Found by an
agent, on an address whose end remained readable.
Skill
skills/gotcha/SKILL.md teaches all of this to a Claude Code agent. Install
it with a link:
ln -s "$PWD/skills/gotcha" ~/.claude/skills/gotchaThe choice of a CLI rather than an MCP server comes down to granularity: in MCP, each capture is a round trip whose result crosses back through the context, and documenting a SaaS takes forty of them. A shell command chains as many as needed and only reports the outcome. A skill, for its part, costs only its description until it is triggered.
gotcha help lists the commands and all their options.
Languages
The interface exists in English and French. The language is chosen in the settings: Auto, which follows Chrome's, or one of the two imposed — a French-speaking user often works on an English Chrome, and the reverse is just as common.
Two mechanisms coexist, and the split between them is not a choice:
src/i18n/carries the whole interface. The catalogues are in TypeScript, not JSON: an unknown key or a forgotten parameter becomes annpm run typecheckerror, instead of an empty string discovered in production.en.tsis the reference — it defines the keys and, through its braces, the parameterst()requires.public/_locales/serves only the manifest — description, icon title, shortcut labels. It is the only mechanism Chrome can read there, and its language is the browser's: the extension's setting does not reach it. These files are generated fromsrc/i18n/bynpm run locales, whichnpm run buildcalls. Do not edit them by hand.
Number agreement goes through tPlural('library_count', n), which composes
library_count_one or library_count_other according to the language's CLDR
rules — French puts zero in the singular, "0 capture", English in the plural,
"0 captures". Writing n > 1 by hand gave French everywhere.
Three things the compiler does not see are held by npm test: a {n} lost
in translation, a key written but never displayed, a text left in English on
the French side. The ten-word constraint on the palette's tooltips applies to
each language — a translation that doubles the length pushes the tooltip
out of the window.
What stays out of the catalogue
The tools' keys (V, T, P…) designate a position on the keyboard
more than a word: translating them would move the gesture without warning
anyone. The console messages [Gotcha] … are diagnostics, never
interface. The name stays Gotcha everywhere.
When the language is set
Each context reads the preference at startup, through initI18n(): the pages
before their first render, the content script before handling the slightest
message — the overlay is in native DOM and never revisits its texts. The
service worker and the offscreen document get suspended and restart with an
empty memory: they set it again on every message, not once at load.
Changing language applies at once to the settings, and to the other pages on their next opening. Reloading the editor for a label would cost the annotations in progress.
Adding a language
Copy src/i18n/fr.ts, add it to the Language type and to CATALOGS in
index.ts, then to the settings selector. The compiler will demand the
missing keys one by one. To note before going further: the catalogues are all
embedded in the bundle, content script included — 10 KB compressed for two
languages, which is inconsequential, but beyond three or four they will have
to be loaded on demand in initI18n, which is already asynchronous.
Distributing
Two products, two audiences, two channels — and a single repository.
| | Extension | CLI |
|---|---|---|
| Audience | humans | agents |
| Channel | Chrome Web Store | npm, @saastisfaction/gotcha |
| Shop window | saastisfaction.com | llms.txt, the skill, gotcha help |
gotcha was taken on npm by an empty package from 2016; the @saastisfaction
scope matches the distribution domain and the binary is still called
gotcha — nobody types the scope.
The site
site/public/ is static and deploys as a container. Nothing there is loaded
from a third party: the fonts are served by the VPS, there is no analytics,
no cookie, no remote script. It is the same promise as the one the product
sells, kept where it is most easily verified — the visitor's network tab. The
CSP can therefore refuse even inline scripts, without exception.
The hero image is a real output of the CLI, made by the command written
in its caption, on the mock-up in site/demo/. A hand-drawn mock-up would lie
about the rendering, and a page selling screenshots without showing any asks
to be taken at its word.
The documentation, site/public/docs/, follows the same rule. Its five
pages — getting started, the extension, the CLI, licence and quotas, writing
documentation with an agent — carry under each image the command that
produced it. The extension's screens are photographed by its own CLI, the
extension loaded into the session by gotcha open --extension:
npm run docs:shots remakes every image (scripts/docs-shots.mjs, same
recipe as store-shots.mjs, plus the overlay's shadow root opened in the copy
so that its buttons can be pointed at by selector). Pass a Pro licence through
GOTCHA_DOCS_KEY for the two images of the settings under licence. The
example on the last page — a user guide for the mock-up, six screenshots —
was produced by a Claude Code agent equipped with the skill, and published as
is, commands included.
The deployment procedure — fresh machine, Traefik in front, post-receive
hook — is in deploy/README.md (in the repository) (in French). It carries a
box not to be skipped: Docker writes its own firewall rules and gets ahead
of ufw, so that a container publishing a port is reachable from the Internet
despite default deny incoming. The rule that follows — no service publishes
a port, except the front — applies to everything that will be added.
The Chrome Web Store
store/LISTING.md contains everything the form asks for, including the
justification for each permission — a vague justification being the first
cause of a back-and-forth with the review.
npm run store:shots # the listing's screenshots, at 1280×800
npm run store:tiles # the 440×280 tile, the 1400×560 banner and the site's share image
npm run store:zip # the archive to uploadThe screenshots are generated, not taken by hand: they drive a Chrome for
Testing with the extension loaded, play the real journey, and are remade at
every touch-up of the interface rather than growing stale. Chrome stable no
longer does — since 137 it neutralises --load-extension, and in 151 the
flag that revived it has gone.
Licence and quotas
The rules live in src/lib/license.ts, with no DOM and no chrome.*, and
the extension and the CLI alike apply them — the same way they already share
the rendering of annotations. A rule written twice ends up diverging, and a
divergence on a usage counter is discovered through a complaint, not a test.
A licence is a signed token, verified offline. An online check at every capture would contradict what the product sells and would put a possible outage on the critical path: the network only comes in at purchase. An accepted trade-off — a clock set back extends an expired pass. Harmless at these amounts.
ECDSA P-256 and not Ed25519: WebCrypto has only exposed Ed25519 since Chrome 137, and the manifest declares 116. No reason to raise that threshold for a signature.
Two switches, armed separately and for the same reason. ENFORCEMENT has
been true since 5 September 2026: a spent free quota is refused, and a
capture behind a login without a licence is refused outright. It shipped
false for two weeks first — the counters ran, the messages were computed,
nothing was refused — so that the control left with the distribution and
became effective by changing one constant, without discovering at that moment
that a call path had been forgotten. ACTIVATION_REQUIRED is still false:
the link with the machine is established and measured, but an unlinked licence
still opens everything.
| | Free | Licensed |
|---|---|---|
| Image, annotation, export | unlimited | unlimited |
| Video recording | 3 per day | unlimited |
| gotcha shot | 20 per day | unlimited |
| Authenticated captures | — | yes |
| Feedback (gotcha feedback, the site page) | 1 per day | unlimited (20 an hour) |
The private signing key is not in the repository (.secrets/, ignored).
scripts/mint-license.mjs issues a licence by hand — the same one the server
issues on receipt of a payment, and enough to help someone out.
Attribution
Every piece of work that includes captures made with Gotcha — a guide, a
support answer, a release note, a bug report — must say "Made with
Gotcha", in the language of the work, linking to https://saastisfaction.com
and to https://www.npmjs.com/package/@saastisfaction/gotcha. It is a condition
of the terms (LICENSE, clause 5, and the site's terms page), free and
licensed alike, and the work remains its author's.
An agent learns it from three places, so that none of them has to be the one
it read: the skill states it up front and gives the line to paste, in Markdown
and in HTML; gotcha help ends with it; and gotcha open says it when it
creates a session, gotcha close when it closes one — once when the agent
plans, once when it is about to write, never on each capture, which would put
the same sentence forty times in its context. With --json, both carry an
attribution field with the text and the two addresses.
Purchasing
Three offers, decided on 4 September 2026: Pro monthly at $3, Pro
yearly at $20 — two Stripe subscriptions, no commitment beyond the period
started, invoices issued by Stripe — and a seven-day pass at $2, a one-off
purchase, never tied to a machine (agent plan).
Stripe collects, the activation service issues. The circuit, seen from the
visitor: the button in the pricing section leads to
/api/checkout?plan=monthly (or yearly, pass7), which creates a hosted
Checkout session at Stripe — in subscription or payment mode depending
on the offer — and redirects there with a 303: a link rather than a form,
because the site's CSP only allows its own form targets. Once paid, the
visitor comes back to /thanks.html?session_id=…, whose script asks
/api/license for the key.
A subscriber's key carries no date. It is valid as long as the
subscription runs; when it stops — cancellation, or a card definitively
declined after Stripe's retries (customer.subscription.deleted, or
updated to canceled/unpaid) — the service writes its identifier into
the registry of withdrawn licences, the same one that catches a published
key. For that, the registry is no longer a static file: Traefik routes
/assets/registry.json to the service, which serves the union of the
repository's manual list and the finished subscriptions, signed, with an
iat that never goes backwards. Nothing changes in the extension or the CLI,
which fetch the address they have always fetched, once a day — hence a delay
of one day at most after a subscription ends (REFRESH_INTERVAL_MS, cut from
seven days to one on 4 September 2026, at Étienne's request). A card in
past_due switches nothing off: Stripe retries for weeks, and cutting off at
the first refusal would punish an expired card.
The subscription page (/manage.html) opens Stripe's customer portal —
card, invoices, cancellation at the end of the paid period — in exchange for
the licence key: /api/portal verifies its signature and finds the Stripe
customer it was generated from. No account here either.
Issuance starts from the webhook, not the page. Stripe calls
/api/stripe/webhook (checkout.session.completed, then
async_payment_succeeded for deferred payment methods); the service verifies
the event's signature, reads the session back from Stripe, and only issues on
payment_status: paid. The page, for its part, only reads — if the webhook
has not come through yet, it triggers the same issuance, once only: licences
are filed by payment session (licenses.json, backed up along with the
counter), and the same session always returns the same key. An in-memory lock
prevents the webhook and the page, which often arrive within the same second,
from issuing two keys.
Three choices that cannot be guessed again:
- Prices are found by lookup key (
gotcha_monthly,gotcha_yearly,gotcha_pass_7d), never by a copiedprice_…identifier — it differs between sandbox and production.npm run stripe:setupcreates them, along with the customer portal configuration and the webhook endpoint, once on each side. - In test mode, every licence expires in seven days, whatever the offer. The circuit is thus tested on the real site, with a test card, without a visitor walking away with a perpetual key. Switching to live keys lifts the rule by itself.
- No SDK:
server/stripe.mjsmakes the three calls withfetch, and verifies webhooks withcrypto. The service's image still has nonode_modules, and the tests cover form encoding, the signature and the test-mode rule without a Stripe account.
Mail — the key after payment, the activation code on request — goes out by
SMTP (GOTCHA_SMTP_HOST, _PORT, _USER, _PASS; 465 in implicit TLS,
otherwise STARTTLS, never in the clear outside localhost) or through an HTTP
API (GOTCHA_MAIL_URL + GOTCHA_MAIL_TOKEN, Resend-style JSON body). The
SMTP client is server/mail.mjs, a hundred lines of standard library, tested
against a fake server. With nothing configured, nothing goes out and the page
displays the key anyway.
Automatic tax (STRIPE_AUTOMATIC_TAX=1) remains an explicit choice: without
an active registration at Stripe, it computes nothing and does not say so.
What needs to be set on the VPS, and how to test the circuit end to end, is in
deploy/README.md (in the repository), section "L'achat" (in French).
Withdrawing a licence from circulation
An offline check can take nothing back. That does not matter for a refund,
but it does for the key that ends up published on a forum: it would serve
everyone there, forever. Hence src/lib/revocations.ts and a signed registry,
site/public/assets/registry.json.
npm run license:revoke -- lic_a1b2c3d4e5f6a7b8 # reissues the registry
git add site/public/assets/registry.json && git push vps mainWhat the mechanism respects, because it is what the product sells:
- nothing is sent. A GET on a static file, with no parameter and no cookie. The whole list comes down and the comparison happens on the machine: the server never learns which key is asking — and it keeps no access log;
- only a licensed machine fetches. The free version makes no request;
- once a day at most, never on the path of a capture. On the extension
side the fetch goes out behind the response; on the CLI side it only
happens in
gotcha licenseandgotcha activate, where someone is already waiting; - failure has no effect. Offline, filtered DNS, server down: the licence stands.
Three details that make the difference between a list and a protection:
- the registry is signed. Otherwise, whoever hijacks DNS serves a list containing every identifier and deprives every customer of what they paid for;
- it carries a usage domain (
typ: 'rev', against the licences'typ: 'lic'). The registry is signed by the same key and also carriesv: 1: without a discriminator, this public file pasted into the "licence key" field would pass for a perpetual licence. A test checks it; - an older registry is ignored (
iat). A signature does not prevent replaying yesterday's copy to resurrect a key withdrawn today. Symmetrically,mint-revocations.mjsrefuses to issue a registry that would lose entries, except with--force.
The file is served with Access-Control-Allow-Origin: *, which lets the
service worker read it without a host permission: the manifest stays
blank, the install without a warning, and the Web Store review on the fast
track.
Tying a licence to an installation
A signed licence can be copied: offline verification is blind to the number of machines. That is harmless as long as the key stays private, and the registry above only catches up after the fact, once the harm is done.
src/lib/activation.ts closes that without going back on the principle. The
key is exchanged once, at first installation, for a seal — a second
signed document that names this licence and this device. From then on the
machine never talks to the server again: the seal is verified offline like
the licence. The server, for its part, counts the exchanges.
A rate, not a stock: five activations per rolling ninety days. A hard cap would be paid for in support at every machine change or reset Chrome profile, and would force building a deactivation. A window never bothers someone who is working, and strangles a shared key — the signal of fencing is in the frequency.
The service is server/activate.mjs: bare Node, no dependency, a JSON file
as counter. It does not do accounts, passwords or sessions: the licence
key is the identity. A session would add nothing we do not already have,
and would add a database to defend where there was none.
What leaves the machine, once in its life: the key and a randomly drawn identifier — not a hardware fingerprint, which would drift at the first Chrome update and only bother the one who paid.
No automatic fallback. A grace period granted on network failure would
make blocking the domain the simplest possible crack: one line in
/etc/hosts, no code to touch. The emergency exit is elsewhere, and it
loosens nothing:
- the seal fits in 126 characters. That is a product constraint, not a curiosity — a test guards it. The body is binary (22 bytes) where everything else in the repository is JSON, because a JSON body would cost 200 characters and stop being copyable by hand;
/activate.htmldoes the same exchange from anywhere, and can send the seal to the address carried by the licence — the one Stripe verified at payment, never a typed-in address. A corporate proxy blocks an unknown domain, not the mailbox;- the
agentplan is exempt. An agent runs in a fresh container every time: tying it to a machine would be wrong by the second run. Its pass is protected otherwise — seven or thirty days are not worth fencing.
Two security rules the code applies and the tests guard:
- nothing counts unless it is signed, in both directions. An unsigned refusal produces no durable state, otherwise whoever sits between the machine and the server could switch off licences wholesale. Only the registry — signed — withdraws a right. That is also why there is no pinned certificate: the signature protects the document whatever the channel, including the clipboard, where there is no channel at all;
- the service's domain is hard-coded, not configurable. The signature protects the response, never the request: a fake server would not forge seals, it would collect the keys sent to it. What is not configurable cannot be hijacked by asking nicely.
Finally, sub is displayed in the clear in the settings. That locks nothing
and does not claim to: sharing your key becomes displaying your address on a
colleague's screen, which handles the most frequent case for the price of one
line.
Feedback
POST /api/feedback takes a bug, an element no selector could reach, or a
wish — from an agent through gotcha feedback <bug|selector|feature>
"<message>", from a person through /feedback.html — which the Feedback
card of the extension's settings opens in a tab, the extension itself
calling nothing. The only channel that existed was an email address, and
an agent has none.
The licence key is the only identity, here too. A valid key — whatever
its plan; an agent pass counts as much as a subscription — gives twenty
messages an hour, counted per licence. Without a key, one a day; not a stock
to earn, just enough to report what blocks. An expired, withdrawn or
unreadable key does not close the door: the message goes through the free
lane, and the answer says why the key did not count.
Without a key, what is left is the network address, and it is not kept.
The day's counter is keyed by an HMAC fingerprint of the address, with a salt
drawn at startup and never written — in memory, forgotten within a day or at
the next restart. Nothing on disk, no access log: the privacy page says it in
those terms, and server/feedback.mjs is what makes it true.
The file is the record; the email is the alert. Every message is appended
to /data/feedback.jsonl — one line each, never rewritten, backed up with the
counter — then emailed to GOTCHA_FEEDBACK_TO ([email protected] by
default) through the transport already configured. A failed email changes
nothing for the sender: the file has the message, the log says the mail did
not leave. The write comes before the count: a failed write answers 503 and
does not spend someone's only message of the day. The licensed rate is
replayed from the file at startup, so that a redeploy does not reopen the
counter. The service log carries the identifier and the kind, never the
message.
The attached context is optional and cut silently to its length (--url,
--selector, --command, --contact, the client version); only the message
is refused when it exceeds its 4000 characters, because a silently truncated
message would lose precisely its end. HTTP codes are real — 400, 429 with
Retry-After, 503 — and the answer is a code, never a sentence: sentences
live in the client, in its language. The rule lives in server/feedback.mjs,
without file or network, and the tests exercise it; the wiring is in
activate.mjs, the shared client in src/lib/feedback.ts — without the word
"licence" in its strings, for the day the extension uses it without a host
permission.
What the skill tells the agent: look at the board before writing, report what blocked it, once per problem, with no customer data, and not to retry before the delay the refusal names.
The public board, and the review. Every report shows up on
/tickets.html — and through GET /api/tickets, or gotcha tickets — the
moment it is received, by category, with its date and the status "received".
That is transparency about the delay as much as about the answer. Once read,
the review writes a public title and summary and a decision: accepted
(a confirmed bug, a feasible selector, a retained idea), done with the
version that shipped it, duplicate pointing at the original, declined (out
of scope, not feasible, or not an improvement — the title says which),
needs_info (not reproduced). One status per nuance would have been one more
box to tick, not one more answer. The message itself is never published,
nor the address, nor the licence: the first part promised "what you typed,
and nothing else", and publishing the messages would have broken it. The
server refuses a decision whose public text carries an address or a link
(personal).
Decisions go to /data/triage.jsonl, a second append-only file next to the
first: what people wrote stays as it is, what was decided reads back in order,
and the last line per ticket wins — new reopens. The board is recomputed
on every read from the two files, a few kilobytes under a one-minute cache: no
state in memory, and a restored file is seen without a restart, like the
revocation registry. An internal note goes with each decision; it never leaves
the service.
The review goes through two routes under an operator token —
GET /api/admin/tickets, POST /api/admin/triage — compared in constant
time, kept in the same secrets file as the Stripe keys. It is not an account:
it opens the review and nothing else, and without it the routes answer closed
while the public board stays up. On the workstation, npm run tickets -- list
and npm run tickets -- decide are the reviewer's tool; the project skill
.claude/skills/triage-tickets (in French, outside the npm package) gives an
agent the process, in order: already reported, already shipped, out of scope
by construction, bug reproduced, selector feasible, improvement retained — and
forbids it from fixing anything in the same run: the review qualifies,
Étienne decides.
What is named, and what no longer is
The Web Store forbids obfuscating code and explicitly allows renaming.
Minification erases variable names but not strings: the delivered package
therefore announced assets/license-store-….js, the storage keys license
and usage, and some thirty license_* keys. The first grep -ri licen put
the reader right on the checkpoint.
Fixed without hiding anything: chunkFileNames neutralises chunk names, the
storage keys are profile / tally / registry, the interface keys are
prefixed plan_, and Verdict.license became Verdict.state. All that
remains in dist/ is React's MIT banners and the texts displayed to the
user, which have no reason to lie.
The CLI keeps its explicit names: npm distributes the source, --help
documents GOTCHA_LICENSE_KEY, and masking there would cost clarity without
taking anything from anyone.
None of this prevents modifying the package — against someone who holds the code, code is no defence. The registry, for its part, acts afterwards: on the key in circulation.
Known limitations
- The sound of other applications cannot be captured.
tabCaptureonly reaches the tab's audio.getDisplayMedia({audio:true})only captures system audio on Windows and ChromeOS — on Linux and macOS, the checkbox does not exist. No extension code gets around that limit. - Nothing outside the browser. The scope is the tab, by choice: it avoids the native sharing picker at every recording. The eyedropper follows the same boundary: it picks from the page's rendering, not from Chrome's interface or the rest of the screen.
- The eyedropper picks from a snapshot, not live. It is taken on entering
the mode, and the page is held for the duration of the gesture: an
animation that goes on under the pointer is not reflected in it. Refreshing
continuously is out of reach —
captureVisibleTabis rate-limited to two calls per second. - Native menus appear in no capture. An open
<select>, the context menu, autocompletion: these are system windows, not the tab's rendering. The delay before capture can do nothing about it. - An area taller than the viewport is refused.
captureVisibleTabonly sees the visible part; accepting would silently produce a truncated image. - After navigating to another origin, the red frame does not come back.
Chrome then revokes the
activeTabgrant, and the project asks for no host permission. The recording goes on normally; only the visual marker and the cursor trace are missing for that page. - GIF is capped at 800 px wide and 12 fps. Beyond that, the weight exceeds the attachment limits of support tools.
- npm vulnerabilities in
vite/esbuild: they concern the development server, not the delivered bundle. Vite 5.4.21 is the last of its line; moving up a major version would require validating CRXJS 2.7 with it.
Structure
src/
├── background/ service worker: orchestration, image capture, state
├── content/ framing overlay, cursor trace (injected on demand)
├── offscreen/ MediaRecorder and audio mixing
├── controller/ recording control window
├── popup/ launch
├── editor/ image and video editors, rendering, history
├── library/ library
├── options/ settings
├── i18n/ translation catalogues and the t() function
├── lib/ types, IndexedDB, messaging, naming, export, colour
│ └── encode/ frame extraction, cursor, GIF, MP4/WebM
└── ui/ style tokens and icons
cli/ Gotcha on the command line, for agents
├── index.ts commands
├── session.ts detached browser, CDP reconnection
├── annotate.ts CSS selectors → rectangles → annotations
├── shot.ts framing, capture, composition
└── render-page.ts rendering executed in the browser (reuses editor/render)
skills/gotcha/ the Claude Code skill
public/
└── _locales/ generated from src/i18n — do not edit by hand