hlsv 1.0.0 → 2.0.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -3
  3. data/README.md +36 -29
  4. data/lib/hlsv/analysis_registry.rb +47 -0
  5. data/lib/hlsv/analysis_runner.rb +193 -0
  6. data/lib/hlsv/config_manager.rb +125 -0
  7. data/lib/hlsv/html2word.rb +18 -18
  8. data/lib/hlsv/path_guard.rb +32 -0
  9. data/lib/hlsv/sdtm_validation/dataset.rb +169 -0
  10. data/lib/hlsv/sdtm_validation/define.rb +142 -0
  11. data/lib/hlsv/sdtm_validation/report.rb +357 -0
  12. data/lib/hlsv/sdtm_validation.rb +390 -0
  13. data/lib/hlsv/url_helper.rb +28 -0
  14. data/lib/hlsv/version.rb +1 -1
  15. data/lib/hlsv/web_app.rb +264 -418
  16. data/lib/hlsv.rb +12 -5
  17. data/public/css/accessibility/accessibility.css +33 -0
  18. data/public/css/base/layout.css +33 -0
  19. data/public/css/base/reset.css +23 -0
  20. data/public/css/base/typography.css +31 -0
  21. data/public/css/components/buttons.css +142 -0
  22. data/public/css/components/file-tree.css +107 -0
  23. data/public/css/components/footer.css +43 -0
  24. data/public/css/components/forms.css +56 -0
  25. data/public/css/components/header.css +52 -0
  26. data/public/css/components/status.css +56 -0
  27. data/public/css/features/csv-table.css +204 -0
  28. data/public/css/features/file-browser.css +208 -0
  29. data/public/css/responsive/responsive.css +133 -0
  30. data/public/css/styles.css +25 -0
  31. data/public/css/styles_csv.css +23 -0
  32. data/public/favicon.ico +0 -0
  33. data/public/js/analysis.js +201 -0
  34. data/public/js/app.js +63 -0
  35. data/public/js/browser.js +172 -0
  36. data/public/js/config.js +214 -0
  37. data/public/js/results.js +240 -0
  38. data/public/js/utils.js +57 -0
  39. data/views/csv_view.erb +11 -12
  40. data/views/index.erb +70 -19
  41. data/views/{report_template.erb → report.erb} +203 -188
  42. metadata +39 -41
  43. data/lib/hlsv/find_keys.rb +0 -979
  44. data/lib/hlsv/mon_script.rb +0 -169
  45. data/public/app.js +0 -569
  46. data/public/styles.css +0 -586
  47. data/public/styles_csv.css +0 -448
