regresso 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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +3 -0
  3. data/CHANGELOG.md +3 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +258 -0
  6. data/Rakefile +12 -0
  7. data/assets/logo-header.svg +28 -0
  8. data/lib/generators/regresso/install_generator.rb +29 -0
  9. data/lib/generators/regresso/templates/regresso.rb +18 -0
  10. data/lib/regresso/adapters/.gitkeep +0 -0
  11. data/lib/regresso/adapters/base.rb +30 -0
  12. data/lib/regresso/adapters/csv.rb +43 -0
  13. data/lib/regresso/adapters/database.rb +53 -0
  14. data/lib/regresso/adapters/database_snapshot.rb +49 -0
  15. data/lib/regresso/adapters/graphql.rb +75 -0
  16. data/lib/regresso/adapters/graphql_batch.rb +42 -0
  17. data/lib/regresso/adapters/http.rb +70 -0
  18. data/lib/regresso/adapters/json_file.rb +30 -0
  19. data/lib/regresso/adapters/proc.rb +30 -0
  20. data/lib/regresso/ci/github_annotation_formatter.rb +46 -0
  21. data/lib/regresso/ci/github_pr_commenter.rb +55 -0
  22. data/lib/regresso/ci/junit_xml_formatter.rb +42 -0
  23. data/lib/regresso/ci/reporter.rb +49 -0
  24. data/lib/regresso/ci.rb +6 -0
  25. data/lib/regresso/comparator.rb +45 -0
  26. data/lib/regresso/configuration.rb +92 -0
  27. data/lib/regresso/differ.rb +129 -0
  28. data/lib/regresso/difference.rb +45 -0
  29. data/lib/regresso/history/entry.rb +70 -0
  30. data/lib/regresso/history/file_backend.rb +51 -0
  31. data/lib/regresso/history/statistics.rb +65 -0
  32. data/lib/regresso/history/store.rb +122 -0
  33. data/lib/regresso/history/trend_reporter.rb +68 -0
  34. data/lib/regresso/history.rb +7 -0
  35. data/lib/regresso/json_path.rb +55 -0
  36. data/lib/regresso/minitest.rb +107 -0
  37. data/lib/regresso/notifiers/base.rb +33 -0
  38. data/lib/regresso/notifiers/microsoft_teams.rb +60 -0
  39. data/lib/regresso/notifiers/slack.rb +72 -0
  40. data/lib/regresso/notifiers.rb +5 -0
  41. data/lib/regresso/parallel/comparison_result.rb +51 -0
  42. data/lib/regresso/parallel/parallel_result.rb +68 -0
  43. data/lib/regresso/parallel/result_aggregator.rb +25 -0
  44. data/lib/regresso/parallel/runner.rb +71 -0
  45. data/lib/regresso/parallel.rb +6 -0
  46. data/lib/regresso/reporter.rb +68 -0
  47. data/lib/regresso/result.rb +70 -0
  48. data/lib/regresso/rspec/.gitkeep +0 -0
  49. data/lib/regresso/rspec/shared_examples.rb +42 -0
  50. data/lib/regresso/rspec.rb +142 -0
  51. data/lib/regresso/snapshot_manager.rb +57 -0
  52. data/lib/regresso/tasks/ci.rake +51 -0
  53. data/lib/regresso/tasks/history.rake +36 -0
  54. data/lib/regresso/templates/.gitkeep +0 -0
  55. data/lib/regresso/templates/report.html.erb +44 -0
  56. data/lib/regresso/version.rb +6 -0
  57. data/lib/regresso/web_ui/diff_formatter.rb +46 -0
  58. data/lib/regresso/web_ui/public/css/app.css +239 -0
  59. data/lib/regresso/web_ui/public/js/app.js +117 -0
  60. data/lib/regresso/web_ui/result_store.rb +60 -0
  61. data/lib/regresso/web_ui/server.rb +70 -0
  62. data/lib/regresso/web_ui/views/index.erb +58 -0
  63. data/lib/regresso/web_ui/views/layout.erb +13 -0
  64. data/lib/regresso/web_ui.rb +5 -0
  65. data/lib/regresso.rb +46 -0
  66. data/sig/regresso.rbs +4 -0
  67. data/site/Gemfile +7 -0
  68. data/site/assets/logo-header.png +0 -0
  69. data/site/assets/site.css +458 -0
  70. data/site/content/index.md +6 -0
  71. data/site/craze.yml +24 -0
  72. data/site/dist/assets/logo-header.svg +28 -0
  73. data/site/dist/assets/site.css +458 -0
  74. data/site/dist/index.html +232 -0
  75. data/site/dist/search.json +9 -0
  76. data/site/dist/sitemap.xml +7 -0
  77. data/site/templates/index.erb +232 -0
  78. data/site/templates/layout.erb +17 -0
  79. metadata +190 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "regresso/rspec"
