staysfixed 0.1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
@@ -0,0 +1,579 @@
1
+ /**
2
+ * The one page a human actually looks at before deciding.
3
+ *
4
+ * It is a single file with everything inside it — pictures as data URIs, styles
5
+ * and the comparison sliders inline — because it has to open by double-clicking
6
+ * it on a laptop with no internet, out of a CI artifact, or over a screen share.
7
+ * That rules out fonts, CDNs and frameworks; nothing here fetches anything.
8
+ */
9
+
10
+ import fsp from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { verdictFor, plainTime, countText } from './console.js';
13
+
14
+ /**
15
+ * @param {unknown} s
16
+ * @returns {string}
17
+ */
18
+ export function escapeHtml(s) {
19
+ return String(s ?? '')
20
+ .replace(/&/g, '&')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;');
25
+ }
26
+
27
+ /**
28
+ * Read a PNG into a data URI. A missing file is not fatal — the report says so
29
+ * instead of failing, because a half-report still beats no report.
30
+ * @param {string|undefined} file
31
+ * @returns {Promise<string|null>}
32
+ */
33
+ async function dataUri(file) {
34
+ if (!file) return null;
35
+ try {
36
+ const buf = await fsp.readFile(file);
37
+ return `data:image/png;base64,${buf.toString('base64')}`;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {number} n
45
+ * @returns {string}
46
+ */
47
+ function pad2(n) {
48
+ return String(n).padStart(2, '0');
49
+ }
50
+
51
+ /**
52
+ * A timestamp written the way a person writes one, without depending on the
53
+ * machine's locale (two machines must produce the same report).
54
+ * @param {string} iso
55
+ * @returns {string}
56
+ */
57
+ function stampText(iso) {
58
+ const t = Date.parse(String(iso));
59
+ if (Number.isNaN(t)) return '';
60
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
61
+ const d = new Date(t);
62
+ return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}, ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
63
+ }
64
+
65
+ /**
66
+ * @param {number} n
67
+ * @param {string} one
68
+ * @param {string} many
69
+ * @returns {string}
70
+ */
71
+ function plural(n, one, many) {
72
+ return n === 1 ? one : many;
73
+ }
74
+
75
+ /**
76
+ * @param {import('../types.js').PictureResult} p
77
+ * @returns {string}
78
+ */
79
+ function changeSentence(p) {
80
+ const pixels = p.diffPixels ?? 0;
81
+ const share = typeof p.diffRatio === 'number' ? ` — ${(p.diffRatio * 100).toFixed(2)}% of the picture` : '';
82
+ const resized =
83
+ p.approvedSize && p.size && (p.approvedSize.width !== p.size.width || p.approvedSize.height !== p.size.height)
84
+ ? ` It is also a different size now: ${p.approvedSize.width}×${p.approvedSize.height} became ${p.size.width}×${p.size.height}.`
85
+ : '';
86
+ return `${countText(pixels)} ${plural(pixels, 'pixel', 'pixels')} moved${share}.${resized}`;
87
+ }
88
+
89
+ /**
90
+ * @param {string|null} uri
91
+ * @param {string} label
92
+ * @returns {string}
93
+ */
94
+ function figure(uri, label) {
95
+ const body = uri
96
+ ? `<img src="${uri}" alt="${escapeHtml(label)}" loading="lazy">`
97
+ : `<p class="gone">This picture is not on disk any more.</p>`;
98
+ return `<figure><figcaption>${escapeHtml(label)}</figcaption>${body}</figure>`;
99
+ }
100
+
101
+ /**
102
+ * @param {string} command
103
+ * @returns {string}
104
+ */
105
+ function commandRow(command) {
106
+ return `<p class="cmd"><code>${escapeHtml(command)}</code><button class="copy" type="button" data-copy="${escapeHtml(command)}">copy</button></p>`;
107
+ }
108
+
109
+ /**
110
+ * @param {import('../types.js').PictureResult} p
111
+ * @param {{approved: string|null, actual: string|null, diff: string|null}} shots
112
+ * @returns {string}
113
+ */
114
+ function changedCard(p, shots) {
115
+ const out = [];
116
+ out.push('<section class="card change">');
117
+ out.push(`<h3><code>${escapeHtml(p.name)}</code> <span class="tag bad">changed</span></h3>`);
118
+ if (p.describe) out.push(`<p class="desc">${escapeHtml(p.describe)}</p>`);
119
+ out.push(`<p class="what">${escapeHtml(changeSentence(p))}</p>`);
120
+
121
+ out.push('<div class="three">');
122
+ out.push(figure(shots.approved, 'Approved'));
123
+ out.push(figure(shots.actual, 'Now'));
124
+ out.push(figure(shots.diff, 'What moved'));
125
+ out.push('</div>');
126
+
127
+ if (shots.approved && shots.actual) {
128
+ out.push('<h4>Drag the line to compare</h4>');
129
+ out.push('<div class="compare" data-compare style="--pos:50">');
130
+ out.push('<div class="slider-stack">');
131
+ out.push(`<img src="${shots.approved}" alt="approved">`);
132
+ out.push(`<img class="after" src="${shots.actual}" alt="now">`);
133
+ out.push('<div class="bar"></div>');
134
+ out.push('<span class="edge left">approved</span><span class="edge right">now</span>');
135
+ out.push('</div>');
136
+ out.push('<input type="range" min="0" max="100" value="50" aria-label="Compare approved and now">');
137
+ out.push('</div>');
138
+
139
+ out.push('<h4>Or fade one into the other</h4>');
140
+ out.push('<div class="fadebox" data-fadebox style="--fade:0.5">');
141
+ out.push('<div class="fade-stack">');
142
+ out.push(`<img src="${shots.approved}" alt="approved">`);
143
+ out.push(`<img class="over" src="${shots.actual}" alt="now">`);
144
+ out.push('</div>');
145
+ out.push('<input type="range" min="0" max="100" value="50" aria-label="Fade between approved and now">');
146
+ out.push('</div>');
147
+ }
148
+
149
+ out.push(errorsBlock(p.consoleErrors));
150
+ out.push('<p class="hint">If this is what you meant to change, approve it. If it is not, you found a regression.</p>');
151
+ out.push(commandRow(`staysfixed approve ${p.name}`));
152
+ out.push('</section>');
153
+ return out.join('\n');
154
+ }
155
+
156
+ /**
157
+ * @param {string[]|undefined} errors
158
+ * @returns {string}
159
+ */
160
+ function errorsBlock(errors) {
161
+ const list = errors ?? [];
162
+ if (list.length === 0) return '';
163
+ const items = list.slice(0, 8).map((e) => `<li>${escapeHtml(e)}</li>`).join('');
164
+ const more = list.length > 8 ? `<li class="muted">and ${countText(list.length - 8)} more</li>` : '';
165
+ return `<div class="errors"><p>The app itself printed ${countText(list.length)} ${plural(list.length, 'error', 'errors')} while this screen was open:</p><ul>${items}${more}</ul></div>`;
166
+ }
167
+
168
+ /**
169
+ * @param {import('../types.js').PictureResult} p
170
+ * @param {string|null} actual
171
+ * @returns {string}
172
+ */
173
+ function newCard(p, actual) {
174
+ const out = [];
175
+ out.push('<section class="card fresh">');
176
+ out.push(`<h3><code>${escapeHtml(p.name)}</code> <span class="tag warn">new</span></h3>`);
177
+ if (p.describe) out.push(`<p class="desc">${escapeHtml(p.describe)}</p>`);
178
+ out.push('<p class="what">Nobody has ever approved this screen, so there is nothing to compare it against. Look at it. If it is right, approve it — and from then on Stays Fixed will tell you the day it stops looking like this.</p>');
179
+ out.push('<div class="one">');
180
+ out.push(figure(actual, 'This is what it looks like now'));
181
+ out.push('</div>');
182
+ out.push(errorsBlock(p.consoleErrors));
183
+ out.push(commandRow(`staysfixed approve ${p.name}`));
184
+ out.push('</section>');
185
+ return out.join('\n');
186
+ }
187
+
188
+ /**
189
+ * @param {import('../types.js').PictureResult} p
190
+ * @param {string|null} actual
191
+ * @returns {string}
192
+ */
193
+ function troubleCard(p, actual) {
194
+ const label = p.status === 'missing' ? 'the approved picture is gone' : p.status === 'flaky' ? 'changed its mind between tries' : 'could not be photographed';
195
+ const out = [];
196
+ out.push('<section class="card trouble">');
197
+ out.push(`<h3><code>${escapeHtml(p.name)}</code> <span class="tag bad">${escapeHtml(p.status)}</span></h3>`);
198
+ if (p.describe) out.push(`<p class="desc">${escapeHtml(p.describe)}</p>`);
199
+ out.push(`<p class="what">${escapeHtml(p.message || label)}</p>`);
200
+ if (actual) out.push(`<div class="one">${figure(actual, 'What the run managed to capture')}</div>`);
201
+ out.push(errorsBlock(p.consoleErrors));
202
+ if (p.status === 'missing') out.push(commandRow(`staysfixed approve ${p.name}`));
203
+ out.push('</section>');
204
+ return out.join('\n');
205
+ }
206
+
207
+ /**
208
+ * @param {import('../types.js').GuardResult[]} guards
209
+ * @returns {string}
210
+ */
211
+ function guardsSection(guards) {
212
+ if (guards.length === 0) return '';
213
+ const failed = guards.filter((g) => g.status === 'failed');
214
+ const out = [];
215
+ out.push('<h2>Guards</h2>');
216
+ out.push(
217
+ `<p class="lead">${
218
+ failed.length === 0
219
+ ? `All ${countText(guards.length)} ${plural(guards.length, 'bug', 'bugs')} that were fixed are still fixed.`
220
+ : `${countText(failed.length)} of ${countText(guards.length)} bugs that were fixed ${plural(failed.length, 'is', 'are')} back.`
221
+ }</p>`,
222
+ );
223
+ out.push('<section class="card guards"><ul class="guardlist">');
224
+ for (const g of guards) {
225
+ const state = g.status === 'passed' ? 'good' : g.status === 'skipped' ? 'muted' : 'bad';
226
+ out.push(`<li class="${state}">`);
227
+ out.push(`<span class="dot"></span><span class="gname">${escapeHtml(g.name)}</span>`);
228
+ if (g.status === 'failed') {
229
+ if (g.failedAt) out.push(`<div class="claim">expected: ${escapeHtml(g.failedAt)}</div>`);
230
+ if (g.message && g.message !== g.failedAt) out.push(`<div class="claim">${escapeHtml(g.message)}</div>`);
231
+ if (g.because) out.push(`<div class="because">Why this guard exists: ${escapeHtml(g.because)}</div>`);
232
+ } else if (g.status === 'skipped') {
233
+ out.push('<div class="note">left out on purpose</div>');
234
+ }
235
+ out.push('</li>');
236
+ }
237
+ out.push('</ul></section>');
238
+ return out.join('\n');
239
+ }
240
+
241
+ /**
242
+ * @param {string[]} names
243
+ * @returns {string}
244
+ */
245
+ function condemnedSection(names) {
246
+ if (names.length === 0) return '';
247
+ const items = names.map((n) => `<li><code>${escapeHtml(n)}</code></li>`).join('');
248
+ return [
249
+ '<h2>These checks keep changing their mind</h2>',
250
+ '<section class="card condemned">',
251
+ `<ul class="plain">${items}</ul>`,
252
+ '<p>Each of these has passed and failed without the code changing. Fix them or delete them — never tolerate them, or one day a real regression will look like more of the same noise.</p>',
253
+ '</section>',
254
+ ].join('\n');
255
+ }
256
+
257
+ /**
258
+ * @param {import('../types.js').PictureResult[]} passed
259
+ * @returns {string}
260
+ */
261
+ function passedSection(passed) {
262
+ if (passed.length === 0) return '';
263
+ const items = passed.map((p) => `<li><code>${escapeHtml(p.name)}</code></li>`).join('');
264
+ return [
265
+ '<details class="card quiet">',
266
+ `<summary>${countText(passed.length)} ${plural(passed.length, 'screen', 'screens')} still ${plural(passed.length, 'looks', 'look')} exactly as approved</summary>`,
267
+ `<ul class="plain columns">${items}</ul>`,
268
+ '</details>',
269
+ ].join('\n');
270
+ }
271
+
272
+ /**
273
+ * Build the whole page.
274
+ * @param {import('../types.js').Project} project
275
+ * @param {import('../types.js').RunSummary} run
276
+ * @returns {Promise<string>}
277
+ */
278
+ async function buildHtml(project, run) {
279
+ const pictures = run.pictures ?? [];
280
+ const guards = run.guards ?? [];
281
+ const changed = pictures.filter((p) => p.status === 'changed');
282
+ const fresh = pictures.filter((p) => p.status === 'new');
283
+ const trouble = pictures.filter((p) => p.status === 'missing' || p.status === 'failed' || p.status === 'flaky');
284
+ const passed = pictures.filter((p) => p.status === 'passed');
285
+ const verdict = verdictFor(run);
286
+ const clear = verdict === 'Everything that worked still works.';
287
+
288
+ const body = [];
289
+
290
+ body.push('<header class="top">');
291
+ body.push(`<p class="brand">Stays Fixed</p>`);
292
+ body.push(`<h1 class="${clear ? 'good' : 'bad'}">${escapeHtml(verdict)}</h1>`);
293
+ const meta = [];
294
+ if (run.git?.branch) meta.push(`branch <code>${escapeHtml(run.git.branch)}</code>`);
295
+ if (run.git?.shortSha) meta.push(`commit <code>${escapeHtml(run.git.shortSha)}</code>${run.git.dirty ? ' with uncommitted changes' : ''}`);
296
+ if (run.startedAt) meta.push(escapeHtml(stampText(run.startedAt)));
297
+ meta.push(`took ${escapeHtml(plainTime(run.durationMs ?? 0))}`);
298
+ if (run.platform) meta.push(`on ${escapeHtml(run.platform)}`);
299
+ body.push(`<p class="meta">${meta.join(' &middot; ')}</p>`);
300
+ const chips = [];
301
+ if (passed.length) chips.push(`<span class="chip good">${countText(passed.length)} unchanged</span>`);
302
+ if (changed.length) chips.push(`<span class="chip bad">${countText(changed.length)} changed</span>`);
303
+ if (fresh.length) chips.push(`<span class="chip warn">${countText(fresh.length)} new</span>`);
304
+ if (trouble.length) chips.push(`<span class="chip bad">${countText(trouble.length)} could not be checked</span>`);
305
+ if (guards.length) {
306
+ const bad = guards.filter((g) => g.status === 'failed').length;
307
+ chips.push(`<span class="chip ${bad ? 'bad' : 'good'}">${countText(guards.length)} ${plural(guards.length, 'guard', 'guards')}${bad ? `, ${countText(bad)} failed` : ' holding'}</span>`);
308
+ }
309
+ if (chips.length) body.push(`<p class="chips">${chips.join('')}</p>`);
310
+ body.push('</header>');
311
+
312
+ if (changed.length) {
313
+ body.push(`<h2>Screens that look different</h2>`);
314
+ body.push('<p class="lead">Approved is what a person signed off. Now is what the app draws today. Decide which one is right.</p>');
315
+ for (const p of changed) {
316
+ const shots = {
317
+ approved: await dataUri(p.approvedPath),
318
+ actual: await dataUri(p.actualPath),
319
+ diff: await dataUri(p.diffPath),
320
+ };
321
+ body.push(changedCard(p, shots));
322
+ }
323
+ }
324
+
325
+ if (fresh.length) {
326
+ body.push('<h2>Screens waiting for a person</h2>');
327
+ for (const p of fresh) body.push(newCard(p, await dataUri(p.actualPath)));
328
+ }
329
+
330
+ if (trouble.length) {
331
+ body.push('<h2>Screens that could not be checked</h2>');
332
+ for (const p of trouble) body.push(troubleCard(p, await dataUri(p.actualPath)));
333
+ }
334
+
335
+ body.push(guardsSection(guards));
336
+ body.push(condemnedSection(run.condemned ?? []));
337
+ body.push(passedSection(passed));
338
+
339
+ const approvable = [...changed, ...fresh, ...trouble.filter((p) => p.status === 'missing')];
340
+ body.push('<footer>');
341
+ if (approvable.length) {
342
+ body.push('<h2>What to do next</h2>');
343
+ body.push('<p class="lead">Approving is a person’s job. Nothing here approves itself, and no agent can do it for you.</p>');
344
+ for (const p of approvable.slice(0, 40)) body.push(commandRow(`staysfixed approve ${p.name}`));
345
+ if (approvable.length > 40) body.push(`<p class="muted">and ${countText(approvable.length - 40)} more</p>`);
346
+ if (approvable.length > 1) {
347
+ body.push('<p class="lead">Or accept every one of them at once:</p>');
348
+ body.push(commandRow('staysfixed approve --all'));
349
+ }
350
+ } else {
351
+ body.push('<p class="lead">Nothing needs your approval. Carry on.</p>');
352
+ }
353
+ body.push(`<p class="muted">Written by Stays Fixed ${escapeHtml(run.tool ?? '')} &middot; ${escapeHtml(path.basename(project.paths.configFile))} &middot; run ${escapeHtml(run.id ?? '')}</p>`);
354
+ body.push('</footer>');
355
+
356
+ return [
357
+ '<!doctype html>',
358
+ '<html lang="en">',
359
+ '<head>',
360
+ '<meta charset="utf-8">',
361
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
362
+ `<title>${escapeHtml(clear ? 'Everything still works' : verdict)} — Stays Fixed</title>`,
363
+ `<style>${STYLE}</style>`,
364
+ '</head>',
365
+ '<body>',
366
+ '<main>',
367
+ body.join('\n'),
368
+ '</main>',
369
+ `<script>${SCRIPT}</script>`,
370
+ '</body>',
371
+ '</html>',
372
+ '',
373
+ ].join('\n');
374
+ }
375
+
376
+ /**
377
+ * Write the report next to the run's evidence.
378
+ * @param {import('../types.js').Project} project
379
+ * @param {import('../types.js').RunSummary} run
380
+ * @returns {Promise<string>} the file it wrote
381
+ */
382
+ export async function writeRunReport(project, run) {
383
+ const file = project.paths.reportFile;
384
+ await fsp.mkdir(path.dirname(file), { recursive: true });
385
+ await fsp.writeFile(file, await buildHtml(project, run));
386
+ return file;
387
+ }
388
+
389
+ const STYLE = `
390
+ :root {
391
+ color-scheme: light dark;
392
+ --ground: #f3f1ec;
393
+ --card: #fffdf9;
394
+ --ink: #1c1a17;
395
+ --soft: #6b655c;
396
+ --line: #e4dfd6;
397
+ --good: #2f7a4f;
398
+ --bad: #b02a20;
399
+ --warn: #8a6100;
400
+ --accent: #3f5bd0;
401
+ --radius: 18px;
402
+ }
403
+ @media (prefers-color-scheme: dark) {
404
+ :root {
405
+ --ground: #151719;
406
+ --card: #1d2023;
407
+ --ink: #e9e6e0;
408
+ --soft: #9d968d;
409
+ --line: #2c3035;
410
+ --good: #74cf97;
411
+ --bad: #ff8f85;
412
+ --warn: #e3b45c;
413
+ --accent: #94a9ff;
414
+ }
415
+ }
416
+ * { box-sizing: border-box; }
417
+ html, body { margin: 0; padding: 0; }
418
+ body {
419
+ background: var(--ground);
420
+ color: var(--ink);
421
+ font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
422
+ -webkit-font-smoothing: antialiased;
423
+ }
424
+ main { max-width: 1180px; margin: 0 auto; padding: 32px 20px 80px; }
425
+ code, .cmd code, pre { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.92em; }
426
+ h1 { font-size: 1.7rem; line-height: 1.25; margin: 4px 0 10px; font-weight: 650; }
427
+ h2 { font-size: 1.12rem; margin: 40px 0 6px; font-weight: 620; letter-spacing: 0.01em; }
428
+ h3 { font-size: 1rem; margin: 0 0 6px; font-weight: 600; }
429
+ h4 { font-size: 0.82rem; margin: 22px 0 8px; font-weight: 600; color: var(--soft); text-transform: uppercase; letter-spacing: 0.06em; }
430
+ p { margin: 0 0 10px; }
431
+ .brand { font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); margin: 0; }
432
+ .top { padding: 8px 0 4px; }
433
+ h1.good { color: var(--good); }
434
+ h1.bad { color: var(--bad); }
435
+ .meta, .muted { color: var(--soft); font-size: 0.86rem; }
436
+ .lead { color: var(--soft); max-width: 62ch; }
437
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
438
+ .chip { border: 1px solid var(--line); background: var(--card); border-radius: 999px; padding: 3px 12px; font-size: 0.8rem; }
439
+ .chip.good { color: var(--good); }
440
+ .chip.bad { color: var(--bad); }
441
+ .chip.warn { color: var(--warn); }
442
+ .card {
443
+ background: var(--card);
444
+ border: 1px solid var(--line);
445
+ border-radius: var(--radius);
446
+ padding: 22px;
447
+ margin: 14px 0 22px;
448
+ }
449
+ .card.change { border-left: 3px solid var(--bad); }
450
+ .card.fresh { border-left: 3px solid var(--warn); }
451
+ .card.trouble { border-left: 3px solid var(--bad); }
452
+ .card.condemned { border-left: 3px solid var(--bad); }
453
+ .tag { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; border-radius: 999px; padding: 2px 9px; border: 1px solid var(--line); vertical-align: 2px; }
454
+ .tag.bad { color: var(--bad); }
455
+ .tag.warn { color: var(--warn); }
456
+ .desc { color: var(--soft); }
457
+ .what { font-weight: 500; }
458
+ .hint { color: var(--soft); margin-top: 18px; }
459
+ .three { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin: 16px 0 4px; }
460
+ .one { margin: 16px 0 4px; }
461
+ figure { margin: 0; }
462
+ figcaption { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--soft); margin-bottom: 6px; }
463
+ figure img { width: 100%; height: auto; display: block; border-radius: 10px; border: 1px solid var(--line); background: #fff; }
464
+ .gone { color: var(--soft); font-size: 0.86rem; border: 1px dashed var(--line); border-radius: 10px; padding: 18px; }
465
+ .slider-stack, .fade-stack { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #fff; }
466
+ .slider-stack { touch-action: none; cursor: ew-resize; user-select: none; }
467
+ .slider-stack img, .fade-stack img { width: 100%; height: auto; display: block; }
468
+ .slider-stack img.after { position: absolute; inset: 0; clip-path: inset(0 0 0 calc(var(--pos) * 1%)); }
469
+ .slider-stack .bar { position: absolute; top: 0; bottom: 0; left: calc(var(--pos) * 1%); width: 2px; background: var(--accent); pointer-events: none; }
470
+ .slider-stack .edge { position: absolute; bottom: 8px; font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; background: rgba(0,0,0,0.55); color: #fff; padding: 2px 8px; border-radius: 999px; pointer-events: none; }
471
+ .slider-stack .edge.left { left: 8px; }
472
+ .slider-stack .edge.right { right: 8px; }
473
+ .fade-stack img.over { position: absolute; inset: 0; opacity: var(--fade); }
474
+ input[type=range] { width: 100%; margin: 12px 0 0; accent-color: var(--accent); }
475
+ .cmd { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0; }
476
+ .cmd code { background: var(--ground); border: 1px solid var(--line); border-radius: 10px; padding: 7px 12px; }
477
+ .copy { font: inherit; font-size: 0.78rem; color: var(--soft); background: transparent; border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px; cursor: pointer; }
478
+ .copy:hover { color: var(--ink); border-color: var(--soft); }
479
+ .errors { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 12px; font-size: 0.88rem; color: var(--soft); }
480
+ .errors ul { margin: 6px 0 0; padding-left: 18px; }
481
+ .guardlist { list-style: none; margin: 0; padding: 0; }
482
+ .guardlist li { padding: 10px 0; border-bottom: 1px solid var(--line); }
483
+ .guardlist li:last-child { border-bottom: 0; }
484
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 10px; background: var(--soft); vertical-align: 1px; }
485
+ .guardlist li.good .dot { background: var(--good); }
486
+ .guardlist li.bad .dot { background: var(--bad); }
487
+ .guardlist li.bad .gname { color: var(--bad); }
488
+ .gname { font-weight: 500; }
489
+ .claim { margin: 6px 0 0 18px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.84rem; color: var(--bad); }
490
+ .because { margin: 4px 0 0 18px; color: var(--soft); font-size: 0.88rem; }
491
+ .note { margin: 4px 0 0 18px; color: var(--soft); font-size: 0.86rem; }
492
+ /* A guard name or a CSS selector can be arbitrarily long; nothing may push the page sideways. */
493
+ code, .claim, .gname, .errors li, .plain li { overflow-wrap: anywhere; }
494
+ .cmd code { max-width: 100%; }
495
+ .guardlist li.muted .gname { color: var(--soft); font-weight: 400; }
496
+ .card.condemned .plain { margin-bottom: 14px; }
497
+ .plain { list-style: none; margin: 10px 0 0; padding: 0; }
498
+ .plain li { padding: 3px 0; }
499
+ .columns { columns: 3 200px; }
500
+ details.quiet { color: var(--soft); }
501
+ details.quiet summary { cursor: pointer; }
502
+ footer { margin-top: 48px; border-top: 1px solid var(--line); padding-top: 24px; }
503
+ @media (max-width: 640px) {
504
+ main { padding: 20px 14px 60px; }
505
+ h1 { font-size: 1.35rem; }
506
+ .columns { columns: 1; }
507
+ }
508
+ `;
509
+
510
+ const SCRIPT = `
511
+ (function () {
512
+ function wireCompare(box) {
513
+ var stack = box.querySelector('.slider-stack');
514
+ var range = box.querySelector('input');
515
+ if (!stack || !range) return;
516
+ var dragging = false;
517
+ function set(v) {
518
+ var p = Math.max(0, Math.min(100, v));
519
+ box.style.setProperty('--pos', String(p));
520
+ range.value = String(p);
521
+ }
522
+ function fromEvent(e) {
523
+ var r = stack.getBoundingClientRect();
524
+ return r.width ? ((e.clientX - r.left) / r.width) * 100 : 50;
525
+ }
526
+ range.addEventListener('input', function () { set(Number(range.value)); });
527
+ stack.addEventListener('pointerdown', function (e) {
528
+ dragging = true;
529
+ if (stack.setPointerCapture) { try { stack.setPointerCapture(e.pointerId); } catch (err) {} }
530
+ set(fromEvent(e));
531
+ e.preventDefault();
532
+ });
533
+ stack.addEventListener('pointermove', function (e) { if (dragging) set(fromEvent(e)); });
534
+ stack.addEventListener('pointerup', function () { dragging = false; });
535
+ stack.addEventListener('pointercancel', function () { dragging = false; });
536
+ }
537
+ function wireFade(box) {
538
+ var range = box.querySelector('input');
539
+ if (!range) return;
540
+ range.addEventListener('input', function () {
541
+ box.style.setProperty('--fade', String(Number(range.value) / 100));
542
+ });
543
+ }
544
+ function copyText(text, btn) {
545
+ var original = btn.textContent;
546
+ function done() {
547
+ btn.textContent = 'copied';
548
+ setTimeout(function () { btn.textContent = original; }, 1200);
549
+ }
550
+ function legacy() {
551
+ var ta = document.createElement('textarea');
552
+ ta.value = text;
553
+ ta.setAttribute('readonly', '');
554
+ ta.style.position = 'fixed';
555
+ ta.style.opacity = '0';
556
+ document.body.appendChild(ta);
557
+ ta.select();
558
+ try { document.execCommand('copy'); done(); } catch (err) {}
559
+ document.body.removeChild(ta);
560
+ }
561
+ if (navigator.clipboard && navigator.clipboard.writeText) {
562
+ navigator.clipboard.writeText(text).then(done, legacy);
563
+ } else {
564
+ legacy();
565
+ }
566
+ }
567
+ var i;
568
+ var sliders = document.querySelectorAll('[data-compare]');
569
+ for (i = 0; i < sliders.length; i++) wireCompare(sliders[i]);
570
+ var fades = document.querySelectorAll('[data-fadebox]');
571
+ for (i = 0; i < fades.length; i++) wireFade(fades[i]);
572
+ var buttons = document.querySelectorAll('[data-copy]');
573
+ for (i = 0; i < buttons.length; i++) {
574
+ buttons[i].addEventListener('click', function () {
575
+ copyText(this.getAttribute('data-copy') || '', this);
576
+ });
577
+ }
578
+ })();
579
+ `;