twinshot 1.2.1

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.
@@ -0,0 +1,600 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+
8
+ module Twinshot
9
+ # Two reports per group folder:
10
+ #
11
+ # - report_for_human.html — self-contained HTML gallery: 1:1 gallery (flat,
12
+ # ordered by directory), with search, a bootstrap-style group switcher
13
+ # dropdown, status filters (failures shown by default), image compare
14
+ # modes (side-by-side / slider / blink) and per-side copy buttons
15
+ # (absolute snapshot path — handy to paste into an agent). CSS and JS
16
+ # are inlined; screenshots referenced by relative paths.
17
+ # Theme built on Radix Colors (radix-ui.com/colors), light scales:
18
+ # slate neutrals, blue accent, red danger, green success. Each step
19
+ # has a fixed role (1-2 backgrounds, 3-6 borders/tints, 9 solid
20
+ # fills, 11-12 text) with guaranteed contrast for that pairing.
21
+ # - report_for_agent.md — markdown for feeding to LLM agents: per-file
22
+ # metadata (paths, kind, detail, ratio) plus the divergence code snippets
23
+ # as unified-style ```diff fences — the same line diffs the human report
24
+ # renders inline (for html that's the canonicalized DOM line diff).
25
+ #
26
+ # Every group gets its own folder twinshot-report/<name>/ with a full
27
+ # report_for_human.html + report_for_agent.md + summary.json + diffs/ set
28
+ # for its results only.
29
+ class Report
30
+ def initialize(config, summary)
31
+ @config = config
32
+ @summary = summary
33
+ end
34
+
35
+ def write
36
+ @summary["groups"].each do |group_stats|
37
+ group = @config.group(group_stats["name"])
38
+ results = @summary["results"].select { |r| r["group"] == group.name }
39
+ write_group(group, {
40
+ "total" => group_stats["total"],
41
+ "passed" => group_stats["passed"],
42
+ "warned" => group_stats["warned"],
43
+ "failed" => group_stats["failed"],
44
+ "results" => results
45
+ })
46
+ end
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ def write_group(group, summary)
53
+ @group = group
54
+ @report_dir = @config.report_dir(group)
55
+ report = Pathname.new(@report_dir)
56
+ @oracle_prefix = Pathname.new(group.oracle_path).relative_path_from(report).to_s
57
+ @current_prefix = Pathname.new(group.current_path).relative_path_from(report).to_s
58
+ FileUtils.mkdir_p(@report_dir)
59
+ File.write(File.join(@report_dir, Config::HUMAN_REPORT), render(group, summary))
60
+ File.write(File.join(@report_dir, Config::AGENT_REPORT), agent_markdown(summary))
61
+ File.write(File.join(@report_dir, "summary.json"), %(#{JSON.pretty_generate(summary)}\n))
62
+ end
63
+
64
+ def esc(text)
65
+ CGI.escapeHTML(text.to_s)
66
+ end
67
+
68
+ # Bootstrap-style group switcher for the topbar: each group's report
69
+ # lives in a sibling folder (twinshot-report/<name>/), so switching is a
70
+ # relative link ../<name>/report_for_human.html. Items carry the group's
71
+ # pass/warn/fail tally so trouble is visible before navigating. With a
72
+ # single group there is nothing to switch to: the plain #gname badge.
73
+ def group_selector(group)
74
+ groups = @summary["groups"] || []
75
+ return %(<span id="gname">#{esc group.name}</span>) if groups.size <= 1
76
+
77
+ items = groups.map do |stats|
78
+ current = stats["name"] == group.name
79
+ attrs = %( href="../#{esc stats["name"]}/#{Config::HUMAN_REPORT}")
80
+ attrs += %( class="active" aria-current="page") if current
81
+ "<li role=\"none\"><a role=\"menuitem\"#{attrs}>" \
82
+ "<span>#{esc stats["name"]}</span>" \
83
+ "<span class=\"dd-stats\">#{esc group_stats_label(stats)}</span></a></li>"
84
+ end.join
85
+ <<~HTML
86
+ <div class="dropdown">
87
+ <button type="button" class="dd-toggle" aria-expanded="false" aria-haspopup="true">
88
+ <span>#{esc group.name}</span>#{CARET}
89
+ </button>
90
+ <ul class="dd-menu" role="menu">#{items}</ul>
91
+ </div>
92
+ HTML
93
+ end
94
+
95
+ def group_stats_label(stats)
96
+ parts = ["#{stats["passed"]}/#{stats["total"]} pass"]
97
+ parts << "#{stats["warned"]} warn" if stats["warned"].to_i.positive?
98
+ parts << "#{stats["failed"]} fail" if stats["failed"].to_i.positive?
99
+ parts.join(" \u00b7 ")
100
+ end
101
+
102
+ def render(group, summary)
103
+ # Failures are what the report is opened for, warnings next; a fully
104
+ # green report falls back to "all" so the gallery isn't empty on open.
105
+ default_filter = if summary["failed"].to_i.positive? then "fail"
106
+ elsif summary["warned"].to_i.positive? then "warn"
107
+ else "all"
108
+ end
109
+ active = ->(f) { %( class="active") if f == default_filter }
110
+ <<~HTML
111
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
112
+ <title>twinshot — 1:1 parity · #{esc group.name}</title>
113
+ <style>#{CSS}</style></head>
114
+ <body data-filter="#{default_filter}" data-mode="side" data-heat="on">
115
+ <header id="topbar">
116
+ <span class="brand">#{LOGO}<b>twinshot</b></span>
117
+ #{group_selector(group)}
118
+ <span id="stats"><span class="ok">#{summary["passed"]}/#{summary["total"]} pass</span> <span class="wrn">#{summary["warned"].to_i} warn</span> <span class="bad">#{summary["failed"]} fail</span></span>
119
+ <input id="q" type="search" placeholder="search by name…">
120
+ <span class="seg">
121
+ <button type="button" data-f="all"#{active.call("all")}>all</button><button type="button" data-f="fail"#{active.call("fail")}>failures</button><button type="button" data-f="warn"#{active.call("warn")}>warnings</button><button type="button" data-f="pass"#{active.call("pass")}>pass</button>
122
+ </span>
123
+ <span class="seg">
124
+ <button type="button" data-m="side" class="active">side by side</button><button type="button" data-m="slider">slider</button><button type="button" data-m="blink">blink</button>
125
+ </span>
126
+ <span class="seg">
127
+ <button type="button" data-h="on" class="active" aria-pressed="true">zones</button>
128
+ </span>
129
+ <span class="hint">n/p — navigate · ⧉ — copy path</span>
130
+ </header>
131
+ #{gallery(summary)}
132
+ </body><script>#{JS}</script></html>
133
+ HTML
134
+ end
135
+
136
+ def gallery(summary)
137
+ summary["results"]
138
+ .group_by { |r| File.dirname(r["rel"]) }
139
+ .sort.map { |dir, results| section(dir, results) }.join
140
+ end
141
+
142
+ # Sections stay as invisible containers (no headings: the dir name already
143
+ # appears in every shot's h3, extra titles only duplicated it) so filtering
144
+ # can still hide empty directories as a unit.
145
+ def section(dir, results)
146
+ %(<section class="group" data-dir="#{esc dir}">#{results.map { |r| shot(r) }.join}</section>)
147
+ end
148
+
149
+ def shot(result)
150
+ rel = result["rel"]
151
+ body = if result["image"]
152
+ image_pair(result)
153
+ elsif result["diff"]
154
+ text_diff(result)
155
+ else
156
+ %(<p class="note">#{esc result["detail"]}</p>)
157
+ end
158
+ diff_link = if result["diff_image"]
159
+ %( <a href="#{esc result["diff_image"]}">diff</a>)
160
+ else
161
+ ""
162
+ end
163
+ "<div class=\"shot #{result["status"]}\" data-name=\"#{esc rel}\">" \
164
+ "<h3>#{esc rel}<span class=\"badge\">#{esc result["kind"]}</span>#{copy_buttons(result)}#{diff_link} " \
165
+ "<span class=\"detail\">#{esc result["detail"]}</span></h3>" \
166
+ "#{body}</div>"
167
+ end
168
+
169
+ # Copy buttons carry the absolute snapshot path (per side) so it can be
170
+ # pasted straight into an agent or a shell. Structure rows have only one side.
171
+ def copy_buttons(result)
172
+ rel = result["rel"]
173
+ if result["kind"] == "structure"
174
+ label, root = if result["detail"].start_with?("missing")
175
+ ["oracle",
176
+ @group.oracle_path]
177
+ else
178
+ ["current", @group.current_path]
179
+ end
180
+ copy_button(label, File.join(root, rel))
181
+ else
182
+ copy_button("oracle", File.join(@group.oracle_path, rel)) +
183
+ copy_button("current", File.join(@group.current_path, rel))
184
+ end
185
+ end
186
+
187
+ def copy_button(label, path)
188
+ %(<button type="button" class="copy" data-copy="#{esc path}" data-label="⧉ #{label}">⧉ #{label}</button>)
189
+ end
190
+
191
+ def image_pair(result)
192
+ rel = result["rel"]
193
+ oracle_src = "#{@oracle_prefix}/#{rel}"
194
+ current_src = "#{@current_prefix}/#{rel}"
195
+ heat = heat_layer(result["heat"])
196
+ badge = current_badge(result)
197
+ <<~HTML
198
+ <div class="pair">
199
+ <figure class="side-a">
200
+ <figcaption>oracle</figcaption>
201
+ <div class="imgwrap">
202
+ <img loading="lazy" src="#{esc oracle_src}" alt="oracle">
203
+ <img loading="lazy" class="overlay" src="#{esc current_src}" alt="current">
204
+ #{heat}
205
+ </div>
206
+ <input class="slider" type="range" min="0" max="100" value="50" aria-label="compare slider">
207
+ </figure>
208
+ <figure class="side-b">
209
+ <figcaption>current #{badge}</figcaption>
210
+ <div class="imgwrap"><img loading="lazy" src="#{esc current_src}" alt="current">#{heat}</div>
211
+ </figure>
212
+ </div>
213
+ HTML
214
+ end
215
+
216
+ # Right-side caption badge: makes it obvious the right image IS the
217
+ # current snapshot even when it looks identical to the oracle.
218
+ def current_badge(result)
219
+ css = badge_css(result)
220
+ if result["detail"] == "bytes equal"
221
+ %(<span class="cbadge ok">≡ byte-equal</span>)
222
+ elsif result["ratio"]
223
+ %(<span class="cbadge #{css}">#{format("%.3f%%", result["ratio"] * 100)}</span>)
224
+ else
225
+ %(<span class="cbadge #{css}">#{esc result["detail"]}</span>)
226
+ end
227
+ end
228
+
229
+ def badge_css(result)
230
+ case result["status"]
231
+ when "pass" then "ok"
232
+ when "warn" then "soft" # soft_check: tolerated diff
233
+ else "bad"
234
+ end
235
+ end
236
+
237
+ # Purple overlay marking divergence zones: one positioned <i> per heat
238
+ # cell, all in a single flat tone (no per-cell intensity scaling) so
239
+ # the zones read as one uniform layer; the report CSS pulses it every
240
+ # two seconds (smooth ease-in-out) so differences catch the eye — except
241
+ # in blink mode, where the compare image itself flashes and a pulsing
242
+ # layer would only distract. Percent-based sizing keeps it glued to
243
+ # the image in every display mode.
244
+ def heat_layer(heat)
245
+ return "" unless heat
246
+
247
+ cells = heat["cells"].map do |col, row, _intensity|
248
+ left = col * heat["cell"].fdiv(heat["w"]) * 100
249
+ top = row * heat["cell"].fdiv(heat["h"]) * 100
250
+ width = [heat["cell"], heat["w"] - col * heat["cell"]].min.fdiv(heat["w"]) * 100
251
+ height = [heat["cell"], heat["h"] - row * heat["cell"]].min.fdiv(heat["h"]) * 100
252
+ box = "left:#{left.round(2)}%;top:#{top.round(2)}%;width:#{width.round(2)}%;height:#{height.round(2)}%"
253
+ %(<i style="#{box};background:rgba(160,32,240,0.3)"></i>)
254
+ end.join
255
+ %(<div class="heat" aria-hidden="true">#{cells}</div>)
256
+ end
257
+
258
+ def text_diff(result)
259
+ rel = result["rel"]
260
+ lines = result["diff"].map { |e| %(<span class="#{e["t"]}">#{esc e["text"]}</span>) }.join
261
+ <<~HTML
262
+ <pre class="diff">#{lines}</pre>
263
+ <p class="note">originals: <a href="#{esc File.join(@oracle_prefix, rel)}">oracle</a> · <a href="#{esc File.join(@current_prefix, rel)}">current</a></p>
264
+ HTML
265
+ end
266
+
267
+ # Markdown report aimed at LLM agents: every failed or warned file gets
268
+ # a section with its metadata (absolute per-side paths, kind, detail,
269
+ # ratio) and, where the adapter produced one, the line diff in a ```diff
270
+ # fence — the same diff the human report shows inline. Passed files are
271
+ # a plain list: no diffs to analyze, just proof they matched.
272
+ def agent_markdown(summary)
273
+ failed = summary["results"].select { |r| r["status"] == "fail" }
274
+ warned = summary["results"].select { |r| r["status"] == "warn" }
275
+ passed = summary["results"].select { |r| r["status"] == "pass" }
276
+ lines = [
277
+ "# twinshot — 1:1 parity · group #{@group.name}", "",
278
+ "- oracle: #{@group.oracle_path}",
279
+ "- current: #{@group.current_path}",
280
+ "- total: #{summary["total"]} · passed: #{summary["passed"]}" \
281
+ " · warned: #{warned.size} · failed: #{summary["failed"]}",
282
+ "",
283
+ "## Failures (#{failed.size})", ""
284
+ ]
285
+ failed.each { |r| lines.concat(agent_section(r)) }
286
+ lines.concat(["## Warnings (#{warned.size})", ""])
287
+ warned.each { |r| lines.concat(agent_section(r)) }
288
+ lines.concat(["## Passed (#{passed.size})", ""])
289
+ passed.each { |r| lines << "- `#{r["rel"]}` — #{r["kind"]}: #{r["detail"]}" }
290
+ lines << ""
291
+ lines.join("\n")
292
+ end
293
+
294
+ def agent_section(result)
295
+ rel = result["rel"]
296
+ lines = ["### #{rel}", "",
297
+ "- kind: #{result["kind"]}",
298
+ "- status: #{result["status"]}",
299
+ "- detail: #{result["detail"]}"]
300
+ if result["kind"] == "structure"
301
+ side = result["detail"].start_with?("missing") ? "oracle" : "current"
302
+ root = side == "oracle" ? @group.oracle_path : @group.current_path
303
+ lines << "- #{side}: #{File.join(root, rel)}"
304
+ else
305
+ lines << "- oracle: #{File.join(@group.oracle_path, rel)}"
306
+ lines << "- current: #{File.join(@group.current_path, rel)}"
307
+ end
308
+ lines << "- ratio: #{format("%.4f", result["ratio"])}" if result["ratio"]
309
+ lines << "- diff image: #{result["diff_image"]}" if result["diff_image"]
310
+ lines << ""
311
+ if result["diff"]
312
+ lines << "````diff"
313
+ result["diff"].each { |e| lines << agent_diff_line(e) }
314
+ lines << "````"
315
+ lines << ""
316
+ end
317
+ lines
318
+ end
319
+
320
+ # Unified-diff prefixes; a four-backtick fence so file content containing
321
+ # triple backticks cannot break out of the block.
322
+ def agent_diff_line(entry)
323
+ case entry["t"]
324
+ when "same" then " #{entry["text"]}"
325
+ when "del" then "-#{entry["text"]}"
326
+ when "add" then "+#{entry["text"]}"
327
+ else entry["text"].to_s
328
+ end
329
+ end
330
+
331
+ # Inline navbar logo (embeds straight into the HTML — the report stays
332
+ # self-contained, no external assets). Cleaned Inkscape export: only the
333
+ # viewBox and the four paths remain. Sized via CSS (.brand svg).
334
+ LOGO = File.read(File.expand_path("logo.svg", __dir__)).strip
335
+
336
+ # Bootstrap-icons caret-down-fill, inlined for the same self-contained
337
+ # reason as LOGO (no external assets). Rotates via CSS while open.
338
+ CARET = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" ' \
339
+ 'viewBox="0 0 16 16" aria-hidden="true">' \
340
+ '<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 ' \
341
+ '.753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/></svg>'
342
+
343
+ # Radix Colors light theme (radix-ui.com/colors). Steps: 1-2 backgrounds,
344
+ # 3-4 tints, 5-6 borders, 7-8 borders/hover, 9-10 solid fills, 11-12 text.
345
+ # blue-a5 is Radix blueA5 — the standard light-theme focus ring.
346
+ CSS = <<~'CSS'
347
+ :root{--slate-1:#fcfcfd;--slate-2:#f9f9fb;--slate-3:#f0f0f3;--slate-4:#e8e8ec;--slate-5:#e0e1e6;--slate-6:#d9d9e0;--slate-7:#cdced6;--slate-8:#b9bbc6;--slate-9:#8b8d98;--slate-10:#80838d;--slate-11:#60646c;--slate-12:#1c2024;
348
+ --blue-4:#d5efff;--blue-5:#c2e5ff;--blue-6:#acd8fc;--blue-7:#8ec8f6;--blue-8:#5eb1ef;--blue-9:#0090ff;--blue-10:#0588f0;--blue-11:#0d74ce;--blue-12:#113264;
349
+ --red-2:#fff7f7;--red-3:#feebec;--red-4:#ffdbdc;--red-6:#fdbdbe;--red-7:#f4a9aa;--red-9:#e5484d;--red-11:#ce2c31;
350
+ --amber-2:#fef6e4;--amber-3:#fbeccb;--amber-7:#e8c46a;--amber-9:#ffb224;--amber-11:#8f6109;
351
+ --green-2:#f4fbf6;--green-3:#e6f6eb;--green-4:#d6f1df;--green-6:#adddc0;--green-9:#30a46c;--green-11:#218358;--blue-a5:#0093ff3d}
352
+ body{font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;margin:0;color:var(--slate-12);background:var(--slate-4);padding-bottom:48px}
353
+ a{color:var(--blue-11)}
354
+ #topbar{position:sticky;top:0;z-index:30;display:flex;flex-wrap:wrap;gap:10px;align-items:center;padding:8px 16px;background:#fff;border-bottom:1px solid var(--slate-6)}
355
+ #topbar b{font-size:15px;color:var(--blue-11)}
356
+ .brand{display:inline-flex;align-items:center;gap:7px}
357
+ .brand svg{width:18px;height:18px;display:block}
358
+ #gname{font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--slate-11);background:var(--slate-2);border:1px solid var(--slate-6);padding:2px 8px;border-radius:.375rem}
359
+ #stats .ok{color:#fff;background:var(--green-9);padding:2px 8px;border-radius:.375rem;font-weight:600}
360
+ #stats .bad{color:#fff;background:var(--red-9);padding:2px 8px;border-radius:.375rem;font-weight:600}
361
+ #stats .wrn{color:#fff;background:var(--amber-9);padding:2px 8px;border-radius:.375rem;font-weight:600}
362
+ #q{width:200px;padding:4px 10px;border:1px solid var(--slate-6);border-radius:.375rem;color:var(--slate-12);font:inherit}
363
+ #q:focus{outline:0;border-color:var(--blue-8);box-shadow:0 0 0 .25rem var(--blue-a5)}
364
+ .seg{display:inline-flex}
365
+ .seg button{border:1px solid var(--slate-6);background:#fff;color:var(--slate-12);padding:4px 12px;cursor:pointer;font:inherit;font-weight:500;line-height:1.4}
366
+ .seg button:hover{background:var(--slate-3)}
367
+ .seg button:focus-visible{outline:0;position:relative;z-index:1;box-shadow:0 0 0 .25rem var(--blue-a5)}
368
+ .seg button+button{margin-left:-1px}
369
+ .seg button:first-child{border-radius:.375rem 0 0 .375rem}
370
+ .seg button:last-child{border-radius:0 .375rem .375rem 0}
371
+ .seg button.active{background:var(--blue-9);border-color:var(--blue-9);color:#fff}
372
+ .hint{margin-left:auto;color:var(--slate-11);font-size:12px}
373
+ .group{max-width:1200px;margin:0 auto;padding:24px 16px 0}
374
+ .shot{background:#fff;border:1px solid var(--slate-6);border-radius:.5rem;margin:0 0 24px;box-shadow:0 1px 2px rgba(0,0,0,.1)}
375
+ .shot.fail{border-color:var(--red-7)}
376
+ .shot.warn{border-color:var(--amber-7)}
377
+ .shot h3{margin:0;padding:8px 12px;font:600 13px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--slate-12);background:var(--slate-2);border-bottom:1px solid var(--slate-6);border-radius:.5rem .5rem 0 0;word-break:break-all}
378
+ .shot.fail h3{background:var(--red-3);border-bottom-color:var(--red-7);color:var(--red-11)}
379
+ .shot.warn h3{background:var(--amber-2);border-bottom-color:var(--amber-7);color:var(--amber-11)}
380
+ .shot>*:last-child{border-radius:0 0 .5rem .5rem;overflow:hidden}
381
+ .shot .badge{font:600 10px system-ui,sans-serif;color:var(--slate-11);border:1px solid var(--slate-6);background:#fff;padding:1px 6px;margin-left:8px;vertical-align:1px;border-radius:.375rem}
382
+ .shot .detail{font-weight:400;color:var(--slate-11)}
383
+ .shot.fail .detail{color:var(--red-11)}
384
+ .shot.warn .detail{color:var(--amber-11)}
385
+ .shot h3 a{color:var(--blue-11)}
386
+ .copy{font:600 11px system-ui,sans-serif;color:var(--slate-11);background:#fff;border:1px solid var(--slate-6);border-radius:.375rem;padding:1px 7px;cursor:pointer;margin-left:6px;vertical-align:1px}
387
+ .copy:hover{background:var(--slate-2)}
388
+ .copy.done{background:var(--green-9);border-color:var(--green-9);color:#fff}
389
+ .pair{display:flex;--cut:50%}
390
+ body[data-mode=slider] .pair,body[data-mode=blink] .pair{display:block;position:relative}
391
+ .pair figure{flex:1 1 50%;min-width:0;margin:0}
392
+ .pair figure+figure{border-left:3px solid var(--slate-6)}
393
+ .pair figcaption{padding:5px 10px;font:700 11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--slate-11);background:var(--slate-2);border-bottom:1px solid var(--slate-6)}
394
+ .cbadge{float:right;font:700 10px system-ui,sans-serif;padding:1px 7px;border-radius:.375rem}
395
+ .cbadge.ok{background:var(--green-3);color:var(--green-11)}
396
+ .cbadge.bad{background:var(--red-3);color:var(--red-11)}
397
+ .cbadge.soft{background:var(--amber-3);color:var(--amber-11)}
398
+ .imgwrap{position:relative}
399
+ .pair img{display:block;width:100%;height:auto}
400
+ .pair .overlay{display:none}
401
+ body[data-mode=slider] .overlay,body[data-mode=blink] .overlay{display:block;position:absolute;top:0;left:0;width:100%;height:auto}
402
+ body[data-mode=slider] .overlay{clip-path:inset(0 calc(100% - var(--cut)) 0 0)}
403
+ body[data-mode=blink] .overlay{animation:blink 1s steps(1) infinite}
404
+ body[data-filter=fail][data-mode=blink] .overlay{animation-duration:2s}
405
+ body[data-mode=slider] .side-b,body[data-mode=blink] .side-b{display:none}
406
+ .heat{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}
407
+ .heat i{position:absolute;display:block;animation:heat-pulse 2s ease-in-out infinite}
408
+ body[data-mode=blink] .heat i{animation:none}
409
+ body[data-heat=off] .heat{display:none}
410
+ @media (prefers-reduced-motion: reduce){.heat i{animation:none}}
411
+ @keyframes blink{0%,50%{opacity:1}50.01%,100%{opacity:0}}
412
+ @keyframes heat-pulse{0%,100%{opacity:1}50%{opacity:.15}}
413
+ .slider{display:none;width:100%;accent-color:var(--blue-9)}
414
+ body[data-mode=slider] .slider{display:block}
415
+ pre.diff{margin:0;padding:8px 0;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:auto;max-height:480px;background:#fff}
416
+ pre.diff span{display:block;padding:0 10px;white-space:pre}
417
+ pre.diff .del{background:var(--red-3);color:var(--red-11)}
418
+ pre.diff .add{background:var(--green-3);color:var(--green-11)}
419
+ pre.diff .same{color:var(--slate-11)}
420
+ .note{padding:6px 12px;color:var(--slate-11);border-top:1px solid var(--slate-6)}
421
+ .dropdown{position:relative}
422
+ .dd-toggle{display:inline-flex;align-items:center;gap:6px;font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--slate-11);background:var(--slate-2);border:1px solid var(--slate-6);padding:2px 8px;border-radius:.375rem;cursor:pointer}
423
+ .dd-toggle:hover{background:var(--slate-3)}
424
+ .dd-toggle:focus-visible{outline:0;border-color:var(--blue-8);box-shadow:0 0 0 .25rem var(--blue-a5)}
425
+ .dd-toggle svg{width:10px;height:10px;display:block;transition:transform .15s ease}
426
+ .dd-toggle[aria-expanded=true] svg{transform:rotate(180deg)}
427
+ .dd-menu{display:none;position:absolute;top:calc(100% + 4px);left:0;z-index:40;min-width:230px;max-height:70vh;overflow:auto;margin:0;padding:6px;list-style:none;background:#fff;border:1px solid var(--slate-6);border-radius:.5rem;box-shadow:0 4px 16px rgba(0,0,0,.15)}
428
+ .dd-menu.show{display:block;animation:dd-in .15s ease-in}
429
+ @keyframes dd-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
430
+ .dd-menu a{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:6px 10px;border-radius:.375rem;color:var(--slate-12);text-decoration:none;font:600 12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
431
+ .dd-menu a:hover,.dd-menu a:focus-visible{background:var(--blue-4);color:var(--blue-12);outline:0}
432
+ .dd-menu a.active{background:var(--blue-9);color:#fff}
433
+ .dd-menu a.active:hover,.dd-menu a.active:focus-visible{background:var(--blue-10);color:#fff}
434
+ .dd-menu .dd-stats{font:600 11px system-ui,sans-serif;color:var(--slate-11);white-space:nowrap}
435
+ .dd-menu a.active .dd-stats{color:#fff}
436
+ @media (prefers-reduced-motion: reduce){.dd-menu.show{animation:none}.dd-toggle svg{transition:none}}
437
+ CSS
438
+
439
+ JS = <<~'JS'
440
+ (function () {
441
+ var body = document.body;
442
+ var search = document.getElementById('q');
443
+
444
+ function apply() {
445
+ var filter = body.dataset.filter;
446
+ var q = search.value.trim().toLowerCase();
447
+ document.querySelectorAll('.group').forEach(function (g) {
448
+ var shown = 0;
449
+ var shots = g.querySelectorAll('.shot');
450
+ shots.forEach(function (s) {
451
+ var visible = (filter === 'all' || s.classList.contains(filter)) &&
452
+ (!q || s.dataset.name.toLowerCase().indexOf(q) >= 0);
453
+ s.hidden = !visible;
454
+ if (visible) shown++;
455
+ });
456
+ g.hidden = shown === 0;
457
+ });
458
+ }
459
+
460
+ document.querySelectorAll('button[data-f]').forEach(function (b) {
461
+ b.addEventListener('click', function () {
462
+ document.querySelectorAll('button[data-f]').forEach(function (x) { x.classList.remove('active'); });
463
+ b.classList.add('active');
464
+ body.dataset.filter = b.dataset.f;
465
+ apply();
466
+ });
467
+ });
468
+
469
+ document.querySelectorAll('button[data-m]').forEach(function (b) {
470
+ b.addEventListener('click', function () {
471
+ document.querySelectorAll('button[data-m]').forEach(function (x) { x.classList.remove('active'); });
472
+ b.classList.add('active');
473
+ body.dataset.mode = b.dataset.m;
474
+ });
475
+ });
476
+
477
+ document.querySelectorAll('button[data-h]').forEach(function (b) {
478
+ b.addEventListener('click', function () {
479
+ var off = body.dataset.heat === 'off';
480
+ body.dataset.heat = off ? 'on' : 'off';
481
+ b.classList.toggle('active', off);
482
+ b.setAttribute('aria-pressed', String(off));
483
+ });
484
+ });
485
+
486
+ document.addEventListener('input', function (e) {
487
+ if (e.target.classList && e.target.classList.contains('slider')) {
488
+ e.target.closest('.pair').style.setProperty('--cut', e.target.value + '%');
489
+ }
490
+ });
491
+
492
+ function markCopied(btn) {
493
+ btn.classList.add('done');
494
+ btn.textContent = '✓ copied';
495
+ setTimeout(function () {
496
+ btn.classList.remove('done');
497
+ btn.textContent = btn.dataset.label;
498
+ }, 1200);
499
+ }
500
+
501
+ function copyText(text, btn) {
502
+ var fallback = function () {
503
+ var ta = document.createElement('textarea');
504
+ ta.value = text;
505
+ ta.style.position = 'fixed';
506
+ ta.style.opacity = '0';
507
+ document.body.appendChild(ta);
508
+ ta.select();
509
+ try { document.execCommand('copy'); markCopied(btn); } catch (e) {}
510
+ document.body.removeChild(ta);
511
+ };
512
+ if (navigator.clipboard && navigator.clipboard.writeText) {
513
+ navigator.clipboard.writeText(text).then(function () { markCopied(btn); }, fallback);
514
+ } else {
515
+ fallback();
516
+ }
517
+ }
518
+
519
+ document.addEventListener('click', function (e) {
520
+ var btn = e.target.closest ? e.target.closest('button.copy') : null;
521
+ if (btn) copyText(btn.dataset.copy, btn);
522
+ });
523
+
524
+ function visibleShots() {
525
+ return Array.prototype.slice.call(document.querySelectorAll('.shot')).filter(function (s) {
526
+ return !s.hidden && s.getBoundingClientRect().height > 0;
527
+ });
528
+ }
529
+
530
+ document.addEventListener('keydown', function (e) {
531
+ if (e.target === search) {
532
+ if (e.key === 'Escape') { search.value = ''; apply(); search.blur(); }
533
+ return;
534
+ }
535
+ if (e.key !== 'n' && e.key !== 'p') return;
536
+ var shots = visibleShots();
537
+ if (!shots.length) return;
538
+ var current = shots.length - 1;
539
+ for (var i = 0; i < shots.length; i++) {
540
+ if (shots[i].getBoundingClientRect().top >= 60) { current = i; break; }
541
+ }
542
+ var target = e.key === 'n' ? Math.min(current + 1, shots.length - 1) : Math.max(current - 1, 0);
543
+ shots[target].scrollIntoView({ block: 'start' });
544
+ });
545
+
546
+ // Bootstrap-style dropdown (group switcher): toggle on click, close on
547
+ // outside click / Escape / Tab, ArrowUp/ArrowDown and Home/End move
548
+ // focus through the menu. Items are links to sibling group reports.
549
+ document.querySelectorAll('.dropdown').forEach(function (dd) {
550
+ var toggle = dd.querySelector('.dd-toggle');
551
+ var menu = dd.querySelector('.dd-menu');
552
+ var items = Array.prototype.slice.call(menu.querySelectorAll('a'));
553
+
554
+ function open(focusIndex) {
555
+ dd.classList.add('show');
556
+ menu.classList.add('show');
557
+ toggle.setAttribute('aria-expanded', 'true');
558
+ if (typeof focusIndex === 'number' && items[focusIndex]) items[focusIndex].focus();
559
+ }
560
+
561
+ function close(refocus) {
562
+ dd.classList.remove('show');
563
+ menu.classList.remove('show');
564
+ toggle.setAttribute('aria-expanded', 'false');
565
+ if (refocus) toggle.focus();
566
+ }
567
+
568
+ toggle.addEventListener('click', function () {
569
+ if (menu.classList.contains('show')) { close(); } else { open(0); }
570
+ });
571
+
572
+ document.addEventListener('click', function (e) {
573
+ if (menu.classList.contains('show') && !dd.contains(e.target)) close();
574
+ });
575
+
576
+ toggle.addEventListener('keydown', function (e) {
577
+ if (!menu.classList.contains('show') &&
578
+ (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
579
+ e.preventDefault();
580
+ open(e.key === 'ArrowDown' ? 0 : items.length - 1);
581
+ }
582
+ });
583
+
584
+ menu.addEventListener('keydown', function (e) {
585
+ var i = items.indexOf(document.activeElement);
586
+ if (e.key === 'Escape') { e.stopPropagation(); close(true); }
587
+ else if (e.key === 'ArrowDown') { e.preventDefault(); items[Math.min(i + 1, items.length - 1)].focus(); }
588
+ else if (e.key === 'ArrowUp') { e.preventDefault(); items[Math.max(i - 1, 0)].focus(); }
589
+ else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
590
+ else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
591
+ else if (e.key === 'Tab') close();
592
+ });
593
+ });
594
+
595
+ search.addEventListener('input', apply);
596
+ apply();
597
+ })();
598
+ JS
599
+ end
600
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twinshot
4
+ # soft_check matching, shared by Config (parent process) and
5
+ # compare_worker.rb (exec'd workers): an entry is either an exact
6
+ # tree-relative path or a glob pattern. Matched with File.fnmatch without
7
+ # FNM_PATHNAME, so "*" crosses "/" — "code/capybara-tests/*" covers the
8
+ # whole subtree, nested files included. FNM_DOTMATCH keeps dotfiles
9
+ # (.gitignore) matchable by "*".
10
+ module SoftCheck
11
+ FLAGS = File::FNM_DOTMATCH
12
+
13
+ module_function
14
+
15
+ def match?(paths, rel)
16
+ paths.any? { |path| File.fnmatch(path, rel, FLAGS) }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module Twinshot
6
+ # Directory tree scanning and 1:1 structure comparison.
7
+ module Tree
8
+ module_function
9
+
10
+ # Sorted relative paths of all files under root (hidden files included).
11
+ def files(root)
12
+ prefix = "#{root.chomp("/")}/"
13
+ [].tap do |out|
14
+ Find.find(root) do |path|
15
+ out << path.delete_prefix(prefix) if path != root && File.file?(path)
16
+ end
17
+ end.sort
18
+ end
19
+
20
+ # Files present in only one of the trees:
21
+ # { "missing" => [in oracle, absent in current], "extra" => [in current, absent in oracle] }.
22
+ def diff(oracle_files, current_files)
23
+ oracle_set = oracle_files.to_h { |rel| [rel, true] }
24
+ current_set = current_files.to_h { |rel| [rel, true] }
25
+ {
26
+ "missing" => oracle_files.reject { current_set.key?(_1) },
27
+ "extra" => current_files.reject { oracle_set.key?(_1) }
28
+ }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Twinshot
4
+ VERSION = "1.2.1"
5
+ end