@@ -0,0 +1,240 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ import { escapeHtml } from './utils.js';
8
+
9
+ export async function loadResults() {
10
+ const btnLoad = document.getElementById('btn-load-results');
11
+ const btnRefresh = document.getElementById('btn-refresh-results');
12
+ const resultsList = document.getElementById('results-list');
13
+
14
+ // Hide Load button and show Refresh button
15
+ btnLoad.style.display = 'none';
16
+ btnRefresh.style.display = 'inline-block';
17
+ resultsList.style.display = 'block';
18
+
19
+ await refreshResults();
20
+ }
21
+
22
+ // Refresh results list
23
+ let isRefreshing = false;
24
+
25
+ export async function refreshResults() {
26
+ if (isRefreshing) return;
27
+ isRefreshing = true;
28
+
29
+ const resultsList = document.getElementById('results-list');
30
+ resultsList.innerHTML = '<p style="color: #666;">⏳ Loading files...</p>';
31
+
32
+ try {
33
+ const response = await fetch('/results');
34
+ const data = await response.json();
35
+
36
+ if (!data.success) {
37
+ resultsList.innerHTML = '<p style="color: #dc3545;">❌ Error loading results</p>';
38
+ return;
39
+ }
40
+
41
+ const tree = data.tree;
42
+
43
+ // Check if there are files or folders
44
+ const hasFiles = tree.files && tree.files.length > 0;
45
+ const hasFolders = tree.folders && Object.keys(tree.folders).length > 0;
46
+
47
+ if (hasFiles || hasFolders) {
48
+ let html = '<div class="file-tree">';
49
+ html += buildTree(tree, '', true);
50
+ html += '</div>';
51
+
52
+ resultsList.innerHTML = html;
53
+ } else {
54
+ resultsList.innerHTML = '<p style="color: #666;">No result files yet. Run an analysis to generate files.</p>';
55
+ }
56
+ } catch (error) {
57
+ console.error('Error:', error);
58
+ resultsList.innerHTML = '<p style="color: #dc3545;">❌ Error loading files: ' + error.message + '</p>';
59
+ } finally {
60
+ isRefreshing = false;
61
+ }
62
+ }
63
+
64
+ // Build HTML tree recursively
65
+
66
+ export function buildTree(node, parentPath, isRoot = false) {
67
+ let html = '';
68
+
69
+ // Display folders
70
+ if (node.folders) {
71
+ const folders = Object.keys(node.folders).sort();
72
+ folders.forEach(folderName => {
73
+ const dirPath = parentPath ? `${parentPath}/${folderName}` : folderName;
74
+ const subFolder = node.folders[folderName];
75
+ const id = 'folder-' + dirPath.replace(/[\/\s]/g, '-');
76
+
77
+ const fileCount = subFolder.files ? subFolder.files.length : 0;
78
+ const folderCount = subFolder.folders ? Object.keys(subFolder.folders).length : 0;
79
+
80
+ // Check if folder contains CSV files
81
+ const hasCsvFiles = subFolder.files && subFolder.files.some(f => f.extension === '.csv');
82
+
83
+ // Check if this is a first-level folder (isRoot is true and no parent path)
84
+ const isFirstLevel = isRoot && !parentPath;
85
+
86
+ // The study is always the first path segment, regardless of how deep
87
+ // this folder is (e.g. "essai/duplicates" -> "essai") - the Excel
88
+ // export is generated per-study (all datasets with duplicates in one
89
+ // workbook), not per-folder.
90
+ const study = dirPath.split('/')[0];
91
+
92
+ html += `
93
+ <div class="folder-item">
94
+ <div class="folder-header" onclick="toggleFolder('${id}')" role="button" tabindex="0" aria-expanded="false" aria-controls="${id}">
95
+ <span class="folder-icon" id="${id}-icon" aria-hidden="true">📁</span>
96
+ <strong>${escapeHtml(folderName)}</strong>
97
+ <span style="color: #666; font-size: 0.9em; margin-left: 10px;">
98
+ (${fileCount} file${fileCount !== 1 ? 's' : ''}, ${folderCount} folder${folderCount !== 1 ? 's' : ''})
99
+ </span>
100
+ <div class="folder-actions">
101
+ ${isFirstLevel ? `
102
+ <button onclick="event.stopPropagation(); viewHtmlReport('${escapeHtml(folderName)}')"
103
+ class="btn-secondary"
104
+ aria-label="View HTML report">
105
+ 👁️ View HTML Report
106
+ </button>
107
+ <button onclick="event.stopPropagation(); downloadFolderAsZip('${escapeHtml(dirPath)}')"
108
+ class="btn-primary"
109
+ aria-label="Download folder as ZIP">
110
+ 📦 Download as ZIP
111
+ </button>
112
+ ` : ''}
113
+ ${hasCsvFiles ? `
114
+ <button onclick="event.stopPropagation(); downloadExcelStudy('${escapeHtml(study)}')"
115
+ class="btn-primary"
116
+ aria-label="Download all datasets with duplicates as Excel">
117
+ 📊 Download as Excel
118
+ </button>
119
+ ` : ''}
120
+ </div>
121
+ </div>
122
+ <div class="folder-content" id="${id}" style="display: none; margin-left: 20px;" role="region">
123
+ ${buildTree(subFolder, dirPath, false)}
124
+ </div>
125
+ </div>
126
+ `;
127
+ });
128
+ }
129
+
130
+ // Display files
131
+ if (node.files && node.files.length > 0) {
132
+ node.files.forEach(file => {
133
+ const icon = getIconForExtension(file.extension);
134
+ const sizeKb = (file.size / 1024).toFixed(2);
135
+
136
+ html += `
137
+ <div class="file-item">
138
+ <div class="file-info">
139
+ <div class="file-name">${icon} ${escapeHtml(file.name)}</div>
140
+ <div class="file-meta">
141
+ ${sizeKb} KB • ${file.date}
142
+ </div>
143
+ </div>
144
+ <div>
145
+ ${file.extension === '.html' ?
146
+ `<button onclick="openFile('${escapeHtml(file.path)}')" class="btn-secondary" style="margin-right: 5px;" aria-label="Open ${escapeHtml(file.name)}">👁️ Open</button>
147
+ <button onclick="downloadHtmlAsWord('${escapeHtml(file.path)}')" class="btn-secondary" aria-label="Download as Word">📄 Word</button>` :
148
+ `<button onclick="download('${escapeHtml(file.path)}')" class="btn-primary" aria-label="Download ${escapeHtml(file.name)}">⬇️ Download</button>`}
149
+ </div>
150
+ </div>
151
+ `;
152
+ });
153
+ }
154
+
155
+ return html;
156
+ }
157
+
158
+ // Toggle folder display
159
+
160
+ export function toggleFolder(id) {
161
+ const folder = document.getElementById(id);
162
+ const icon = document.getElementById(id + '-icon');
163
+ const header = folder.previousElementSibling;
164
+
165
+ if (folder && icon) {
166
+ const isExpanded = folder.style.display !== 'none';
167
+ folder.style.display = isExpanded ? 'none' : 'block';
168
+ icon.textContent = isExpanded ? '📁' : '📂';
169
+ if (header) {
170
+ header.setAttribute('aria-expanded', !isExpanded);
171
+ }
172
+ }
173
+ }
174
+
175
+ // Get icon based on extension
176
+
177
+ export function getIconForExtension(ext) {
178
+ const icons = {
179
+ '.html': '🌐',
180
+ '.csv': '📊',
181
+ '.xlsx': '📗',
182
+ '.xls': '📗',
183
+ '.pdf': '📕',
184
+ '.txt': '📄',
185
+ '.md': '📄',
186
+ '.yaml': '⚙️',
187
+ '.yml': '⚙️'
188
+ };
189
+ return icons[ext.toLowerCase()] || '📄';
190
+ }
191
+
192
+ // Download a file (forces the browser to save it, even for types normally shown inline like .html/.pdf)
193
+
194
+ export function download(filePath) {
195
+ window.location.href = '/download/' + encodeURIComponent(filePath) + '?download=1';
196
+ }
197
+
198
+ // Open HTML file in new tab (viewed inline, not downloaded)
199
+
200
+ export function openFile(filePath) {
201
+ window.open('/download/' + encodeURIComponent(filePath), '_blank', 'noopener,noreferrer');
202
+ }
203
+
204
+ // View the HTML report for a study, generating it on demand if needed.
205
+ // `study` is the top-level folder name in hlsv_results/, which matches the
206
+ // key used by AnalysisRegistry - this works as soon as the study folder
207
+ // exists, even before the html file itself has ever been generated.
208
+
209
+ export function viewHtmlReport(study) {
210
+ window.open('/report/' + encodeURIComponent(study) + '/html', '_blank', 'noopener,noreferrer');
211
+ }
212
+
213
+ // Utility Functions
214
+
215
+ // Duration in milliseconds
216
+
217
+ export function downloadFolderAsZip(dirPath) {
218
+ window.location.href = '/download_zip_dir/' + encodeURIComponent(dirPath);
219
+ }
220
+
221
+ // Download all datasets with duplicates for a study as one Excel workbook.
222
+ // This is per-study, not per-folder - sv.report.excel aggregates every
223
+ // dataset that has duplicates into a single workbook, wherever in the
224
+ // study's tree the button was clicked.
225
+
226
+ export function downloadExcelStudy(study) {
227
+ window.location.href = '/report/' + encodeURIComponent(study) + '/excel';
228
+ }
229
+
230
+ // Download HTML report as Word document.
231
+ // `filePath` is relative to hlsv_results/, e.g. "MyStudy/myStudy_high_level_check.html" -
232
+ // its first segment is the study name, which is the key used by AnalysisRegistry.
233
+
234
+ export function downloadHtmlAsWord(filePath) {
235
+ const study = filePath.split('/')[0];
236
+ window.location.href = '/report/' + encodeURIComponent(study) + '/docx';
237
+ setTimeout(refreshResults, 1500); // let the file appear before refreshing the tree
238
+ }
239
+
240
+ // Download HTML file as PDF
@@ -0,0 +1,57 @@
1
+ /*
2
+ Copyright (c) 2026 AdClin
3
+ Licensed under the GNU Affero General Public License v3.0 or later.
4
+ See the LICENSE file for details.
5
+ */
6
+
7
+ export function formatDuration(milliseconds) {
8
+ if (milliseconds < 1000) {
9
+ return `${Math.round(milliseconds)} ms`;
10
+ }
11
+
12
+ const totalSeconds = milliseconds / 1000;
13
+
14
+ if (totalSeconds < 60) {
15
+ return `${totalSeconds.toFixed(2)} s`;
16
+ }
17
+
18
+ const minutes = Math.floor(totalSeconds / 60);
19
+ const seconds = totalSeconds % 60;
20
+
21
+ return `${minutes} min ${seconds.toFixed(2)} s`;
22
+ }
23
+
24
+ // Show message with auto-dismiss
25
+ // Broadcasts to every element sharing the given data-message-group (not a
26
+ // single id) so the same status can be shown in more than one place at
27
+ // once - e.g. config-issue now appears both next to the top quick actions
28
+ // and next to the bottom button group, since with two button rows the
29
+ // user might be looking at either one.
30
+ export function showMessage(groupName, type, message, duration = 3000) {
31
+ const targets = document.querySelectorAll(`[data-message-group="${groupName}"]`);
32
+ targets.forEach(msgDiv => {
33
+ msgDiv.innerHTML = `<div class="status ${type}">${message}</div>`;
34
+ });
35
+
36
+ if (duration > 0) {
37
+ setTimeout(() => targets.forEach(msgDiv => msgDiv.innerHTML = ''), duration);
38
+ }
39
+ }
40
+
41
+ // Escape HTML to prevent XSS
42
+
43
+ export function escapeHtml(text) {
44
+ const div = document.createElement('div');
45
+ div.textContent = text;
46
+ return div.innerHTML;
47
+ }
48
+
49
+ // Keyboard accessibility for folder toggles
50
+ document.addEventListener('keydown', (e) => {
51
+ if (e.key === 'Enter' || e.key === ' ') {
52
+ if (e.target.classList.contains('folder-header')) {
53
+ e.preventDefault();
54
+ e.target.click();
55
+ }
56
+ }
57
+ });
data/views/csv_view.erb CHANGED
@@ -13,12 +13,12 @@
13
13
  <meta charset="utf-8">
