staysfixed 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/guards.md ADDED
@@ -0,0 +1,226 @@
1
+ # Guards
2
+
3
+ A guard is one check per bug that has already been fixed once. Its only job is to
4
+ fail on the day that bug comes back.
5
+
6
+ That is the whole idea. A guard is not a unit test and it is not a spec. Nobody
7
+ writes a guard for behaviour that has never broken. You write one the moment you
8
+ finish fixing something, while you still remember exactly what went wrong, and
9
+ then you never think about it again until the day it saves you.
10
+
11
+ Picture checks catch what you can see. Guards catch what you cannot: a keyboard
12
+ shortcut that stopped firing, a build that started emitting the wrong file, a
13
+ session that stopped being cleared on logout.
14
+
15
+ ---
16
+
17
+ ## Where they live
18
+
19
+ A folder of plain JavaScript files, `.staysfixed/guards/` by default. Files
20
+ starting with `_` or `.` are skipped, which is a handy way to park one. The
21
+ default export is the guard; a file may also export an array of guards, or
22
+ several named exports.
23
+
24
+ ```js
25
+ export default {
26
+ name: 'the sidebar still collapses',
27
+ because: 'A CSS rename broke the toggle handler and it shipped.',
28
+ async run(app) {
29
+ await app.open('/');
30
+ await app.click('[data-action="toggle-sidebar"]');
31
+ await app.expect('the sidebar is hidden', async () => !(await app.page.visible('.sidebar')));
32
+ },
33
+ };
34
+ ```
35
+
36
+ They run inside `staysfixed check`, in the same real app the pictures are taken
37
+ from. `staysfixed check --guards-only` runs just the guards, which is much faster
38
+ and is what you want when your edit could not possibly change how anything looks.
39
+
40
+ ---
41
+
42
+ ## The name is the point
43
+
44
+ The name is the whole handover. It is what prints when the guard fails, what goes
45
+ in the report, what an agent reads back before deciding whether it broke
46
+ something, and what a person has to judge in five seconds at one in the morning.
47
+ Six months from now the name is all that is left of the bug.
48
+
49
+ So the tool refuses names that only make sense to whoever typed them. This is
50
+ enforced, not suggested — a bad name is rejected when the guards load, with an
51
+ explanation and, where the tool can honestly build one, a rewrite.
52
+
53
+ **The rule:** write what the app is supposed to do, in the words you would say out
54
+ loud. At least three words. Present tense. No test ids.
55
+
56
+ ### What gets refused
57
+
58
+ | Refused | Why |
59
+ | --- | --- |
60
+ | `sidebar_collapse_test` | A code identifier, not a sentence. Guard names are printed to people, so use spaces and ordinary words. |
61
+ | `#4412` or `BUG-88` | An issue number. The number tells nobody what broke — put it in `link` and use the name to say what should still work. |
62
+ | `test sidebar collapse` | Starts with a test word. That describes a test, not the app. Drop it. |
63
+ | `should collapse` | Same, and only two words. |
64
+ | `sidebar` | One word. It says which area it touches, not what should still be true. |
65
+ | `SIDEBAR COLLAPSES` | ALL CAPS reads like shouting, not like a sentence. |
66
+ | `src/ui/sidebar.js` | A file name. Say what the app should still do, not where the code lives. |
67
+ | `Sidebar#collapse` | `#` and `::` read like code references. Put the reference in `link`. |
68
+ | A 140-character paragraph | Over 120 characters. The name is a short sentence; the story goes in `because`. |
69
+
70
+ ### What passes
71
+
72
+ - `the sidebar still collapses`
73
+ - `prices still show two decimals`
74
+ - `logging out clears the session`
75
+ - `the export button still produces a csv`
76
+ - `keyboard shortcuts still work after the modal closes`
77
+ - `the settings window remembers its size`
78
+
79
+ Notice they are all sentences you could say to a colleague, and each one names
80
+ the behaviour rather than the code.
81
+
82
+ Two guards may not share a name. When one fails, the report shows the name — and
83
+ if two guards share it, nobody can tell which one broke.
84
+
85
+ ---
86
+
87
+ ## The fields
88
+
89
+ ```js
90
+ export default {
91
+ // Required. Plain language, three words or more.
92
+ name: 'the sidebar still collapses',
93
+
94
+ // When it was fixed. Free text — a date, a version, whatever you would say.
95
+ fixed: '2026-08-14',
96
+
97
+ // The story of the bug, in a sentence or two. Printed underneath the failure,
98
+ // so somebody who has never seen this bug knows what they are looking at.
99
+ because: 'A CSS refactor renamed .sidebar--open everywhere except the toggle handler.',
100
+
101
+ // An issue, a commit, a note. Anywhere to read more.
102
+ link: 'https://github.com/asadev/staysfixed/issues/12',
103
+
104
+ // Park it without deleting it. `check` reports it as left out on purpose.
105
+ skip: false,
106
+
107
+ // How long it gets before it is called failed. Default 30000.
108
+ timeoutMs: 20_000,
109
+
110
+ // The check itself.
111
+ async run(app) { /* ... */ },
112
+ };
113
+ ```
114
+
115
+ ---
116
+
117
+ ## What `app` gives you
118
+
119
+ | | |
120
+ | --- | --- |
121
+ | `app.page` | The full page. `goto`, `click`, `type`, `press`, `hover`, `waitFor`, `waitForGone`, `scrollTo`, `wait`, `evaluate`, `visible`, `exists`, `textOf`, `count`, `boxOf`, `url`, `title`, `shoot`, `setViewport`, `consoleErrors`. |
122
+ | `app.open(path)` | Shorthand for `page.goto`. |
123
+ | `app.click(selector)` | Shorthand for `page.click`. |
124
+ | `app.expect(sentence, check)` | An assertion, in plain language. |
125
+ | `app.run(cmd, opts)` | Run a shell command in the project root. Returns `{ code, stdout, stderr }`. A non-zero exit is returned, never thrown — whether it means failure is the guard's decision. |
126
+ | `app.read(file)` | Read a project file as text. |
127
+ | `app.project` | The resolved config and paths. |
128
+
129
+ ---
130
+
131
+ ## The `expect` style
132
+
133
+ An assertion is a sentence plus a check, never a bare comparison:
134
+
135
+ ```js
136
+ await app.expect('the sidebar is hidden', async () => !(await app.page.visible('.sidebar')));
137
+ ```
138
+
139
+ When it fails, the terminal says:
140
+
141
+ ```
142
+ ✗ the sidebar still collapses This should still be true, and it is not: "the sidebar is hidden". 0.4s
143
+ expected: the sidebar is hidden
144
+ why this guard exists: A CSS refactor renamed .sidebar--open everywhere except the toggle handler.
145
+ ```
146
+
147
+ Anyone can act on that, including somebody who has never opened this repository.
148
+ Compare it with what a normal assertion library would have printed —
149
+ `AssertionError: expected false to be true` — which tells you nothing at all.
150
+
151
+ The check fails when it returns something falsy, or throws. Anything truthy
152
+ passes. Some shapes that read well:
153
+
154
+ ```js
155
+ // A thing is on screen
156
+ await app.expect('the invoice total is on screen', () => app.page.visible('[data-total]'));
157
+
158
+ // A thing is gone
159
+ await app.expect('the error banner is gone', async () => !(await app.page.exists('.error-banner')));
160
+
161
+ // Text is right
162
+ await app.expect('the total still shows two decimals', async () => {
163
+ const text = await app.page.textOf('[data-total]');
164
+ return /^\$\d+\.\d{2}$/.test(text.trim());
165
+ });
166
+
167
+ // A count
168
+ await app.expect('all five plans are listed', async () => (await app.page.count('.plan-card')) === 5);
169
+
170
+ // Nothing to do with the screen at all
171
+ await app.expect('the production build still succeeds', async () => {
172
+ const { code } = await app.run('npm run build');
173
+ return code === 0;
174
+ });
175
+
176
+ await app.expect('the collapsed class is still defined', async () => {
177
+ const css = await app.read('src/styles/sidebar.css');
178
+ return css.includes('.sidebar--collapsed');
179
+ });
180
+ ```
181
+
182
+ Write the sentence first. If you cannot say in plain words what should be true,
183
+ the guard is not ready to be written.
184
+
185
+ ---
186
+
187
+ ## Writing a good one
188
+
189
+ **Check the thing that actually broke, not only the thing you can see.** If the
190
+ bug was a class name that no longer matched, check the class name as well as the
191
+ visible result. A later refactor could hide the sidebar a different way, and the
192
+ guard should still hold.
193
+
194
+ **Check that the fix did not create a new trap.** A collapse you cannot undo is a
195
+ worse bug than the one you were fixing. Toggle it back.
196
+
197
+ **Keep it to one bug.** A guard that checks four unrelated things fails with one
198
+ name and four possible causes.
199
+
200
+ **Put the story in `because`, not the name.** The name is a sentence. The story
201
+ is a paragraph, and the tool prints it right underneath the failure.
202
+
203
+ **Do not add a guard for something that has never broken.** That is a test, and
204
+ it belongs in your test suite. Guards are a memory of real failures, and their
205
+ value comes from every single one of them mattering.
206
+
207
+ ---
208
+
209
+ ## The rule that keeps them honest
210
+
211
+ **A guard that flakes twice gets fixed or deleted. Never tolerated.**
212
+
213
+ Every run is remembered. When a guard changes its mind while the git sha and the
214
+ working tree stood still, that is a flake. Past `flakeLimit` — 2 by default — the
215
+ guard is condemned, and `check` says so in red until a person deals with it.
216
+
217
+ There is no option to tolerate a condemned guard, and there never will be. A
218
+ guard nobody believes is worse than no guard: it trains everybody to ignore red,
219
+ and then the real one goes unread. Usually the fix is easy — wait for the right
220
+ thing rather than a fixed delay, use `waitFor` instead of `wait`, or stop
221
+ depending on data that changes.
222
+
223
+ ```
224
+ staysfixed flake # the register
225
+ staysfixed flake --clear "the sidebar still collapses" # forgive one that is genuinely fixed
226
+ ```
@@ -0,0 +1,315 @@
1
+ # How it stays stable
2
+
3
+ A picture check is only worth having if it is silent when nothing changed. The
4
+ moment it fails for a reason nobody caused, people start ignoring it — and once
5
+ they ignore it, the real regression walks through with everything else. So most
6
+ of the engineering in Stays Fixed is not in taking the picture or comparing it.
7
+ It is in removing every reason the picture could change on its own.
8
+
9
+ This page is the long version: each source of wobble, what actually goes wrong,
10
+ what the tool does about it, and — the part that usually goes unsaid — what it
11
+ cannot fix.
12
+
13
+ ---
14
+
15
+ ## 1. Time
16
+
17
+ **What goes wrong.** Almost every app puts time on the screen: a "3 minutes ago",
18
+ a copyright year, a date column, a greeting that says good morning. Any of them
19
+ makes a picture that never matches itself twice. Worse are the invisible ones: a
20
+ component that keys off `Date.now()`, a cache that expires, a token that looks
21
+ stale on the second run.
22
+
23
+ Time zone is the sneakier half. The same instant renders as `14:00` in London and
24
+ `09:00` in New York, so a picture approved on somebody's laptop fails in CI for a
25
+ reason that has nothing to do with the code.
26
+
27
+ **What the tool does.** Two layers, because neither is enough alone.
28
+
29
+ The Chrome DevTools Protocol is told which time zone and locale the renderer
30
+ believes it is in. That reaches `Date.prototype.toString`, `getTimezoneOffset`
31
+ and all the ICU date formatters — places page script cannot touch. The defaults
32
+ are UTC and `en-US`.
33
+
34
+ Then a script injected before any of the app's own code replaces `Date` with a
35
+ subclass whose `new Date()` and `Date.now()` return a fixed instant, by default
36
+ `2026-01-01T12:00:00.000Z`. A subclass rather than a proxy, so `instanceof`,
37
+ every `Date.prototype` method and all date arithmetic keep working untouched.
38
+
39
+ The instant is frozen but the app is not dead: real timers still fire, so a
40
+ spinner that waits 300ms still finishes. Only the *reading* of the clock is
41
+ pinned. `Emulation.setVirtualTimePolicy` — which would genuinely stop time —
42
+ is deliberately not used, because it also stops the app.
43
+
44
+ **What it cannot fix.** Time that comes from your server rather than the browser.
45
+ If the API returns `"created_at"` and the app renders it, the picture depends on
46
+ your database, not on the clock. Seed the data, use `network: 'replay'`, or mask
47
+ the column. And `Date()` called without `new`, which returns a string — app code
48
+ effectively never does this and minifiers never produce it, but it is the one
49
+ thing the subclass gives up.
50
+
51
+ ---
52
+
53
+ ## 2. Movement
54
+
55
+ **What goes wrong.** Anything that moves is a picture that disagrees with itself.
56
+ A fade-in caught at 60% opacity. A skeleton loader shimmering. A carousel one
57
+ slide further along. A modal mid-scale. A `<video>` that has autoplayed to a
58
+ different frame. Timing is what decides which frame you got, and timing is never
59
+ the same twice.
60
+
61
+ **What the tool does.** Three layers, because each catches what the others miss.
62
+
63
+ CSS kills declared animations and transitions — including delayed ones, which
64
+ would otherwise fire later, and infinite ones, which hold a compositor layer
65
+ open. `will-change` is forced back to `auto`: a promoted element is rasterised on
66
+ different pixel boundaries, so taking the promotion away puts it back on the same
67
+ pixels every run. Smooth scrolling becomes instant scrolling.
68
+
69
+ Page script kills what CSS cannot reach: running Web Animations are cancelled
70
+ (cancelled, not finished — finishing an infinite spinner is meaningless and
71
+ finishing a fade-out would hide content you wanted to see), `<video>` elements
72
+ are paused and seeked to frame zero, and `Element.prototype.animate` is replaced
73
+ with a stub that reports itself already finished. The stub matters: libraries
74
+ await `animation.finished` before showing the next thing, so simply deleting
75
+ `animate()` would leave those apps hung half-rendered forever. A mutation
76
+ observer re-sweeps whenever the page changes, because a router-mounted spinner
77
+ or a lazily-faded image brings its own animations with it.
78
+
79
+ The protocol is told the machine prefers reduced motion, which is the only thing
80
+ that stops a well-behaved app starting an animation in the first place.
81
+
82
+ **What it cannot fix.** An animated GIF. A `<canvas>` draw loop that ignores
83
+ `requestAnimationFrame`. A WebGL scene. Anything driven by a `setInterval` that
84
+ paints directly. Mask those regions.
85
+
86
+ ---
87
+
88
+ ## 3. Randomness
89
+
90
+ **What goes wrong.** Randomness reaches a picture in more places than people
91
+ expect: a shuffled list, a placeholder avatar colour, a chart's jitter, a React
92
+ key printed into a data attribute, a generated id that ends up in the
93
+ accessibility tree and then in a tooltip. Any one of them makes a screen that
94
+ never matches itself.
95
+
96
+ **What the tool does.** `Math.random`, `crypto.getRandomValues` and
97
+ `crypto.randomUUID` are all replaced with a seeded generator — mulberry32, 32
98
+ bits of state, well distributed enough that a shuffled list still looks
99
+ shuffled, and identical on every machine on every run. The seed defaults to
100
+ `20260101` and is configurable.
101
+
102
+ The real functions stay reachable on `window.__staysfixed_realRandom`, because a
103
+ handful of apps genuinely need unpredictable bytes — a crypto key, a WebRTC
104
+ session — and would break rather than merely look different.
105
+
106
+ **What it cannot fix.** Randomness that happened on your server. An API that
107
+ returns a random featured item produces a different picture no matter what the
108
+ browser does.
109
+
110
+ ---
111
+
112
+ ## 4. The network
113
+
114
+ **What goes wrong.** This is the single biggest reason a picture stops matching
115
+ tomorrow. A page that fetches an avatar from a CDN, a font from a third party, an
116
+ analytics beacon or a live feed is a page whose picture depends on somebody
117
+ else's server, on today's weather in their data centre, and on the office wifi.
118
+ A stock photo service rotates its image and your check fails at 3am for a change
119
+ nobody made.
120
+
121
+ **What the tool does.** Every request is intercepted, in one of three modes.
122
+
123
+ `live` lets everything through and counts it, so `--verbose` can show you what
124
+ your app is actually reaching for.
125
+
126
+ `block-external` — the default — lets the app's own origin, localhost and an
127
+ allow list out, and refuses everybody else. A tiny glob syntax covers the allow
128
+ list: `*` stops at a path separator, `**` crosses them.
129
+
130
+ `replay` records every reply once into `.staysfixed/fixtures/` and then serves
131
+ those same bytes forever. This is usually the only way a desktop app or an
132
+ API-heavy page renders the same screen twice. Those recordings belong in git —
133
+ they are part of the promise, not part of the evidence.
134
+
135
+ Every paused request gets exactly one answer: continue, fail, or fulfil. A
136
+ request that is paused and never answered stalls the page silently, which looks
137
+ exactly like a hung app, so the code is careful about it. On replay,
138
+ `content-encoding`, `content-length` and `transfer-encoding` headers are dropped:
139
+ the recorded body was handed over already decoded and whole, so telling the
140
+ browser it is gzipped makes the browser throw the reply away and the "replayed"
141
+ run shows a blank page for no visible reason.
142
+
143
+ **What it cannot fix.** Your own backend returning different data. If the app
144
+ talks to a live database, the picture is a picture of that database. Seed it,
145
+ replay it, or accept that the screen is not checkable.
146
+
147
+ ---
148
+
149
+ ## 5. Fonts and images arriving late
150
+
151
+ **What goes wrong.** The most common cause of a picture that "randomly" fails is
152
+ a font or an image that had not landed yet. Text reflows when the real face
153
+ replaces the fallback — every line moves. A missing image collapses a card and
154
+ everything below it slides up. Neither is a bug in the app, and neither is worth
155
+ waking a human for.
156
+
157
+ **What the tool does.** Waits for both before the shutter. `document.fonts.ready`
158
+ is treated as a starting gun rather than a finish line — it can settle while a
159
+ face requested a moment ago is still in flight — so the tool then polls
160
+ `document.fonts.status` until the browser itself agrees everything is loaded, up
161
+ to a bounded number of tries. Images are waited on the same way.
162
+
163
+ Every wait races a real `setTimeout` rather than a clock reading, because the
164
+ clock is frozen and `Date.now()` would never reach the deadline.
165
+
166
+ **What it cannot fix.** A font that genuinely is not available on the machine
167
+ taking the picture. If your CSS falls back to a system font, the picture is of
168
+ the fallback, and a different machine has a different fallback. Self-host your
169
+ fonts, or allow the font host through `networkAllow`.
170
+
171
+ ---
172
+
173
+ ## 6. Layout that shifts after it looks finished
174
+
175
+ **What goes wrong.** A chart draws itself a beat late. A virtualised list
176
+ measures its rows and re-lays them out. A scrollbar decides it exists and takes
177
+ 15 pixels of width away from everything. A late `ResizeObserver` fires. The page
178
+ looked ready and then moved.
179
+
180
+ **What the tool does.** This is what the settle loop is for, and it is the reason
181
+ picture checks can be trusted at all. Take the photo, take it again, and only
182
+ accept it once two photos in a row agree. Everything else in the freeze layer
183
+ removes a *reason* to change; settle is the net for the reasons nobody thought
184
+ of.
185
+
186
+ Before the first shot it waits for `load`, gives any surviving animations a
187
+ bounded grace period to end on their own, and waits two animation frames — the
188
+ first lets the browser run what was scheduled, the second only arrives once that
189
+ work has actually been painted. Then it shoots on an interval until `frames`
190
+ consecutive photos are identical, or `timeoutMs` runs out. Two identical frames
191
+ is the default; `maxDriftPixels` is 0, meaning identical means identical.
192
+
193
+ The comparison between two settle frames starts by comparing the compressed PNG
194
+ bytes, which costs nothing and is what happens almost every time. Only when they
195
+ differ does it pay to decode both.
196
+
197
+ If it times out it hands back the last photo anyway and records that it never
198
+ settled, rather than failing — a screen that will not hold still is worth seeing.
199
+
200
+ **What it cannot fix.** A page that never settles: a live-updating dashboard, a
201
+ running timer, a chat that polls. Mask the moving region, or do not check that
202
+ screen.
203
+
204
+ ---
205
+
206
+ ## 7. Focus rings
207
+
208
+ **What goes wrong.** Whichever element happened to have focus when the last step
209
+ finished draws an outline. Which element that is depends on click timing, so it
210
+ changes between runs on identical code — and the approved picture has the ring on
211
+ a different element, or on none.
212
+
213
+ **What the tool does.** Blurs the active element immediately before the shutter,
214
+ unless it is `document.body`, then forces a layout flush and waits two frames so
215
+ the removal is actually painted rather than merely calculated. It also resets
216
+ scroll to the top unless the recipe scrolled somewhere on purpose, for the same
217
+ reason: a page left scrolled by a click-into-view photographs differently every
218
+ run.
219
+
220
+ **What it cannot fix.** A focus ring you actually want in the picture. If the
221
+ screen you are checking is "the search box is focused", the blur will undo it —
222
+ capture that state with a mask around the rest, or check it with a guard instead.
223
+
224
+ ---
225
+
226
+ ## 8. Scrollbars and the text caret
227
+
228
+ **What goes wrong.** A scrollbar appears when content grows by one line, and
229
+ taking 15 pixels of width away reflows the entire page. Whether it is an overlay
230
+ scrollbar or a classic one is an operating-system setting, so the same code
231
+ photographs differently on two machines. Meanwhile a text cursor blinks: half the
232
+ runs catch it on, half catch it off.
233
+
234
+ **What the tool does.** Hides both. Scrollbars go via CSS
235
+ (`::-webkit-scrollbar`, `scrollbar-width: none`) and via Chrome's
236
+ `--hide-scrollbars` flag; the caret goes via `caret-color: transparent`. Both are
237
+ on by default and both can be turned off in `freeze`.
238
+
239
+ **What it cannot fix.** A custom scrollbar your app draws itself out of divs.
240
+ That is content, and it is checked like content.
241
+
242
+ ---
243
+
244
+ ## 9. GPU rasterisation
245
+
246
+ **What goes wrong.** The same page, rasterised through two different graphics
247
+ drivers, is not the same pixels. Gradients band differently. A rotated element's
248
+ edges land a fraction differently. A composited layer is rounded to a different
249
+ boundary. None of it is visible to a person and all of it is visible to a pixel
250
+ comparison.
251
+
252
+ **What the tool does.** The browser is launched with GPU rasterisation off
253
+ (`--disable-gpu`), runtime Skia optimisations off, partial raster off, composited
254
+ antialiasing off, and the colour profile forced to sRGB. Software rendering is
255
+ slower and it is the same everywhere, which is the trade this tool exists to
256
+ make. The device scale factor is forced rather than inherited from the display,
257
+ so plugging in an external monitor does not change your pictures.
258
+
259
+ **What it cannot fix.** Software rasterisation still differs a little between
260
+ Chrome versions. That is why the CI workflow pins Chrome's major version, and why
261
+ bumping it is a deliberate act followed by re-approving what moved.
262
+
263
+ ---
264
+
265
+ ## 10. Operating-system text smoothing
266
+
267
+ **What goes wrong.** macOS, Windows and Linux each draw text differently, and
268
+ within one OS the answer changes with the display, with whether the window is on
269
+ an external monitor, and with the graphics driver version. Subpixel antialiasing
270
+ puts colour fringes on glyph edges. Hinting snaps stems to the pixel grid.
271
+ Different fake-bold synthesis appears when a weight is missing.
272
+
273
+ **What the tool does.** Pins all of it: `-webkit-font-smoothing: antialiased`,
274
+ `text-rendering: geometricPrecision` (which stops glyph advances being rounded to
275
+ whole pixels — that rounding is what makes a line of text reflow by one pixel
276
+ between runs), and `font-synthesis: none` (so a fake bold is never invented,
277
+ because inventing one is a per-machine decision). At the browser level, font
278
+ render hinting is off, LCD text is off, and subpixel positioning is off.
279
+
280
+ This trades a little fidelity for pictures that do not change when the operating
281
+ system changes its mind. It is worth it.
282
+
283
+ **What it cannot fix — and this is the honest limit of the whole tool.** A
284
+ picture is tied to the operating system that took it. A macOS-approved picture
285
+ will not match on Linux, no matter how many flags are set: the font stack is
286
+ different, the fallback faces are different, and the text rasteriser is a
287
+ different piece of code. Approved pictures are stamped with the platform that
288
+ took them (`darwin-arm64`, `linux-x64`) and comparing across platforms warns you.
289
+
290
+ There are two honest ways to live with this:
291
+
292
+ 1. **Take the pictures in one place.** Approve on CI, or approve on one machine
293
+ everyone shares. This is the simpler answer and it is the one most projects
294
+ should pick.
295
+ 2. **Approve per platform.** Keep a separate approved folder per platform by
296
+ setting `dir` from an environment variable, and approve on each. More work,
297
+ but it lets everybody run `check` locally.
298
+
299
+ ---
300
+
301
+ ## The last line of defence: the flake register
302
+
303
+ Even with all of the above, something will eventually wobble. So the tool keeps
304
+ score. Every run appends a status per check. When a check changes its mind while
305
+ the git sha and the working tree stood still, that is recorded as a flake — that
306
+ is the only honest definition, because anything looser blames you for your own
307
+ edits. A check that only passes on the retry inside a single run counts too.
308
+
309
+ Past `flakeLimit` (2 by default) the check is **condemned**, and `check` says so
310
+ in red until a person deals with it. There is no option to tolerate it. Fix it or
311
+ delete it — a check nobody believes is worse than no check, and the whole tool is
312
+ built on being believed.
313
+
314
+ `staysfixed flake` shows the register. `staysfixed flake --clear <name>` forgives
315
+ a check once it has genuinely been fixed.