Meroar commited on
Commit
52954c2
Β·
verified Β·
1 Parent(s): c0fd883

Add a tool tip icon at the top of the page next to the sync scroll toggle that, when clicked, opens up a modal that explains how the diffs are determined and what the limitations are and potential flaws so that users know what to expect and how to use / understand it

Browse files
Files changed (4) hide show
  1. README.md +8 -5
  2. index.html +178 -18
  3. script.js +534 -0
  4. style.css +235 -19
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Filediff Viewer
3
- emoji: πŸ‘€
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
+ title: FileDiff Viewer πŸš€
3
+ colorFrom: green
4
+ colorTo: blue
5
+ emoji: 🐳
6
  sdk: static
7
  pinned: false
8
+ tags:
9
+ - deepsite-v3
10
  ---
11
 
12
+ # Welcome to your new DeepSite project!
13
+ This project was created with [DeepSite](https://huggingface.co/deepsite).
index.html CHANGED
@@ -1,19 +1,179 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>FileDiff Viewer</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpolyline points='14 2 14 8 20 8'/%3E%3Cline x1='16' y1='13' x2='8' y2='13'/%3E%3Cline x1='16' y1='17' x2='8' y2='17'/%3E%3Cpolyline points='10 9 9 9 8 9'/%3E%3C/svg%3E" />
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+ <body>
11
+ <header class="app-header">
12
+ <div class="brand">
13
+ <span class="logo">≋</span>
14
+ <h1>FileDiff Viewer</h1>
15
+ </div>
16
+
17
+ <div class="header-actions">
18
+ <label class="switch" title="Sync scrolling">
19
+ <input type="checkbox" id="syncScroll" checked />
20
+ <span class="slider"></span>
21
+ <span class="switch-label">Sync scroll</span>
22
+ </label>
23
+
24
+ <label class="switch" title="Ignore whitespace-only changes">
25
+ <input type="checkbox" id="ignoreWs" />
26
+ <span class="slider"></span>
27
+ <span class="switch-label">Ignore whitespace</span>
28
+ </label>
29
+ </div>
30
+ </header>
31
+
32
+ <section class="controls">
33
+ <div class="file-block" data-side="left">
34
+ <div class="block-header">
35
+ <strong>File A</strong>
36
+ <div class="file-actions">
37
+ <input type="file" id="fileLeft" accept=".txt,.js,.ts,.json,.md,.css,.html,.xml,.yml,.yaml,.csv,.ini,.py,.rb,.go,.java,.c,.h,.cpp,.cs,.sql" />
38
+ <button class="btn secondary" id="pasteLeftBtn" type="button">Paste</button>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="textarea-wrap hidden" id="pasteLeftWrap">
43
+ <textarea id="textLeft" placeholder="Paste contents of File A here..." spellcheck="false"></textarea>
44
+ <div class="paste-actions">
45
+ <button class="btn" id="applyLeftText" type="button">Use Pasted A</button>
46
+ <button class="btn ghost" id="cancelLeftText" type="button">Cancel</button>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="file-summary" id="leftSummary">No file selected.</div>
51
+ </div>
52
+
53
+ <div class="file-block" data-side="right">
54
+ <div class="block-header">
55
+ <strong>File B</strong>
56
+ <div class="file-actions">
57
+ <input type="file" id="fileRight" accept=".txt,.js,.ts,.json,.md,.css,.html,.xml,.yml,.yaml,.csv,.ini,.py,.rb,.go,.java,.c,.h,.cpp,.cs,.sql" />
58
+ <button class="btn secondary" id="pasteRightBtn" type="button">Paste</button>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="textarea-wrap hidden" id="pasteRightWrap">
63
+ <textarea id="textRight" placeholder="Paste contents of File B here..." spellcheck="false"></textarea>
64
+ <div class="paste-actions">
65
+ <button class="btn" id="applyRightText" type="button">Use Pasted B</button>
66
+ <button class="btn ghost" id="cancelRightText" type="button">Cancel</button>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="file-summary" id="rightSummary">No file selected.</div>
71
+ </div>
72
+ </section>
73
+
74
+ <section class="stats" id="stats" hidden>
75
+ <div class="stat">
76
+ <span class="label">Added</span>
77
+ <span class="value" id="statAdded">0</span>
78
+ </div>
79
+ <div class="stat">
80
+ <span class="label">Deleted</span>
81
+ <span class="value" id="statDeleted">0</span>
82
+ </div>
83
+ <div class="stat">
84
+ <span class="label">Modified</span>
85
+ <span class="value" id="statModified">0</span>
86
+ </div>
87
+ <div class="stat">
88
+ <span class="label">Moved</span>
89
+ <span class="value" id="statMoved">0</span>
90
+ </div>
91
+ </section>
92
+
93
+ <main class="diff-container">
94
+ <div class="pane" id="paneLeft" aria-label="File A">
95
+ <div class="pane-header">
96
+ <h2>File A</h2>
97
+ <span class="file-meta" id="metaLeft"></span>
98
+ </div>
99
+ <div class="code-wrap">
100
+ <table class="code">
101
+ <tbody id="tbodyLeft"></tbody>
102
+ </table>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="pane" id="paneRight" aria-label="File B">
107
+ <div class="pane-header">
108
+ <h2>File B</h2>
109
+ <span class="file-meta" id="metaRight"></span>
110
+ </div>
111
+ <div class="code-wrap">
112
+ <table class="code">
113
+ <tbody id="tbodyRight"></tbody>
114
+ </table>
115
+ </div>
116
+ </div>
117
+ </main>
118
+ <footer class="app-footer">
119
+ <small>Tip: Click the jump button next to a moved/modified line to focus its counterpart.</small>
120
+ </footer>
121
+
122
+ <!-- Modal -->
123
+ <div class="modal-overlay hidden" id="modalOverlay">
124
+ <div class="modal">
125
+ <div class="modal-header">
126
+ <h3>How FileDiff Viewer Works</h3>
127
+ <button class="modal-close" id="modalClose" aria-label="Close modal">
128
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
129
+ <line x1="18" y1="6" x2="6" y2="18"/>
130
+ <line x1="6" y1="6" x2="18" y2="18"/>
131
+ </svg>
132
+ </button>
133
+ </div>
134
+ <div class="modal-content">
135
+ <div class="modal-section">
136
+ <h4>πŸ” Algorithm Overview</h4>
137
+ <p>FileDiff Viewer uses a <strong>Longest Common Subsequence (LCS)</strong>-based algorithm to identify changes between two files:</p>
138
+ <ul>
139
+ <li><strong>Equal lines:</strong> Identical content (whitespace-aware if the ignore option is enabled)</li>
140
+ <li><strong>Modified lines:</strong> Similar but not identical content (detected using token similarity)</li>
141
+ <li><strong>Added lines:</strong> Lines present in File B but not in File A</li>
142
+ <li><strong>Deleted lines:</strong> Lines present in File A but not in File B</li>
143
+ <li><strong>Moved lines:</strong> Lines that are identical but appear in different positions</li>
144
+ </ul>
145
+ </div>
146
+
147
+ <div class="modal-section">
148
+ <h4>⚠️ Limitations & Known Issues</h4>
149
+ <ul>
150
+ <li><strong>Token similarity threshold:</strong> Modified lines are detected using a 55% similarity threshold, which may not always match human intuition</li>
151
+ <li><strong>Moved line detection:</strong> Only detects exact matches that aren't already classified as modifications</li>
152
+ <li><strong>Large files:</strong> Performance degrades with files larger than 5MB due to O(nΒ²) complexity</li>
153
+ <li><strong>Context awareness:</strong> The algorithm works line-by-line without understanding code structure or semantics</li>
154
+ <li><strong>Whitespace handling:</strong> The "ignore whitespace" option only affects change detection, not the actual displayed content</li>
155
+ </ul>
156
+ </div>
157
+
158
+ <div class="modal-section">
159
+ <h4>πŸ’‘ Best Practices</h4>
160
+ <ul>
161
+ <li>Use the <strong>Jump buttons (⟷)</strong> to quickly navigate between related changes</li>
162
+ <li>Enable <strong>Sync scroll</strong> for easier comparison of large diffs</li>
163
+ <li>For large files, consider using the <strong>ignore whitespace</strong> option to focus on meaningful changes</li>
164
+ <li>Review the statistics to get a quick overview of change types</li>
165
+ </ul>
166
+ </div>
167
+
168
+ <div class="modal-section">
169
+ <h4>πŸ”§ Technical Details</h4>
170
+ <p>The algorithm combines LCS for exact matches with Jaccard similarity for modified line detection. Similarity is computed by comparing token sets between lines, providing a balance between accuracy and performance.</p>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <script src="script.js"></script>
177
+ <script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
178
+ </body>
179
  </html>
script.js ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // FileDiff Viewer - Line-by-line visual diff with moved lines and jump-to
2
+ (() => {
3
+ // State
4
+ const state = {
5
+ files: {
6
+ left: { name: '', size: 0, text: '' },
7
+ right: { name: '', size: 0, text: '' },
8
+ },
9
+ options: {
10
+ ignoreWhitespace: false,
11
+ syncScroll: true,
12
+ },
13
+ result: null, // diff result
14
+ syncing: false,
15
+ };
16
+ // Elements
17
+ const els = {
18
+ // file inputs and paste
19
+ fileLeft: document.getElementById('fileLeft'),
20
+ fileRight: document.getElementById('fileRight'),
21
+ pasteLeftBtn: document.getElementById('pasteLeftBtn'),
22
+ pasteRightBtn: document.getElementById('pasteRightBtn'),
23
+ pasteLeftWrap: document.getElementById('pasteLeftWrap'),
24
+ pasteRightWrap: document.getElementById('pasteRightWrap'),
25
+ textLeft: document.getElementById('textLeft'),
26
+ textRight: document.getElementById('textRight'),
27
+ applyLeftText: document.getElementById('applyLeftText'),
28
+ applyRightText: document.getElementById('applyRightText'),
29
+ cancelLeftText: document.getElementById('cancelLeftText'),
30
+ cancelRightText: document.getElementById('cancelRightText'),
31
+ leftSummary: document.getElementById('leftSummary'),
32
+ rightSummary: document.getElementById('rightSummary'),
33
+
34
+ // options
35
+ ignoreWs: document.getElementById('ignoreWs'),
36
+ syncScroll: document.getElementById('syncScroll'),
37
+
38
+ // modal
39
+ infoBtn: document.getElementById('infoBtn'),
40
+ modalOverlay: document.getElementById('modalOverlay'),
41
+ modalClose: document.getElementById('modalClose'),
42
+
43
+ // meta
44
+ metaLeft: document.getElementById('metaLeft'),
45
+ metaRight: document.getElementById('metaRight'),
46
+
47
+ // panes and bodies
48
+ paneLeft: document.getElementById('paneLeft'),
49
+ paneRight: document.getElementById('paneRight'),
50
+ tbodyLeft: document.getElementById('tbodyLeft'),
51
+ tbodyRight: document.getElementById('tbodyRight'),
52
+
53
+ // stats
54
+ statsWrap: document.getElementById('stats'),
55
+ statAdded: document.getElementById('statAdded'),
56
+ statDeleted: document.getElementById('statDeleted'),
57
+ statModified: document.getElementById('statModified'),
58
+ statMoved: document.getElementById('statMoved'),
59
+ };
60
+ // Utilities
61
+ const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB safety limit
62
+
63
+ const byId = (id) => document.getElementById(id);
64
+ const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
65
+ const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
66
+ const trimWs = (s) => s.trim();
67
+ const isBlank = (s) => s.length === 0;
68
+ const fileSizeHuman = (n) => {
69
+ if (n < 1024) return `${n} B`;
70
+ if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
71
+ return `${(n/1024/1024).toFixed(1)} MB`;
72
+ };
73
+
74
+ function similarity(a, b) {
75
+ // Jaccard similarity over tokens (naive but useful)
76
+ const ta = (a.trim().match(/\S+/g) || []);
77
+ const tb = (b.trim().match(/\S+/g) || []);
78
+ const sa = new Set(ta);
79
+ const sb = new Set(tb);
80
+ let inter = 0;
81
+ for (const t of sa) if (sb.has(t)) inter++;
82
+ const union = new Set([...ta, ...tb]).size || 1;
83
+ return inter / union;
84
+ }
85
+
86
+ // Diff engine
87
+ function lcsMatrix(a, b) {
88
+ const n = a.length, m = b.length;
89
+ const dp = Array(n + 1);
90
+ for (let i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
91
+ for (let i = n - 1; i >= 0; i--) {
92
+ for (let j = m - 1; j >= 0; j--) {
93
+ if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
94
+ else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
95
+ }
96
+ }
97
+ return dp;
98
+ }
99
+
100
+ function backtrackLCS(dp, a, b) {
101
+ const pairs = [];
102
+ let i = 0, j = 0;
103
+ while (i < a.length && j < b.length) {
104
+ if (a[i] === b[j]) {
105
+ pairs.push([i, j]);
106
+ i++; j++;
107
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
108
+ i++;
109
+ } else {
110
+ j++;
111
+ }
112
+ }
113
+ return pairs; // array of [i, j] indices of equal lines
114
+ }
115
+
116
+ function findEqualRanges(pairs) {
117
+ if (!pairs.length) return [];
118
+ const ranges = [];
119
+ let startI = pairs[0][0], startJ = pairs[0][1], prevI = startI, prevJ = startJ;
120
+ for (let k = 1; k < pairs.length; k++) {
121
+ const [i, j] = pairs[k];
122
+ if (i === prevI + 1 && j === prevJ + 1) {
123
+ prevI = i; prevJ = j;
124
+ } else {
125
+ ranges.push([startI, prevI, startJ, prevJ]);
126
+ startI = i; startJ = j; prevI = i; prevJ = j;
127
+ }
128
+ }
129
+ ranges.push([startI, prevI, startJ, prevJ]);
130
+ return ranges;
131
+ }
132
+
133
+ function diffLines(textA, textB, ignoreWs) {
134
+ const A = normalize(textA).split('\n');
135
+ const B = normalize(textB).split('\n');
136
+ const a = ignoreWs ? A.map(trimWs) : A;
137
+ const b = ignoreWs ? B.map(trimWs) : B;
138
+
139
+ const dp = lcsMatrix(a, b);
140
+ const pairs = backtrackLCS(dp, a, b);
141
+ const ranges = findEqualRanges(pairs);
142
+
143
+ const aLen = a.length, bLen = b.length;
144
+ let ai = 0, bj = 0;
145
+
146
+ const aRes = [];
147
+ const bRes = [];
148
+
149
+ // Mapping for moved lines
150
+ const aToB = new Map(); // aIndex -> bIndex (for moved)
151
+ const bToA = new Map(); // bIndex -> aIndex (for moved)
152
+
153
+ function pushEqualRange(sI, eI, sJ, eJ) {
154
+ for (let k = 0; k <= eI - sI; k++) {
155
+ const i = sI + k, j = sJ + k;
156
+ aRes.push({ type: 'equal', aIndex: i, bIndex: null, aText: A[i], bText: null });
157
+ bRes.push({ type: 'equal', aIndex: null, bIndex: j, aText: null, bText: B[j] });
158
+ }
159
+ ai = eI + 1; bj = eJ + 1;
160
+ }
161
+
162
+ for (const [sI, eI, sJ, eJ] of ranges) {
163
+ // First, deletions/insertions/modifications between current ai..sI and bj..sJ
164
+ while (ai < sI && bj < sJ) {
165
+ const del = a[ai], ins = b[bj];
166
+ const sim = similarity(a[ai], b[bj]);
167
+ if (sim >= 0.55) {
168
+ // Modified pair
169
+ aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj });
170
+ bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai });
171
+ aToB.set(ai, bj);
172
+ bToA.set(bj, ai);
173
+ ai++; bj++;
174
+ } else {
175
+ aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
176
+ bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
177
+ ai++;
178
+ }
179
+ }
180
+ while (ai < sI) {
181
+ aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
182
+ bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
183
+ ai++;
184
+ }
185
+ while (bj < sJ) {
186
+ aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
187
+ bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: null, bText: B[bj] });
188
+ bj++;
189
+ }
190
+
191
+ // Now equal run
192
+ pushEqualRange(sI, eI, sJ, eJ);
193
+ }
194
+
195
+ // Tail
196
+ while (ai < aLen && bj < bLen) {
197
+ const sim = similarity(a[ai], b[bj]);
198
+ if (sim >= 0.55) {
199
+ aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj });
200
+ bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai });
201
+ aToB.set(ai, bj);
202
+ bToA.set(bj, ai);
203
+ ai++; bj++;
204
+ } else {
205
+ aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
206
+ bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
207
+ ai++;
208
+ }
209
+ }
210
+ while (ai < aLen) {
211
+ aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null });
212
+ bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
213
+ ai++;
214
+ }
215
+ while (bj < bLen) {
216
+ aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null });
217
+ bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: B[bj], bText: null });
218
+ bj++;
219
+ }
220
+
221
+ // Moved detection: single deleted lines whose partner is a single inserted line later
222
+ const aDelGroups = groupBy(aRes.filter(x => x.type === 'deleted'), (x) => x.aIndex);
223
+ const bInsGroups = groupBy(bRes.filter(x => x.type === 'inserted'), (x) => x.bIndex);
224
+
225
+ // Make reverse maps with indices
226
+ const aDeletedSet = new Set(aRes.filter(x => x.type === 'deleted').map(x => x.aIndex));
227
+ const bInsertedSet = new Set(bRes.filter(x => x.type === 'inserted').map(x => x.bIndex));
228
+
229
+ for (const [ai2, bj2] of aToB.entries()) {
230
+ // Already partners in modified; skip
231
+ continue;
232
+ }
233
+
234
+ // Consider unmatched singles by equality only
235
+ for (const ai2 of aDeletedSet) {
236
+ const bj2 = aToB.get(ai2);
237
+ if (bj2 != null && bInsertedSet.has(bj2)) {
238
+ // It could still be treated as moved if both are not in other relations
239
+ // We'll mark if they are exact equal (trim aware) and not already modified
240
+ const leftText = a[ai2];
241
+ const rightText = b[bj2];
242
+ if (leftText === rightText) {
243
+ // Tag both sides as moved and neutralize delete/insert
244
+ const aRow = aRes.find(x => x.aIndex === ai2 && x.type === 'deleted');
245
+ const bRow = bRes.find(x => x.bIndex === bj2 && x.type === 'inserted');
246
+ if (aRow && bRow) {
247
+ aRow.type = 'moved';
248
+ aRow.partner = bj2;
249
+ bRow.type = 'moved';
250
+ bRow.partner = ai2;
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ // Stats
257
+ const added = bRes.filter(x => x.type === 'inserted').length;
258
+ const deleted = aRes.filter(x => x.type === 'deleted').length;
259
+ const modified = aRes.filter(x => x.type === 'modified').length;
260
+ const moved = aRes.filter(x => x.type === 'moved').length;
261
+
262
+ return {
263
+ A, B,
264
+ aRes, bRes,
265
+ stats: { added, deleted, modified, moved }
266
+ };
267
+ }
268
+
269
+ function groupBy(arr, keyFn) {
270
+ const map = new Map();
271
+ for (const item of arr) {
272
+ const k = keyFn(item);
273
+ if (!map.has(k)) map.set(k, []);
274
+ map.get(k).push(item);
275
+ }
276
+ return map;
277
+ }
278
+
279
+ // Rendering
280
+ function renderDiff(result) {
281
+ const { aRes, bRes, A, B, stats } = result;
282
+
283
+ // Clear
284
+ els.tbodyLeft.innerHTML = '';
285
+ els.tbodyRight.innerHTML = '';
286
+
287
+ const fragLeft = document.createDocumentFragment();
288
+ const fragRight = document.createDocumentFragment();
289
+
290
+ for (let i = 0; i < aRes.length; i++) {
291
+ const ar = aRes[i];
292
+ const br = bRes[i];
293
+
294
+ const trL = document.createElement('tr');
295
+ trL.setAttribute('data-kind', ar.type);
296
+ trL.setAttribute('data-index', String(ar.aIndex ?? -1));
297
+ trL.id = ar.aIndex != null ? `L${ar.aIndex}` : `L-gap-${i}`;
298
+
299
+ const trR = document.createElement('tr');
300
+ trR.setAttribute('data-kind', br.type);
301
+ trR.setAttribute('data-index', String(br.bIndex ?? -1));
302
+ trR.id = br.bIndex != null ? `R${br.bIndex}` : `R-gap-${i}`;
303
+
304
+ // Line number cells
305
+ const tdNumL = document.createElement('td');
306
+ tdNumL.className = 'gutter';
307
+ tdNumL.textContent = ar.aIndex != null ? String(ar.aIndex + 1) : '';
308
+
309
+ const tdNumR = document.createElement('td');
310
+ tdNumR.className = 'gutter';
311
+ tdNumR.textContent = br.bIndex != null ? String(br.bIndex + 1) : '';
312
+
313
+ // Content cells
314
+ const tdContentL = document.createElement('td');
315
+ tdContentL.className = 'content';
316
+ tdContentL.classList.add('line');
317
+ tdContentL.classList.add(ar.type);
318
+
319
+ const tdContentR = document.createElement('td');
320
+ tdContentR.className = 'content';
321
+ tdContentR.classList.add('line');
322
+ tdContentR.classList.add(br.type);
323
+
324
+ // Prepare text
325
+ let leftText = '';
326
+ let rightText = '';
327
+
328
+ if (ar.type === 'equal') leftText = A[ar.aIndex] ?? '';
329
+ else if (ar.type === 'deleted') leftText = A[ar.aIndex] ?? '';
330
+ else if (ar.type === 'modified') leftText = A[ar.aIndex] ?? '';
331
+ else if (ar.type === 'moved') leftText = A[ar.aIndex] ?? '';
332
+ else leftText = '';
333
+
334
+ if (br.type === 'equal') rightText = B[br.bIndex] ?? '';
335
+ else if (br.type === 'inserted') rightText = B[br.bIndex] ?? '';
336
+ else if (br.type === 'modified') rightText = B[br.bIndex] ?? '';
337
+ else if (br.type === 'moved') rightText = B[br.bIndex] ?? '';
338
+ else rightText = '';
339
+
340
+ tdContentL.innerHTML = `${escapeHtml(leftText)}${renderActions(ar, 'left')}`;
341
+ tdContentR.innerHTML = `${escapeHtml(rightText)}${renderActions(br, 'right')}`;
342
+
343
+ trL.appendChild(tdNumL);
344
+ trL.appendChild(tdContentL);
345
+
346
+ trR.appendChild(tdNumR);
347
+ trR.appendChild(tdContentR);
348
+
349
+ fragLeft.appendChild(trL);
350
+ fragRight.appendChild(trR);
351
+ }
352
+
353
+ els.tbodyLeft.appendChild(fragLeft);
354
+ els.tbodyRight.appendChild(fragRight);
355
+
356
+ // Wire up actions
357
+ wireJumpButtons();
358
+
359
+ // Update stats
360
+ els.statsWrap.hidden = false;
361
+ els.statAdded.textContent = String(stats.added);
362
+ els.statDeleted.textContent = String(stats.deleted);
363
+ els.statModified.textContent = String(stats.modified);
364
+ els.statMoved.textContent = String(stats.moved);
365
+ }
366
+
367
+ function renderActions(row, side) {
368
+ // side: 'left' | 'right'
369
+ // Determine if a jump button is available
370
+ let partnerIndex = null;
371
+ if (row.type === 'moved' && row.partner != null) {
372
+ partnerIndex = row.partner;
373
+ } else if (row.type === 'modified' && row.partner != null) {
374
+ partnerIndex = row.partner;
375
+ }
376
+ if (partnerIndex == null) return '';
377
+
378
+ const targetId = side === 'left' ? `R${partnerIndex}` : `L${partnerIndex}`;
379
+ const label = side === 'left' ? 'Jump to B' : 'Jump to A';
380
+ return `<span class="actions"><button class="btn-jump" data-target="${targetId}" title="${label}" aria-label="${label}">⟷</button></span>`;
381
+ }
382
+
383
+ function wireJumpButtons() {
384
+ const buttons = document.querySelectorAll('.btn-jump');
385
+ buttons.forEach(btn => {
386
+ btn.addEventListener('click', (e) => {
387
+ e.stopPropagation();
388
+ const target = btn.getAttribute('data-target');
389
+ if (!target) return;
390
+ const el = document.getElementById(target);
391
+ if (!el) return;
392
+
393
+ el.classList.add('jump-indicator');
394
+ el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
395
+ setTimeout(() => el.classList.remove('jump-indicator'), 900);
396
+ });
397
+ });
398
+ }
399
+
400
+ // Sync scrolling
401
+ function setupSyncScrolling() {
402
+ let last = 0;
403
+ els.paneLeft.addEventListener('scroll', () => {
404
+ if (!state.options.syncScroll || state.syncing) return;
405
+ state.syncing = true;
406
+ const ratio = els.paneLeft.scrollTop / Math.max(1, (els.paneLeft.scrollHeight - els.paneLeft.clientHeight));
407
+ const target = ratio * (els.paneRight.scrollHeight - els.paneRight.clientHeight);
408
+ els.paneRight.scrollTop = target;
409
+ setTimeout(() => state.syncing = false, 0);
410
+ });
411
+
412
+ els.paneRight.addEventListener('scroll', () => {
413
+ if (!state.options.syncScroll || state.syncing) return;
414
+ state.syncing = true;
415
+ const ratio = els.paneRight.scrollTop / Math.max(1, (els.paneRight.scrollHeight - els.paneRight.clientHeight));
416
+ const target = ratio * (els.paneLeft.scrollHeight - els.paneLeft.clientHeight);
417
+ els.paneLeft.scrollTop = target;
418
+ setTimeout(() => state.syncing = false, 0);
419
+ });
420
+ }
421
+
422
+ // Compute and render pipeline
423
+ function compute() {
424
+ const leftText = state.files.left.text || '';
425
+ const rightText = state.files.right.text || '';
426
+
427
+ // Guard: large content
428
+ if (leftText.length + rightText.length > 1_000_000) {
429
+ alert('Warning: Large content. Rendering may be slow.');
430
+ }
431
+
432
+ state.result = diffLines(leftText, rightText, state.options.ignoreWhitespace);
433
+ renderDiff(state.result);
434
+ }
435
+
436
+ // File loading helpers
437
+ async function handleFileInput(inputEl, side) {
438
+ const file = inputEl.files?.[0];
439
+ if (!file) return;
440
+ if (file.size > MAX_SIZE_BYTES) {
441
+ alert(`File "${file.name}" is too large (${fileSizeHuman(file.size)}). Max allowed is ${fileSizeHuman(MAX_SIZE_BYTES)}.`);
442
+ inputEl.value = '';
443
+ return;
444
+ }
445
+ try {
446
+ const text = await file.text();
447
+ state.files[side] = { name: file.name, size: file.size, text };
448
+ updateSummary(side);
449
+ compute();
450
+ } catch (err) {
451
+ console.error(err);
452
+ alert('Failed to read the selected file.');
453
+ }
454
+ }
455
+
456
+ function updateSummary(side) {
457
+ const data = state.files[side];
458
+ const sumEl = side === 'left' ? els.leftSummary : els.rightSummary;
459
+ if (!data.text) {
460
+ sumEl.textContent = 'No file selected.';
461
+ } else {
462
+ const lines = normalize(data.text).split('\n').length;
463
+ sumEl.textContent = `${data.name || '(pasted)'} β€’ ${fileSizeHuman(data.size || data.text.length)} β€’ ${lines} line${lines !== 1 ? 's' : ''}`;
464
+ }
465
+ const metaEl = side === 'left' ? els.metaLeft : els.metaRight;
466
+ metaEl.textContent = data.name ? data.name : '(pasted)';
467
+ }
468
+
469
+ // Paste panels
470
+ function togglePaste(side, show) {
471
+ const wrap = side === 'left' ? els.pasteLeftWrap : els.pasteRightWrap;
472
+ wrap.classList.toggle('hidden', !show);
473
+ const textarea = side === 'left' ? els.textLeft : els.textRight;
474
+ if (show) textarea.focus();
475
+ }
476
+
477
+ // Events
478
+ function wireEvents() {
479
+ // File inputs
480
+ els.fileLeft.addEventListener('change', () => handleFileInput(els.fileLeft, 'left'));
481
+ els.fileRight.addEventListener('change', () => handleFileInput(els.fileRight, 'right'));
482
+
483
+ // Paste buttons
484
+ els.pasteLeftBtn.addEventListener('click', () => togglePaste('left', true));
485
+ els.pasteRightBtn.addEventListener('click', () => togglePaste('right', true));
486
+
487
+ els.applyLeftText.addEventListener('click', () => {
488
+ const text = els.textLeft.value || '';
489
+ state.files.left = { name: '(pasted)', size: text.length, text };
490
+ els.textLeft.value = '';
491
+ togglePaste('left', false);
492
+ updateSummary('left');
493
+ compute();
494
+ });
495
+ els.applyRightText.addEventListener('click', () => {
496
+ const text = els.textRight.value || '';
497
+ state.files.right = { name: '(pasted)', size: text.length, text };
498
+ els.textRight.value = '';
499
+ togglePaste('right', false);
500
+ updateSummary('right');
501
+ compute();
502
+ });
503
+ els.cancelLeftText.addEventListener('click', () => {
504
+ els.textLeft.value = '';
505
+ togglePaste('left', false);
506
+ });
507
+ els.cancelRightText.addEventListener('click', () => {
508
+ els.textRight.value = '';
509
+ togglePaste('right', false);
510
+ });
511
+
512
+ // Options
513
+ els.ignoreWs.addEventListener('change', () => {
514
+ state.options.ignoreWhitespace = els.ignoreWs.checked;
515
+ if (state.files.left.text || state.files.right.text) compute();
516
+ });
517
+ els.syncScroll.addEventListener('change', () => {
518
+ state.options.syncScroll = els.syncScroll.checked;
519
+ });
520
+
521
+ // Initial syncing
522
+ setupSyncScrolling();
523
+ }
524
+
525
+ // Init
526
+ function init() {
527
+ wireEvents();
528
+ // Initial blank
529
+ updateSummary('left');
530
+ updateSummary('right');
531
+ }
532
+
533
+ document.addEventListener('DOMContentLoaded', init);
534
+ })();
style.css CHANGED
@@ -1,28 +1,244 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
16
  }
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
  }
 
 
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
1
+ :root{
2
+ /* Theme colors - set to undefined to force external override, or keep defaults */
3
+ --color-primary: #0ea5e9; /* sky-500 */
4
+ --color-primary-600: #0284c7;
5
+ --color-secondary: #64748b; /* slate-500 */
6
+ --color-bg: #ffffff;
7
+ --color-bg-soft: #f8fafc; /* slate-50 */
8
+ --color-text: #0f172a; /* slate-900 */
9
+ --color-muted: #475569; /* slate-600 */
10
+ --color-border: #e2e8f0; /* slate-200 */
11
+ --color-added-bg: #ecfdf5; /* emerald-50 */
12
+ --color-del-bg: #fef2f2; /* rose-50 */
13
+ --color-mod-bg: #fff7ed; /* orange-50 */
14
+ --color-move-bg: #eff6ff; /* blue-50 */
15
+ --color-added-border: #10b981;
16
+ --color-del-border: #ef4444;
17
+ --color-mod-border: #f59e0b;
18
+ --color-move-border: #3b82f6;
19
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
20
+ --shadow-md: 0 4px 8px rgba(0,0,0,0.08);
21
+ --radius: 10px;
22
  }