14
14
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
15
  <title><%= @ds_name %>: Duplicate Analysis</title>
16
- <link rel="stylesheet" href="/styles_csv.css">
16
+ <link rel="stylesheet" href="/css/styles_csv.css">
17
17
  </head>
18
18
  <body>
19
19
 
20
20
  <!-- Header with title and logo -->
21
- <header class="csv-header">
21
+ <header class="app-header">
22
22
  <h1>Duplicates in <%= @ds_name %> for <%= @type %></h1>
23
23
  <div class="company-branding">
24
24
  <a href="https://adclin.com" target="_blank" rel="noopener noreferrer" class="company-link">
@@ -27,15 +27,7 @@
27
27
  </div>
28
28
  </header>
29
29
 
30
- <nav class="actions" role="navigation" aria-label="File actions">
31
- <a class="btn-export"
32
- href="/excel_export?file=<%= URI.encode_www_form_component(params[:file]) %>&last_valid_key=<%= URI.encode_www_form_component(@last_valid_key.join(',')) %>"
33
- aria-label="Export to Excel">
34
- ⬇️ Export to Excel
35
- </a>
36
- </nav>
37
-
38
- <section class="info-section" role="region" aria-label="Information">
30
+ <section class="info" role="region" aria-label="Information">
39
31
  <p>This table displays the detected duplicates, grouped according to the last key tested.