4
+
5
+ ::RSpec.shared_examples "regresso:api_regression" do |config|
6
+ config[:patterns].each do |pattern|
7
+ context(pattern[:name] || pattern[:endpoint]) do
8
+ it "has no regression" do
9
+ old_source = Regresso::Adapters::Http.new(
10
+ base_url: config[:old_base_url],
11
+ endpoint: pattern[:endpoint],
12
+ params: pattern[:params] || {},
13
+ headers: pattern[:headers] || {}
14
+ )
15
+
16
+ new_source = Regresso::Adapters::Http.new(
17
+ base_url: config[:new_base_url],
18
+ endpoint: pattern[:endpoint],
19
+ params: pattern[:params] || {},
20
+ headers: pattern[:headers] || {}
21
+ )
22
+
23
+ expect(new_source)
24
+ .to have_no_regression_from(old_source)
25
+ .with_tolerance(config[:tolerance] || 0.0)
26
+ .ignoring(*(config[:ignore_paths] || []))
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ ::RSpec.shared_examples "regresso:csv_regression" do |config|
33
+ it "CSV export has no regression" do
34
+ old_csv = config[:old_csv_source].call
35
+ new_csv = config[:new_csv_source].call
36
+
37
+ expect(new_csv)
38
+ .to have_no_regression_from(old_csv)
39
+ .with_tolerance(config[:tolerance] || 0.0)
40
+ .ignoring(*(config[:ignore_columns] || []))
41
+ end
42
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "regresso"
4
+ require "regresso/snapshot_manager"
5
+
6
+ module Regresso
7
+ # RSpec integration for Regresso.
8
+ module RSpec
9
+ # Custom matchers for regression and snapshot comparisons.
10
+ module Matchers
11
+ extend ::RSpec::Matchers::DSL
12
+
13
+ matcher :have_no_regression_from do |source_a|
14
+ match do |source_b|
15
+ @config = build_config
16
+ @comparator = Regresso::Comparator.new(
17
+ source_a: normalize_source(source_a),
18
+ source_b: normalize_source(source_b),
19
+ config: @config
20
+ )
21
+ @result = @comparator.compare
22
+ @result.passed?
23
+ end
24
+
25
+ chain :with_tolerance do |tolerance|
26
+ @tolerance = tolerance
27
+ end
28
+
29
+ chain :ignoring do |*paths|
30
+ @ignore_paths = paths.flatten
31
+ end
32
+
33
+ chain :with_path_tolerance do |path, tolerance|
34
+ @path_tolerances ||= {}
35
+ @path_tolerances[path] = tolerance
36
+ end
37
+
38
+ chain :order_insensitive do
39
+ @order_insensitive = true
40
+ end
41
+
42
+ failure_message do
43
+ message = "Expected no regression, but found #{@result.meaningful_diffs.size} difference(s):\n\n"
44
+ @result.meaningful_diffs.first(10).each do |diff|
45
+ message += " - #{diff}\n"
46
+ end
47
+ if @result.meaningful_diffs.size > 10
48
+ message += " ... and #{@result.meaningful_diffs.size - 10} more\n"
49
+ end
50
+ message
51
+ end
52
+
53
+ failure_message_when_negated do
54
+ "Expected regression, but none found"
55
+ end
56
+
57
+ private
58
+
59
+ def build_config
60
+ Regresso::Configuration.new.tap do |config|
61
+ config.default_tolerance = @tolerance if @tolerance
62
+ config.ignore_paths = @ignore_paths if @ignore_paths
63
+ config.tolerance_overrides = @path_tolerances if @path_tolerances
64
+ config.array_order_sensitive = !@order_insensitive
65
+ end
66
+ end
67
+
68
+ def normalize_source(source)
69
+ case source
70
+ when Regresso::Adapters::Base
71
+ source
72
+ when Hash
73
+ if source[:url] || source[:base_url]
74
+ base_url = source[:base_url] || source[:url]
75
+ Regresso::Adapters::Http.new(**source.merge(base_url: base_url).reject { |k, _| k == :url })
76
+ elsif source[:path] && source[:path].to_s.end_with?(".csv")
77
+ Regresso::Adapters::Csv.new(**source)
78
+ elsif source[:path]
79
+ Regresso::Adapters::JsonFile.new(**source)
80
+ else
81
+ Regresso::Adapters::Proc.new { source }
82
+ end
83
+ when String
84
+ if source.end_with?(".csv")
85
+ Regresso::Adapters::Csv.new(path: source)
86
+ else
87
+ Regresso::Adapters::JsonFile.new(path: source)
88
+ end
89
+ when ::Proc
90
+ Regresso::Adapters::Proc.new(source)
91
+ else
92
+ Regresso::Adapters::Proc.new { source }
93
+ end
94
+ end
95
+ end
96
+
97
+ matcher :match_snapshot do |snapshot_name|
98
+ match do |actual|
99
+ @snapshot_manager = Regresso::SnapshotManager.new
100
+ @snapshot_path = @snapshot_manager.path_for(snapshot_name)
101
+
102
+ if ENV["UPDATE_SNAPSHOTS"] || !File.exist?(@snapshot_path)
103
+ @snapshot_manager.save(snapshot_name, actual)
104
+ true
105
+ else
106
+ @config = build_snapshot_config
107
+ expected = @snapshot_manager.load(snapshot_name)
108
+ differ = Regresso::Differ.new(@config)
109
+ @diffs = differ.diff(expected, actual)
110
+ @diffs.empty?
111
+ end
112
+ end
113
+
114
+ chain :with_tolerance do |tolerance|
115
+ @tolerance = tolerance
116
+ end
117
+
118
+ chain :ignoring do |*paths|
119
+ @ignore_paths = paths.flatten
120
+ end
121
+
122
+ failure_message do
123
+ "Snapshot mismatch for '#{snapshot_name}':\n" +
124
+ @diffs.first(10).map { |diff| " - #{diff}" }.join("\n")
125
+ end
126
+
127
+ private
128
+
129
+ def build_snapshot_config
130
+ Regresso::Configuration.new.tap do |config|
131
+ config.default_tolerance = @tolerance || 0.0
132
+ config.ignore_paths = @ignore_paths || []
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ ::RSpec.configure do |config|
141
+ config.include Regresso::RSpec::Matchers
142
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Regresso
7
+ # Manages JSON snapshots on disk.
8
+ class SnapshotManager
9
+ # Default directory for stored snapshots.
10
+ DEFAULT_DIR = "spec/snapshots/regresso"
11
+
12
+ # @param base_dir [String,nil]
13
+ def initialize(base_dir: nil)
14
+ @base_dir = base_dir || DEFAULT_DIR
15
+ end
16
+
17
+ # @param name [String]
18
+ # @return [String]
19
+ def path_for(name)
20
+ File.join(@base_dir, "#{name}.json")
21
+ end
22
+
23
+ # @param name [String]
24
+ # @param data [Object]
25
+ # @return [void]
26
+ def save(name, data)
27
+ path = path_for(name)
28
+ FileUtils.mkdir_p(File.dirname(path))
29
+ File.write(path, JSON.pretty_generate(data))
30
+ end
31
+
32
+ # @param name [String]
33
+ # @return [Object]
34
+ def load(name)
35
+ JSON.parse(File.read(path_for(name)))
36
+ end
37
+
38
+ # @param name [String]
39
+ # @return [Boolean]
40
+ def exists?(name)
41
+ File.exist?(path_for(name))
42
+ end
43
+
44
+ # @param name [String]
45
+ # @return [void]
46
+ def delete(name)
47
+ FileUtils.rm_f(path_for(name))
48
+ end
49
+
50
+ # @return [Array<String>]
51
+ def list
52
+ Dir.glob(File.join(@base_dir, "*.json")).map do |path|
53
+ File.basename(path, ".json")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "regresso/parallel"
5
+ require "regresso/ci"
6
+
7
+ namespace :regresso do
8
+ desc "Run regression comparisons and write JUnit XML"
9
+ task :run, [:comparisons_file, :output] do |_task, args|
10
+ file = args[:comparisons_file] || ENV["REGRESSO_COMPARISONS"]
11
+ raise "comparisons file is required" unless file
12
+ raise "comparisons file not found: #{file}" unless File.exist?(file)
13
+
14
+ load file
15
+ comparisons = Object.const_get("REGRESSO_COMPARISONS")
16
+
17
+ runner = Regresso::Parallel::Runner.new(comparisons: comparisons)
18
+ result = runner.run
19
+ reporter = Regresso::CI::Reporter.new(result)
20
+
21
+ output = args[:output] || ENV["REGRESSO_JUNIT_XML"] || "tmp/regresso-junit.xml"
22
+ FileUtils.mkdir_p(File.dirname(output))
23
+ reporter.write_junit_xml(output)
24
+
25
+ puts "Regresso result: #{result.summary}"
26
+ end
27
+
28
+ desc "Post Regresso report as a GitHub PR comment"
29
+ task :comment_pr, [:comparisons_file] do |_task, args|
30
+ token = ENV["GITHUB_TOKEN"]
31
+ repo = ENV["GITHUB_REPOSITORY"]
32
+ pr_number = ENV["GITHUB_PR_NUMBER"]&.to_i
33
+
34
+ raise "GITHUB_TOKEN is required" unless token
35
+ raise "GITHUB_REPOSITORY is required" unless repo
36
+ raise "GITHUB_PR_NUMBER is required" unless pr_number
37
+
38
+ file = args[:comparisons_file] || ENV["REGRESSO_COMPARISONS"]
39
+ raise "comparisons file is required" unless file
40
+ raise "comparisons file not found: #{file}" unless File.exist?(file)
41
+
42
+ load file
43
+ comparisons = Object.const_get("REGRESSO_COMPARISONS")
44
+
45
+ runner = Regresso::Parallel::Runner.new(comparisons: comparisons)
46
+ result = runner.run
47
+
48
+ commenter = Regresso::CI::GitHubPRCommenter.new(token: token, repo: repo, pr_number: pr_number)
49
+ commenter.post_comment(result)
50
+ end
51
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "regresso/history"
5
+
6
+ namespace :regresso do
7
+ namespace :history do
8
+ desc "Print history statistics"
9
+ task :stats, [:period] do |_task, args|
10
+ store = Regresso::History::Store.new
11
+ stats = store.statistics(period: (args[:period] || :week).to_sym)
12
+ puts stats
13
+ end
14
+
15
+ desc "Generate history trend report"
16
+ task :report, [:output, :period] do |_task, args|
17
+ store = Regresso::History::Store.new
18
+ reporter = Regresso::History::TrendReporter.new(store)
19
+ output = args[:output] || "tmp/regresso_trends.html"
20
+ FileUtils.mkdir_p(File.dirname(output))
21
+ File.write(output, reporter.generate(period: (args[:period] || :week).to_sym))
22
+ puts "Wrote #{output}"
23
+ end
24
+
25
+ desc "Cleanup history entries older than given days"
26
+ task :cleanup, [:days] do |_task, args|
27
+ days = (args[:days] || 30).to_i
28
+ cutoff = Time.now - (60 * 60 * 24 * days)
29
+
30
+ backend = Regresso::History::FileBackend.new
31
+ backend.all.each do |entry|
32
+ backend.delete(entry.id) if entry.timestamp < cutoff
33
+ end
34
+ end
35
+ end
36
+ end
File without changes
@@ -0,0 +1,44 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Regresso Report</title>
6
+ <style>
7
+ body { font-family: Arial, sans-serif; margin: 24px; }
8
+ table { border-collapse: collapse; width: 100%; margin-top: 16px; }
9
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
10
+ th { background: #f4f4f4; }
11
+ .diff-added { color: #0a7d00; }
12
+ .diff-removed { color: #a40000; }
13
+ .diff-changed { color: #0a4aa8; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h1>Regresso Report</h1>
18
+
19
+ <p><strong>Passed:</strong> <%= @result.passed? %></p>
20
+ <p><strong>Total diffs:</strong> <%= @result.diffs.size %></p>
21
+ <p><strong>Meaningful diffs:</strong> <%= @result.meaningful_diffs.size %></p>
22
+
23
+ <table>
24
+ <thead>
25
+ <tr>
26
+ <th>Path</th>
27
+ <th>Old</th>
28
+ <th>New</th>
29
+ <th>Type</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% @result.meaningful_diffs.each do |diff| %>
34
+ <tr>
35
+ <td><%= diff.path.to_s %></td>
36
+ <td><%= diff.old_value.inspect %></td>
37
+ <td><%= diff.new_value.inspect %></td>
38
+ <td class="diff-<%= diff.type %>"><%= diff.type %></td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+ </body>
44
+ </html>
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Regresso
4
+ # Gem version string.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Regresso
6
+ module WebUI
7
+ # Formats diffs for Web UI rendering.
8
+ class DiffFormatter
9
+ # @param diffs [Array<Hash>]
10
+ # @param mode [Symbol] :html or :plain
11
+ # @return [String]
12
+ def format(diffs, mode: :html)
13
+ case mode
14
+ when :html
15
+ diffs.map { |diff| format_html(diff) }.join("\n")
16
+ when :plain
17
+ diffs.map { |diff| format_plain(diff) }.join("\n")
18
+ else
19
+ raise ArgumentError, "Unknown format: #{mode}"
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def format_html(diff)
26
+ type = CGI.escapeHTML(diff["type"].to_s)
27
+ path = CGI.escapeHTML(diff["path"].to_s)
28
+ old_value = CGI.escapeHTML(diff["old_value"].inspect)
29
+ new_value = CGI.escapeHTML(diff["new_value"].inspect)
30
+
31
+ <<~HTML.strip
32
+ <div class="diff-row diff-#{type}">
33
+ <span class="diff-path">#{path}</span>
34
+ <span class="diff-old">#{old_value}</span>
35
+ <span class="diff-new">#{new_value}</span>
36
+ <span class="diff-type">#{type}</span>
37
+ </div>
38
+ HTML
39
+ end
40
+
41
+ def format_plain(diff)
42
+ "#{diff['path']}: #{diff['old_value'].inspect} -> #{diff['new_value'].inspect}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,239 @@
1
+ :root {
2
+ --bg: #f4f1ea;
3
+ --ink: #1b1b1b;
4
+ --muted: #6c6c6c;
5
+ --accent: #d74b23;
6
+ --accent-2: #1d4d9f;
7
+ --surface: #ffffff;
8
+ --shadow: rgba(22, 22, 22, 0.15);
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ font-family: "Space Grotesk", "Avenir Next", "Futura", sans-serif;
18
+ color: var(--ink);
19
+ background: radial-gradient(circle at top left, #fbe7d5, transparent 55%),
20
+ radial-gradient(circle at 20% 80%, #d2e2ff, transparent 50%),
21
+ var(--bg);
22
+ min-height: 100vh;
23
+ }
24
+
25
+ .app-shell {
26
+ padding: 32px clamp(16px, 4vw, 48px) 48px;
27
+ animation: fadeIn 0.6s ease-out;
28
+ }
29
+
30
+ .app-header {
31
+ display: flex;
32
+ flex-wrap: wrap;
33
+ gap: 24px;
34
+ align-items: flex-end;
35
+ justify-content: space-between;
36
+ }
37
+
38
+ .kicker {
39
+ text-transform: uppercase;
40
+ letter-spacing: 0.12em;
41
+ font-size: 12px;
42
+ margin: 0 0 6px;
43
+ color: var(--accent-2);
44
+ }
45
+
46
+ h1 {
47
+ margin: 0;
48
+ font-size: clamp(32px, 4vw, 52px);
49
+ }
50
+
51
+ .subtitle {
52
+ margin: 8px 0 0;
53
+ color: var(--muted);
54
+ max-width: 520px;
55
+ }
56
+
57
+ .stats {
58
+ display: grid;
59
+ grid-template-columns: repeat(3, minmax(80px, 1fr));
60
+ gap: 12px;
61
+ }
62
+
63
+ .stat {
64
+ background: rgba(255, 255, 255, 0.75);
65
+ border-radius: 16px;
66
+ padding: 12px 16px;
67
+ box-shadow: 0 10px 24px var(--shadow);
68
+ backdrop-filter: blur(6px);
69
+ }
70
+
71
+ .stat-label {
72
+ font-size: 12px;
73
+ color: var(--muted);
74
+ }
75
+
76
+ .stat-value {
77
+ display: block;
78
+ font-size: 20px;
79
+ font-weight: 600;
80
+ }
81
+
82
+ .app-main {
83
+ display: grid;
84
+ grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
85
+ gap: 24px;
86
+ margin-top: 32px;
87
+ }
88
+
89
+ .panel {
90
+ background: var(--surface);
91
+ border-radius: 20px;
92
+ padding: 20px;
93
+ box-shadow: 0 18px 40px var(--shadow);
94
+ }
95
+
96
+ .panel-header {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: space-between;
100
+ gap: 12px;
101
+ margin-bottom: 16px;
102
+ }
103
+
104
+ .button {
105
+ border: none;
106
+ background: var(--accent);
107
+ color: white;
108
+ padding: 10px 16px;
109
+ border-radius: 999px;
110
+ font-weight: 600;
111
+ cursor: pointer;
112
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
113
+ }
114
+
115
+ .button:hover {
116
+ transform: translateY(-2px);
117
+ box-shadow: 0 8px 16px rgba(215, 75, 35, 0.35);
118
+ }
119
+
120
+ .list {
121
+ display: grid;
122
+ gap: 12px;
123
+ }
124
+
125
+ .list-item {
126
+ border: 1px solid #e5e5e5;
127
+ border-radius: 16px;
128
+ padding: 14px;
129
+ cursor: pointer;
130
+ transition: border 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
131
+ background: #fff8f1;
132
+ }
133
+
134
+ .list-item:hover {
135
+ border-color: var(--accent);
136
+ transform: translateY(-2px);
137
+ box-shadow: 0 10px 18px rgba(215, 75, 35, 0.2);
138
+ }
139
+
140
+ .list-item.active {
141
+ border-color: var(--accent-2);
142
+ background: #eef4ff;
143
+ }
144
+
145
+ .detail-grid {
146
+ display: grid;
147
+ gap: 16px;
148
+ }
149
+
150
+ .summary {
151
+ display: grid;
152
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
153
+ gap: 12px;
154
+ }
155
+
156
+ .summary-card {
157
+ padding: 12px 14px;
158
+ border-radius: 14px;
159
+ background: #f7f7f7;
160
+ }
161
+
162
+ .summary-card strong {
163
+ display: block;
164
+ font-size: 18px;
165
+ }
166
+
167
+ .diffs {
168
+ min-height: 240px;
169
+ border-radius: 16px;
170
+ background: #fdfdfd;
171
+ border: 1px solid #e9e9e9;
172
+ padding: 16px;
173
+ display: grid;
174
+ gap: 8px;
175
+ }
176
+
177
+ .diff-row {
178
+ display: grid;
179
+ grid-template-columns: 1fr 1fr 1fr auto;
180
+ gap: 12px;
181
+ padding: 8px 10px;
182
+ border-radius: 10px;
183
+ background: #ffffff;
184
+ border: 1px solid #f0f0f0;
185
+ font-size: 13px;
186
+ }
187
+
188
+ .diff-added {
189
+ border-left: 4px solid #2c9f45;
190
+ }
191
+
192
+ .diff-removed {
193
+ border-left: 4px solid #d35454;
194
+ }
195
+
196
+ .diff-changed {
197
+ border-left: 4px solid #3c6fd1;
198
+ }
199
+
200
+ .diff-path {
201
+ font-weight: 600;
202
+ }
203
+
204
+ .detail-meta {
205
+ color: var(--muted);
206
+ margin: 6px 0 0;
207
+ }
208
+
209
+ .filters {
210
+ display: flex;
211
+ gap: 10px;
212
+ align-items: center;
213
+ }
214
+
215
+ .input,
216
+ .select {
217
+ border-radius: 999px;
218
+ border: 1px solid #ddd;
219
+ padding: 8px 14px;
220
+ font-size: 14px;
221
+ background: #fff;
222
+ }
223
+
224
+ .empty {
225
+ color: var(--muted);
226
+ text-align: center;
227
+ margin-top: 40px;
228
+ }
229
+
230
+ @media (max-width: 900px) {
231
+ .app-main {
232
+ grid-template-columns: 1fr;
233
+ }
234
+ }
235
+
236
+ @keyframes fadeIn {
237
+ from { opacity: 0; transform: translateY(10px); }
238
+ to { opacity: 1; transform: translateY(0); }
239
+ }