23
 
24
+ * { box-sizing: border-box; }
25
+ html, body { height: 100%; }
26
+ body{
27
+ margin:0;
28
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
29
+ color: var(--color-text);
30
+ background: var(--color-bg);
31
  }
32
 
33
+ .app-header{
34
+ position: sticky; top: 0; z-index: 10;
35
+ display: flex; align-items: center; justify-content: space-between;
36
+ padding: 12px 16px;
37
+ border-bottom: 1px solid var(--color-border);
38
+ background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-bg-soft) 100%);
39
+ backdrop-filter: blur(4px);
40
  }
41
 
42
+ .brand{ display: flex; align-items: center; gap: 10px; }
43
+ .brand .logo{
44
+ width: 32px; height: 32px; display: grid; place-items:center;
45
+ border-radius: 8px; background: var(--color-primary);
46
+ color: white; font-weight: 700;
47
+ box-shadow: var(--shadow-sm);
48
  }
49
+ h1{ font-size: 18px; margin: 0; }
50
+ .header-actions{ display: flex; align-items: center; gap: 16px; }
51
 
52
+ .btn-info{
53
+ width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--color-border);
54
+ background: var(--color-bg); color: var(--color-muted); cursor: pointer; display: grid; place-items: center;
55
+ transition: background .2s ease, color .2s ease;
56
+ }
57
+ .btn-info:hover{ background: var(--color-primary); color: white; }
58
+ .switch{ display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
59
+ .switch input{ display: none; }
60
+ .switch .slider{
61
+ width: 40px; height: 22px; border-radius: 999px; background: var(--color-border);
62
+ position: relative; transition: background .2s ease;
63
+ }
64
+ .switch .slider::after{
65
+ content:""; position: absolute; top: 3px; left: 3px;
66
+ width: 16px; height: 16px; border-radius: 50%; background: white; box-shadow: var(--shadow-sm);
67
+ transition: transform .2s ease;
68
+ }
69
+ .switch input:checked + .slider{ background: var(--color-primary); }
70
+ .switch input:checked + .slider::after{ transform: translateX(18px); }
71
+ .switch .switch-label{ color: var(--color-muted); font-size: 14px; }
72
+
73
+ .controls{
74
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
75
+ padding: 12px 16px;
76
+ }
77
+ .file-block{
78
+ border: 1px solid var(--color-border);
79
+ border-radius: var(--radius);
80
+ background: var(--color-bg);
81
+ box-shadow: var(--shadow-sm);
82
+ overflow: hidden;
83
+ }
84
+ .block-header{
85
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
86
+ padding: 10px 12px;
87
+ border-bottom: 1px solid var(--color-border);
88
+ background: var(--color-bg-soft);
89
+ }
90
+ .file-actions{ display: flex; align-items: center; gap: 8px; }
91
+ .btn{
92
+ background: var(--color-primary); color: white; border: none; border-radius: 8px;
93
+ padding: 8px 12px; cursor: pointer; box-shadow: var(--shadow-sm);
94
+ transition: transform .05s ease, background .2s ease;
95
+ }
96
+ .btn:hover{ background: var(--color-primary-600); }
97
+ .btn:active{ transform: translateY(1px); }
98
+ .btn.secondary{
99
+ background: var(--color-secondary);
100
+ }
101
+ .btn.ghost{
102
+ background: transparent; color: var(--color-text); border: 1px solid var(--color-border);
103
+ }
104
+ .file-summary{ padding: 8px 12px; color: var(--color-muted); font-size: 14px; }
105
+
106
+ .textarea-wrap{ padding: 10px; border-top: 1px dashed var(--color-border); background: var(--color-bg-soft); }
107
+ .textarea-wrap textarea{
108
+ width: 100%; min-height: 160px; resize: vertical; padding: 10px; border-radius: 8px;
109
+ border: 1px solid var(--color-border); background: white; color: var(--color-text);
110
+ }
111
+ .paste-actions{ display:flex; gap: 8px; margin-top: 8px; }
112
+
113
+ .stats{
114
+ display: flex; gap: 16px; align-items: center;
115
+ padding: 8px 16px; color: var(--color-muted);
116
+ }
117
+ .stat{ display: inline-flex; gap: 6px; align-items: center; background: var(--color-bg-soft); border: 1px solid var(--color-border); padding: 6px 10px; border-radius: 999px; }
118
+ .stat .label{ font-size: 12px; }
119
+ .stat .value{ font-weight: 700; color: var(--color-text); }
120
+
121
+ .diff-container{
122
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
123
+ padding: 12px 16px 24px;
124
+ }
125
+
126
+ .pane{
127
+ display:flex; flex-direction: column; min-height: 60vh; border: 1px solid var(--color-border);
128
+ border-radius: var(--radius); overflow: hidden; background: var(--color-bg);
129
+ box-shadow: var(--shadow-sm);
130
+ }
131
+ .pane-header{
132
+ display:flex; align-items: baseline; justify-content: space-between; gap: 10px;
133
+ padding: 10px 12px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-soft);
134
+ }
135
+ .pane-header h2{ margin: 0; font-size: 16px; }
136
+ .file-meta{ color: var(--color-muted); font-size: 12px; }
137
+
138
+ .code-wrap{ overflow: auto; height: 100%; }
139
+ table.code{ width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
140
+ tbody tr{ border-bottom: 1px solid var(--color-border); }
141
+ tbody tr:last-child{ border-bottom: none; }
142
+ td{
143
+ padding: 6px 10px; vertical-align: top; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
144
+ font-size: 13px; line-height: 1.45;
145
+ }
146
+ .gutter{ width: 64px; color: var(--color-muted); background: var(--color-bg); border-right: 1px solid var(--color-border); text-align: right; user-select: none; }
147
+ .content{ white-space: pre-wrap; word-break: break-word; }
148
+
149
+ .line.added{ background: var(--color-added-bg); }
150
+ .line.added .content{ border-left: 3px solid var(--color-added-border); padding-left: 7px; }
151
+ .line.deleted{ background: var(--color-del-bg); }
152
+ .line.deleted .content{ border-left: 3px solid var(--color-del-border); padding-left: 7px; }
153
+ .line.modified{ background: var(--color-mod-bg); }
154
+ .line.modified .content{ border-left: 3px solid var(--color-mod-border); padding-left: 7px; }
155
+ .line.moved{ background: var(--color-move-bg); }
156
+ .line.moved .content{ border-left: 3px solid var(--color-move-border); padding-left: 7px; }
157
+ .line.equal .content{ color: var(--color-text); }
158
+
159
+ .line .actions{
160
+ display: inline-flex; gap: 6px; margin-left: 8px; opacity: 0; transition: opacity .15s ease;
161
+ }
162
+ tr:hover .line .actions{ opacity: 1; }
163
+
164
+ .btn-jump{
165
+ border: none; background: transparent; color: var(--color-muted); cursor: pointer; padding: 2px 4px;
166
+ border-radius: 6px;
167
+ }
168
+ .btn-jump:hover{ color: var(--color-primary); background: rgba(14,165,233,0.08); }
169
+ .jump-indicator{ color: var(--color-primary); font-weight: 700; }
170
+
171
+ .app-footer{
172
+ padding: 12px 16px; border-top: 1px solid var(--color-border); color: var(--color-muted);
173
+ background: var(--color-bg-soft);
174
+ }
175
+ .hidden{ display: none !important; }
176
+
177
+ /* Modal styles */
178
+ .modal-overlay{
179
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 50;
180
+ display: flex; align-items: center; justify-content: center;
181
+ background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px);
182
+ animation: fadeIn .2s ease;
183
+ }
184
+
185
+ .modal{
186
+ background: var(--color-bg); border-radius: 12px; border: 1px solid var(--color-border);
187
+ width: 90%; max-width: 640px; max-height: 80vh; overflow: hidden; box-shadow: var(--shadow-md);
188
+ animation: slideUp .2s ease;
189
+ }
190
+
191
+ .modal-header{
192
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
193
+ padding: 16px 20px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-soft);
194
+ }
195
+ .modal-header h3{ margin: 0; font-size: 18px; }
196
+
197
+ .modal-close{
198
+ border: none; background: transparent; cursor: pointer; color: var(--color-muted);
199
+ width: 32px; height: 32px; border-radius: 6px; display: grid; place-items: center;
200
+ transition: background .2s ease, color .2s ease;
201
+ }
202
+ .modal-close:hover{ background: var(--color-border); color: var(--color-text); }
203
+
204
+ .modal-content{
205
+ padding: 20px; overflow-y: auto; max-height: calc(80vh - 70px);
206
+ }
207
+
208
+ .modal-section{
209
+ margin-bottom: 24px;
210
+ }
211
+ .modal-section:last-child{
212
+ margin-bottom: 0;
213
+ }
214
+ .modal-section h4{
215
+ margin: 0 0 10px 0; font-size: 16px; color: var(--color-text);
216
+ }
217
+ .modal-section p{
218
+ margin: 0 0 10px 0; line-height: 1.5; color: var(--color-muted);
219
+ }
220
+ .modal-section ul{
221
+ margin: 0; padding-left: 20px; color: var(--color-muted);
222
+ }
223
+ .modal-section li{
224
+ margin-bottom: 6px; line-height: 1.4;
225
+ }
226
+ .modal-section strong{
227
+ color: var(--color-text);
228
+ }
229
+
230
+ @media (max-width: 980px){
231
+ .controls{ grid-template-columns: 1fr; }
232
+ .diff-container{ grid-template-columns: 1fr; }
233
+ .modal{ width: 95%; max-height: 85vh; }
234
+ }
235
+
236
+ @keyframes fadeIn{
237
+ from{ opacity: 0; }
238
+ to{ opacity: 1; }
239
+ }
240
+
241
+ @keyframes slideUp{
242
+ from{ transform: translateY(20px); opacity: 0; }
243
+ to{ transform: translateY(0); opacity: 1; }
244
  }