40
32
  The duplicate groups are represented by a number in the "No" column.
41
33
  Alternating colors are used to distinguish them visually.</p>
@@ -44,6 +36,13 @@
44
36
  <% unless @last_valid_key.empty? %>
45
37
  <p>Last key tested: <strong><%= @last_valid_key.join(', ') %></strong></p>
46
38
  <% end %>
39
+ <% if @study %>
40
+ <a class="btn-quick-action btn-quick-primary"
41
+ href="<%= export_csv_excel_url(file: @file, study: @study, dataset: @ds_name, last_valid_key: @last_valid_key) %>"
42
+ aria-label="Export as Excel">
43
+ 📊 Export as Excel
44
+ </a>
45
+ <% end %>
47
46
  </section>
48
47
 
49
48
  <div class="table-container" role="region" aria-label="Duplicates table" tabindex="0">
@@ -72,7 +71,7 @@
72
71
  </div>
73
72
 
74
73
  <!-- Footer with copyright and license on same line -->
75
- <footer class="csv-footer">
74
+ <footer class="app-footer">
76
75
  <div class="footer-content">
77
76
  <span class="copyright">© 1999-2026 AdClin. All rights reserved.</span>
78
77
  <span class="separator">•</span>
data/views/index.erb CHANGED
@@ -13,7 +13,8 @@
13
13
  <title>High Level Check on SDTM Packages</title>
