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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +107 -0
- data/README.md +179 -0
- data/Rakefile +12 -0
- data/exe/twinshot +6 -0
- data/lib/twinshot/adapters/binary.rb +21 -0
- data/lib/twinshot/adapters/html.rb +27 -0
- data/lib/twinshot/adapters/png.rb +128 -0
- data/lib/twinshot/adapters/txt.rb +27 -0
- data/lib/twinshot/adapters/xml.rb +28 -0
- data/lib/twinshot/adapters.rb +105 -0
- data/lib/twinshot/cli.rb +503 -0
- data/lib/twinshot/compare.rb +249 -0
- data/lib/twinshot/compare_worker.rb +47 -0
- data/lib/twinshot/config.rb +212 -0
- data/lib/twinshot/logo.svg +8 -0
- data/lib/twinshot/opener/darwin.rb +18 -0
- data/lib/twinshot/opener/linux.rb +19 -0
- data/lib/twinshot/opener/windows.rb +20 -0
- data/lib/twinshot/opener.rb +59 -0
- data/lib/twinshot/progress.rb +104 -0
- data/lib/twinshot/report.rb +600 -0
- data/lib/twinshot/soft_check.rb +19 -0
- data/lib/twinshot/tree.rb +31 -0
- data/lib/twinshot/version.rb +5 -0
- data/lib/twinshot.rb +18 -0
- data/logo.svg +100 -0
- data/sig/twinshot.rbs +4 -0
- metadata +130 -0
|
@@ -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
|