14
14
  <meta charset="UTF-8">
15
15
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
- <link rel="stylesheet" href="/styles.css">
16
+ <link rel="icon" type="image/x-icon" href="/favicon.ico">
17
+ <link rel="stylesheet" href="/css/styles.css">
17
18
  </head>
18
19
  <body>
19
20
  <!-- Header with title and logo -->
@@ -32,7 +33,29 @@
32
33
 
33
34
  <!-- Configuration -->
34
35
  <div class="container">
35
- <h2>⚙️ Configuration</h2>
36
+ <div class="config-header-row">
37
+ <h2>⚙️ Configuration</h2>
38
+ <div class="quick-actions">
39
+ <button type="button" class="btn-quick-action btn-quick-primary" onclick="configSave()" aria-label="Save current configuration">
40
+ 💾 Save
41
+ </button>
42
+ <button type="button" class="btn-quick-action" onclick="fullConfigViewer()" aria-label="View current configuration">
43
+ 📄 View Current
44
+ </button>
45
+ <button type="button" class="btn-quick-action btn-quick-danger" onclick="clearFields()" aria-label="Clear all fields">
46
+ 🗑️ Clear
47
+ </button>
48
+ <button type="button" class="btn-quick-action" onclick="loadDefaultConfig()" aria-label="Load default configuration">
49
+ 📋 Load Default
50
+ </button>
51
+ <button type="button" class="btn-quick-action" onclick="loadExistingConfig()" aria-label="Load an existing configuration file">
52
+ 📂 Load Existing
53
+ </button>
54
+ </div>
55
+ </div>
56
+
57
+ <div data-message-group="config-issue" role="status" aria-live="polite"></div>
58
+
36
59
  <h3>General Information</h3>
37
60
 
38
61
  <div class="config-grid">
@@ -50,13 +73,19 @@
50
73
  </div>
51
74
 
52
75
  <div class="form-group">
53
- <label for="data_directory">Datasets Directory <span class="required">*</span></label>
76
+ <div class="form-label-row">
77
+ <label for="data_directory">Datasets Directory <span class="required">*</span></label>
78
+ <button type="button" class="btn-browse-link" onclick="openBrowser('data_directory', 'directory')" aria-label="Browse for datasets directory">📁 Browse</button>
79
+ </div>
54
80
  <input type="text" id="data_directory" value="<%= @config['data_directory'] %>" required aria-required="true">
55
81
  <small>Path to the directory containing the <strong>xpt</strong> datasets to analyze</small>
56
82
  </div>
57
83
 
58
84
  <div class="form-group">
59
- <label for="define_path">Path to define.xml <span class="required">*</span></label>
85
+ <div class="form-label-row">
86
+ <label for="define_path">Path to define.xml <span class="required">*</span></label>
87
+ <button type="button" class="btn-browse-link" onclick="openBrowser('define_path', 'file', '.xml')" aria-label="Browse for define.xml file">📁 Browse</button>
88
+ </div>
60
89
  <input type="text" id="define_path" value="<%= @config['define_path'] %>" required aria-required="true">
61
90
  <small>Enter "-" to skip the define.xml validation part of the analysis</small>
62
91
  </div>
@@ -164,24 +193,27 @@
164
193
  <input type="text" id="TV_key" value="<%= @config['TV_key'] %>" required aria-required="true">
165
194
  </div>
166
195
  </div>
167
-
196
+
168
197
  <!-- Configuration Management Buttons -->
169
- <div class="button-group">
170
- <button onclick="sauvegarderConfig()" class="btn-primary" aria-label="Save current configuration">
171
- 💾 Save Current Configuration
198
+ <div class="button-group button-group-compact">
199
+ <button onclick="configSave()" class="btn-primary" aria-label="Save current configuration">
200
+ 💾 Save Configuration
172
201
  </button>
173
- <button class="btn-secondary" onclick="afficherConfigComplete()" aria-label="View current configuration">
174
- 📄 View Current Configuration
202
+ <button class="btn-secondary" onclick="fullConfigViewer()" aria-label="View current configuration">
203
+ 📄 View Configuration
175
204
  </button>
176
- <button class="btn-danger" onclick="effacerChamps()" aria-label="Clear all fields">
205
+ <button class="btn-danger" onclick="clearFields()" aria-label="Clear all fields">
177
206
  🗑️ Clear All Fields
178
207
  </button>
179
- <button class="btn-secondary" onclick="chargerDefaut()" aria-label="Load default configuration">
208
+ <button class="btn-secondary" onclick="loadDefaultConfig()" aria-label="Load default configuration">
180
209
  📋 Load Default Configuration
181
210
  </button>
211
+ <button class="btn-secondary" onclick="loadExistingConfig()" aria-label="Load an existing configuration file">
212
+ 📂 Load Existing Configuration
213
+ </button>
182
214
  </div>
183
215
 
184
- <div id="message-config" role="status" aria-live="polite"></div>
216
+ <div data-message-group="config-issue" role="status" aria-live="polite"></div>
185
217
  </div>
186
218
 
187
219
  <!-- Processing -->
@@ -195,11 +227,11 @@
195
227
  <li>Ad hoc search for a natural key for all datasets</li>
196
228
  </ul>
197
229
 
198
- <button class="btn-success" onclick="lancerTraitement()" id="btn-traiter" aria-label="Start analysis">
230
+ <button class="btn-success" onclick="startAnalysis()" id="btn-process" aria-label="Start analysis">
199
231
  🚀 Start Analysis
200
232
  </button>
201
233
 
202
- <div id="status-traitement" role="status" aria-live="polite"></div>
234
+ <div id="analysis-status" role="status" aria-live="polite"></div>
203
235
  </div>
204
236
 
205
237
  <!-- Results -->
@@ -207,16 +239,16 @@
207
239
  <h2>📁 Result Files</h2>
208
240
 
209
241
  <div class="button-group">
210
- <button class="btn-secondary" onclick="chargerResultats()" id="btn-load-results" aria-label="Load results">
242
+ <button class="btn-secondary" onclick="loadResults()" id="btn-load-results" aria-label="Load results">
211
243
  📂 Load Results
212
244
  </button>
213
- <button class="btn-secondary" onclick="rafraichirResultats()" id="btn-refresh-results" style="display: none;" aria-label="Refresh results">
245
+ <button class="btn-secondary" onclick="refreshResults()" id="btn-refresh-results" style="display: none;" aria-label="Refresh results">
214
246
  🔄 Refresh
215
247
  </button>
216
248
  <p>Browse files generated in the <code>hlsv_results/</code> directory</p>
217
249
  </div>
218
250
 
219
- <div id="liste-resultats" style="display: none;" role="region" aria-label="Results list"></div>
251
+ <div id="results-list" style="display: none;" role="region" aria-label="Results list"></div>
220
252
  </div>
221
253
 
222
254
  <!-- Footer with copyright and license on same line -->
@@ -228,6 +260,25 @@
228
260
  </div>
229
261
  </footer>
230
262
 
231
- <script src="/app.js"></script>
263
+ <!-- File/Directory Browser Modal -->
264
+ <div id="browse-modal" class="modal-overlay" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="browse-modal-title">
265
+ <div class="modal-box">
266
+ <div class="modal-header">
267
+ <h3 id="browse-modal-title">📁 Select a directory</h3>
268
+ <button type="button" class="modal-close" onclick="closeBrowser()" aria-label="Close">✖</button>
269
+ </div>
270
+ <div class="modal-path-bar">
271
+ <button type="button" id="browse-up" onclick="browseUp()" aria-label="Go to parent directory">⬆️</button>
272
+ <input type="text" id="browse-current-path" readonly aria-label="Current directory">
273
+ </div>
274
+ <div id="browse-list" class="modal-list" role="listbox" aria-label="Directory contents"></div>
275
+ <div class="modal-footer">
276
+ <button type="button" class="btn-secondary" onclick="closeBrowser()">Cancel</button>
277
+ <button type="button" class="btn-primary" id="browse-select-btn" onclick="confirmBrowseSelection()">Select this folder</button>
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <script type="module" src="/js/app.js"></script>
232
283
  </body>
233
284
  </html>