figure-viewer 1.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.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Open figure viewer for the current project
3
+ agent: build
4
+ ---
5
+
6
+ Run the figure viewer to check generated figures:
7
+
8
+ !`figure-viewer --watch`
9
+
10
+ This will:
11
+ - Auto-discover the figures directory (outputs/figures/, figures/, or plots/)
12
+ - Open a browser split below in cmux
13
+ - Watch for changes in the background
14
+
15
+ To stop the watcher when done:
16
+
17
+ !`figure-viewer --kill`
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Figure Viewer
2
+
3
+ CLI tool for viewing and navigating scientific figures in a browser pane.
4
+
5
+ **Note:** This project is not affiliated with or endorsed by OpenCode or cmux. It simply integrates with their tools.
6
+
7
+ ## Features
8
+
9
+ - Auto-discovers figures directories (`outputs/figures`, `figures`, `plots`, etc.)
10
+ - Interactive HTML viewer with grid layout
11
+ - Freshness indicators (Active/Recent/Older)
12
+ - Lightbox for full-size image viewing
13
+ - File watching with auto-refresh
14
+ - cmux browser integration (opens in split below)
15
+ - Configurable via `.figure-viewer.json`
16
+ - Multiple independent sessions support
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install -g figure-viewer
22
+ ```
23
+
24
+ Or link locally:
25
+ ```bash
26
+ cd figure-viewer
27
+ npm link
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ # Basic - auto-discover figures directory
34
+ figure-viewer --watch
35
+
36
+ # With options
37
+ figure-viewer --watch --refresh 60 --timeout 120
38
+
39
+ # Kill watcher when done
40
+ figure-viewer --kill
41
+ ```
42
+
43
+ ### Options
44
+
45
+ | Option | Description | Default |
46
+ |--------|-------------|---------|
47
+ | `-p, --path <path>` | Explicit path to figures directory | auto-discover |
48
+ | `-o, --output <path>` | Output HTML file path | `figure-viewer.html` |
49
+ | `-w, --watch` | Watch for file changes | false |
50
+ | `--no-open` | Don't open browser | false |
51
+ | `-r, --refresh <seconds>` | Auto-refresh interval | 30 |
52
+ | `-t, --timeout <minutes>` | Kill after inactivity | 60 |
53
+ | `--kill` | Kill background watcher | - |
54
+ | `--clear-history` | Clear figure history | - |
55
+
56
+ ## Configuration
57
+
58
+ Create a `.figure-viewer.json` in your project or home directory:
59
+
60
+ ```json
61
+ {
62
+ "figuresDirs": [
63
+ "outputs/figures",
64
+ "figures",
65
+ "plots",
66
+ "output/figures",
67
+ "output/plots"
68
+ ],
69
+ "refreshInterval": 30000,
70
+ "watcherTimeout": 3600000,
71
+ "openOnStartup": true
72
+ }
73
+ ```
74
+
75
+ ## OpenCode Integration
76
+
77
+ Add `/figures` command to OpenCode:
78
+
79
+ ```bash
80
+ # Link the command
81
+ ln -s /path/to/figure-viewer/.opencode/command/figures.md ~/.config/opencode/command/figures.md
82
+ ```
83
+
84
+ Then use in OpenCode:
85
+ ```
86
+ /figures
87
+ ```
88
+
89
+ ## How It Works
90
+
91
+ 1. Auto-discovers figures directory by walking up from current directory
92
+ 2. Generates interactive HTML with figure grid
93
+ 3. Opens in cmux browser split (or falls back to system browser)
94
+ 4. Watches for file changes in background
95
+ 5. Auto-kills after inactivity timeout (configurable)
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,293 @@
1
+ # Figure Viewer — Specification
2
+
3
+ ## Overview
4
+
5
+ A CLI tool for viewing and navigating scientific figures generated during agentic data analysis workflows. Designed for macOS + cmux + OpenCode. Renders figures in a browser pane within cmux for perfect image quality.
6
+
7
+ ## Problem
8
+
9
+ When AI coding agents (OpenCode) generate figures during data analysis:
10
+ - Opening figures requires context-switching to Finder/Preview
11
+ - No good TUI-native solution for image preview on macOS
12
+ - Hard to track which figures are new vs. iterations
13
+ - Want to view figures inside cmux without leaving the terminal
14
+
15
+ ## Solution
16
+
17
+ - CLI tool: `figure-viewer`
18
+ - Generates interactive HTML from figures directory
19
+ - HTML opened in cmux browser split
20
+ - Auto-discovery of figures folder from working directory
21
+ - Freshness tracking for iteration awareness
22
+
23
+ ---
24
+
25
+ ## CLI Interface
26
+
27
+ ### Commands
28
+
29
+ ```bash
30
+ # Basic usage — auto-discover figures from cwd
31
+ figure-viewer
32
+
33
+ # Explicit path
34
+ figure-viewer ./outputs/figures
35
+ figure-viewer /path/to/project/analysis/outputs/figures
36
+
37
+ # Watch mode (default on)
38
+ figure-viewer --watch
39
+ figure-viewer --watch ./figures
40
+
41
+ # Disable auto-open in browser
42
+ figure-viewer --no-open
43
+
44
+ # Specify output HTML path
45
+ figure-viewer --output viewer.html
46
+
47
+ # Help
48
+ figure-viewer --help
49
+ ```
50
+
51
+ ### Auto-Discovery Logic
52
+
53
+ 1. If path provided, use it
54
+ 2. Else, start at `pwd`
55
+ 3. Walk up directories looking for:
56
+ - `outputs/figures/`
57
+ - `figures/`
58
+ - `plots/`
59
+ - `output/figures/`
60
+ 4. If not found, error with suggestion to pass explicit path
61
+
62
+ ---
63
+
64
+ ## HTML Viewer
65
+
66
+ ### Layout
67
+
68
+ ```
69
+ ┌─────────────────────────────────────────────────────┐
70
+ │ 🔄 Figure Viewer [Refresh] │
71
+ ├─────────────────────────────────────────────────────┤
72
+ │ Filter: [________________] Sort: [Newest ▼] │
73
+ ├─────────────────────────────────────────────────────┤
74
+ │ │
75
+ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
76
+ │ │ img │ │ img │ │ img │ │ img │ │
77
+ │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │
78
+ │ │ 🔴 │ │ ⚪ │ │ ⚪ │ │ 🟡 │ │
79
+ │ └──────┘ └──────┘ └──────┘ └──────┘ │
80
+ │ │
81
+ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
82
+ │ │ img │ │ img │ │ img │ │ img │ │
83
+ │ │ 5 │ │ 6 │ │ 7 │ │ 8 │ │
84
+ │ │ ⚪ │ │ ⚪ │ │ 🔴 │ │ ⚪ │ │
85
+ │ └──────┘ └──────┘ └──────┘ └──────┘ │
86
+ │ │
87
+ └─────────────────────────────────────────────────────┘
88
+ ```
89
+
90
+ ### Grid
91
+
92
+ - Responsive grid: auto-fit, min 200px per image
93
+ - Each card shows:
94
+ - Thumbnail (aspect-ratio preserved)
95
+ - Filename (truncated if long)
96
+ - Timestamp (relative: "2 min ago", "1 hour ago")
97
+ - Freshness indicator (color border)
98
+
99
+ ### Freshness Indicators
100
+
101
+ | Status | Color | Time Window |
102
+ |--------|-------|-------------|
103
+ | 🔴 Active | Red border | < 5 minutes |
104
+ | 🟡 Recent | Yellow border | 5 min – 1 hour |
105
+ | ⚪ Older | Gray border | > 1 hour |
106
+
107
+ ### Lightbox
108
+
109
+ On click:
110
+ - Full-size image in modal overlay
111
+ - Dark background (rgba(0,0,0,0.9))
112
+ - Close: ESC key or click outside
113
+ - Navigation: ← → arrows or keyboard
114
+ - Shows metadata: filename, dimensions, file size, full path
115
+
116
+ ### Auto-Refresh
117
+
118
+ - Meta refresh every 5 seconds: `<meta http-equiv="refresh" content="5">`
119
+ - Or JavaScript polling (less intrusive)
120
+ - Manual refresh button always available
121
+
122
+ ---
123
+
124
+ ## Freshness Tracking
125
+
126
+ ### Storage
127
+
128
+ `~/.figure-viewer/history.json`:
129
+
130
+ ```json
131
+ {
132
+ "/path/to/project/outputs/figures": {
133
+ "figure_1.png": {
134
+ "mtime": 1709324400000,
135
+ "version": 1
136
+ },
137
+ "figure_1.png": {
138
+ "mtime": 1709324500000,
139
+ "version": 2
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ ### Version Detection
146
+
147
+ If same filename appears with newer mtime:
148
+ - Increment version counter
149
+ - Track version history
150
+ - Display "v1", "v2", etc. on card
151
+
152
+ ---
153
+
154
+ ## cmux Integration
155
+
156
+ ### Opening the Viewer
157
+
158
+ ```bash
159
+ # Generate HTML
160
+ figure-viewer --output /tmp/figure-viewer.html
161
+
162
+ # Open in cmux browser (verify exact command)
163
+ cmux browser open file:///tmp/figure-viewer.html
164
+ ```
165
+
166
+ ### Watch + Open
167
+
168
+ ```bash
169
+ # Watch and auto-open on first run
170
+ figure-viewer --watch --open
171
+ ```
172
+
173
+ On watch detect new/modified figure:
174
+ 1. Regenerate HTML
175
+ 2. Refresh browser (cmux command TBD or manual)
176
+
177
+ ### Fallback
178
+
179
+ If cmux not available or browser split fails:
180
+ - Open in default browser: `open file:///path/to/viewer.html`
181
+ - Print instruction to user
182
+
183
+ ---
184
+
185
+ ## OpenCode Integration
186
+
187
+ ### Custom Command
188
+
189
+ Create `.opencode/commands/figures.md`:
190
+
191
+ ```yaml
192
+ ---
193
+ description: Open figure viewer
194
+ agent: build
195
+ ---
196
+
197
+ Run the figure viewer to check generated figures:
198
+
199
+ !`figure-viewer --open`
200
+ ```
201
+
202
+ ### Usage
203
+
204
+ In OpenCode:
205
+ ```
206
+ /figures
207
+ ```
208
+
209
+ Or in conversation:
210
+ ```
211
+ "Can you run /figures to check the plots we generated?"
212
+ ```
213
+
214
+ ---
215
+
216
+ ## File Structure
217
+
218
+ ```
219
+ figure-viewer/
220
+ ├── figure-viewer.js # Main CLI entry point
221
+ ├── lib/
222
+ │ ├── discover.js # Find figures directory
223
+ │ ├── html.js # Generate HTML
224
+ │ ├── history.js # Track freshness/versions
225
+ │ └── watcher.js # File system watch (fswatch)
226
+ ├── templates/
227
+ │ └── viewer.html # HTML template (inline in js or separate)
228
+ ├── package.json
229
+ └── README.md
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Dependencies
235
+
236
+ - Node.js (or could be Go/Bash)
237
+ - `chokidar` or `fswatch` for file watching
238
+ - Optional: `open` CLI (macOS) for fallback browser open
239
+
240
+ ---
241
+
242
+ ## Edge Cases
243
+
244
+ 1. **No figures directory found**
245
+ - Error message: "No figures directory found. Pass explicit path or create one of: outputs/figures/, figures/, plots/"
246
+
247
+ 2. **Empty figures directory**
248
+ - Show message: "No figures yet. Run your analysis to generate figures."
249
+
250
+ 3. **Very large images**
251
+ - Generate thumbnails for grid? Or lazy load
252
+ - Full size in lightbox
253
+
254
+ 4. **Non-image files in folder**
255
+ - Filter to: .png, .jpg, .jpeg, .pdf, .svg
256
+ - Ignore everything else
257
+
258
+ 5. **Path with spaces/special chars**
259
+ - Proper escaping in HTML file:// URLs
260
+
261
+ 6. **Multiple projects**
262
+ - History stored per directory path
263
+ - Can clear history: `figure-viewer --clear-history`
264
+
265
+ 7. **Watch mode permissions**
266
+ - Handle ENOENT if directory deleted while watching
267
+
268
+ ---
269
+
270
+ ## Future Enhancements
271
+
272
+ - [ ] Version comparison: side-by-side v1 vs v2
273
+ - [ ] Annotations: add notes to figures
274
+ - [ ] Export: download all figures as zip
275
+ - [ ] Zoom: pinch-zoom in lightbox
276
+ - [ ] Slideshow: auto-advance mode
277
+ - [ ] AI captioning: generate descriptions with vision model
278
+ - [ ] Share: copy image URL or embed
279
+
280
+ ---
281
+
282
+ ## Acceptance Criteria
283
+
284
+ 1. ✅ `figure-viewer` runs from CLI with no arguments and auto-discovers figures
285
+ 2. ✅ Generated HTML displays grid of images with thumbnails
286
+ 3. ✅ Freshness indicators show correct colors based on mtime
287
+ 4. ✅ Click on image opens lightbox with full-size view
288
+ 5. ✅ Lightbox supports keyboard navigation (ESC to close, arrows to navigate)
289
+ 6. ✅ Watch mode detects new files and regenerates HTML
290
+ 7. ✅ Opens in cmux browser split (or fallback to system browser)
291
+ 8. ✅ Custom command `/figures` works in OpenCode
292
+ 9. ✅ Works with nested directory structures (project/analysis/outputs/figures)
293
+ 10. ✅ Handles empty directory gracefully with helpful message
@@ -0,0 +1,12 @@
1
+ {
2
+ "figuresDirs": [
3
+ "outputs/figures",
4
+ "figures",
5
+ "plots",
6
+ "output/figures",
7
+ "output/plots"
8
+ ],
9
+ "refreshInterval": 30000,
10
+ "watcherTimeout": 3600000,
11
+ "openOnStartup": true
12
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const { discoverFiguresDirectory, getErrorMessage } = require('./lib/discover');
8
+ const { generateHtml, getImages } = require('./lib/html');
9
+ const { updateHistory, getHistoryForDir, clearHistory: clearHistoryFn } = require('./lib/history');
10
+ const { createWatcher, closeWatcher } = require('./lib/watcher');
11
+ const { getConfig } = require('./lib/config');
12
+
13
+ const program = new Command();
14
+
15
+ const PID_FILE = '.figure-viewer.pid';
16
+
17
+ program
18
+ .name('figure-viewer')
19
+ .description('CLI tool for viewing and navigating scientific figures in a browser pane')
20
+ .version('1.0.0')
21
+ .option('-p, --path <path>', 'Explicit path to figures directory')
22
+ .option('-o, --output <path>', 'Output HTML file path', 'figure-viewer.html')
23
+ .option('-w, --watch', 'Watch for file changes and auto-refresh', false)
24
+ .option('--no-open', 'Do not open browser automatically')
25
+ .option('-r, --refresh <seconds>', 'Auto-refresh interval in seconds (default: 30)', parseInt)
26
+ .option('-t, --timeout <minutes>', 'Kill watcher after N minutes of inactivity (default: 60)', parseInt)
27
+ .option('--clear-history', 'Clear figure history')
28
+ .option('--kill', 'Kill the background watcher process');
29
+
30
+ program.parse(process.argv);
31
+
32
+ const options = program.opts();
33
+
34
+ function generateAndSave(figuresDir, outputPath) {
35
+ const config = getConfig();
36
+ // CLI options override config
37
+ if (options.refresh) {
38
+ config.refreshInterval = options.refresh * 1000;
39
+ }
40
+ if (options.timeout) {
41
+ config.watcherTimeout = options.timeout * 60 * 1000;
42
+ }
43
+ const images = getImages(figuresDir);
44
+ const history = updateHistory(figuresDir, images);
45
+ const html = generateHtml(figuresDir, { history, config });
46
+
47
+ fs.writeFileSync(outputPath, html);
48
+ console.log(`Generated: ${outputPath} (${images.length} figures)`);
49
+
50
+ return outputPath;
51
+ }
52
+
53
+ function openInBrowser(filePath) {
54
+ // Try cmux first, fallback to system browser
55
+ const fileUrl = 'file://' + filePath;
56
+
57
+ // Check if cmux socket exists (cmux sets CMUX_SOCKET_PATH or we can check default)
58
+ const cmuxSocket = process.env.CMUX_SOCKET_PATH || '/tmp/cmux.sock';
59
+ const hasCmux = fs.existsSync(cmuxSocket) || process.env.CMUX_WORKSPACE_ID;
60
+
61
+ if (hasCmux) {
62
+ console.log('Detected cmux session - opening in cmux browser...');
63
+ try {
64
+ const { execSync } = require('child_process');
65
+ // Try cmux browser open command
66
+ // Use new-pane with direction for cmux
67
+ execSync(`cmux new-pane --type browser --direction down --url "${fileUrl}"`, { stdio: 'inherit' });
68
+ console.log('Opened in cmux browser');
69
+ return;
70
+ } catch (e) {
71
+ console.log('cmux command failed, falling back to system browser:', e.message);
72
+ }
73
+ }
74
+
75
+ // Fallback: use system open command
76
+ const { exec } = require('child_process');
77
+ exec(`open "${fileUrl}"`, (err) => {
78
+ if (err) {
79
+ console.error('Failed to open browser:', err.message);
80
+ } else {
81
+ console.log('Opened in default browser');
82
+ }
83
+ });
84
+ }
85
+
86
+ async function main() {
87
+ // Handle kill option
88
+ if (options.kill) {
89
+ const pidPath = path.join(process.cwd(), PID_FILE);
90
+ if (fs.existsSync(pidPath)) {
91
+ try {
92
+ const data = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
93
+ const pid = data.pid;
94
+ process.kill(pid, 'SIGTERM');
95
+ console.log(`Killed watcher process (PID: ${pid})`);
96
+ } catch (e) {
97
+ console.log('Watcher process already stopped or invalid PID file.');
98
+ }
99
+ // Clean up PID file
100
+ try { fs.unlinkSync(pidPath); } catch {}
101
+ } else {
102
+ console.log('No watcher process found in current directory.');
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Handle clear history
108
+ if (options.clearHistory) {
109
+ clearHistoryFn();
110
+ console.log('History cleared.');
111
+ return;
112
+ }
113
+
114
+ // Discover figures directory
115
+ let figuresDir;
116
+ try {
117
+ figuresDir = discoverFiguresDirectory(options.path);
118
+ } catch (err) {
119
+ console.error(err.message);
120
+ process.exit(1);
121
+ }
122
+
123
+ if (!figuresDir) {
124
+ console.error(getErrorMessage(FIGURE_DIRS));
125
+ process.exit(1);
126
+ }
127
+
128
+ console.log(`Figures directory: ${figuresDir}`);
129
+
130
+ // Generate HTML
131
+ const outputPath = path.resolve(options.output);
132
+ generateAndSave(figuresDir, outputPath);
133
+
134
+ // Check if watcher already running
135
+ const pidPath = path.join(process.cwd(), PID_FILE);
136
+ const watcherAlreadyRunning = fs.existsSync(pidPath);
137
+
138
+ // Watch mode - run as background process
139
+ if (options.watch) {
140
+ if (watcherAlreadyRunning) {
141
+ console.log('Watcher already running in this directory.');
142
+ } else {
143
+ const { spawn } = require('child_process');
144
+
145
+ // Get config for refresh and timeout
146
+ const config = getConfig();
147
+ const refreshMs = options.refresh ? options.refresh * 1000 : config.refreshInterval;
148
+ const timeoutMs = options.timeout ? options.timeout * 60 * 1000 : config.watcherTimeout;
149
+
150
+ // Pass the output directory as first arg (projectRoot)
151
+ const child = spawn(
152
+ process.execPath,
153
+ [
154
+ path.join(__dirname, 'lib/watcher-daemon.js'),
155
+ path.dirname(outputPath),
156
+ figuresDir,
157
+ outputPath,
158
+ refreshMs.toString(),
159
+ timeoutMs.toString()
160
+ ],
161
+ {
162
+ cwd: __dirname,
163
+ detached: true,
164
+ stdio: 'ignore'
165
+ }
166
+ );
167
+
168
+ child.unref();
169
+
170
+ console.log('Watching for changes in background...');
171
+ console.log(`Auto-refresh every ${refreshMs/1000}s, timeout after ${Math.round(timeoutMs/60000)}min`);
172
+ }
173
+ }
174
+
175
+ // Open in browser (only if not already watching and not explicitly disabled)
176
+ if (options.open !== false && !watcherAlreadyRunning) {
177
+ openInBrowser(outputPath);
178
+ } else if (watcherAlreadyRunning) {
179
+ console.log('Browser already open. Use --no-open to skip browser entirely.');
180
+ } else if (!options.watch) {
181
+ // Only show URL for non-watch mode
182
+ console.log(`\nOpen this URL in your browser:\nfile://${outputPath}`);
183
+ }
184
+ }
185
+
186
+ main().catch(console.error);
package/lib/config.js ADDED
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DEFAULT_CONFIG = {
5
+ figuresDirs: [
6
+ 'outputs/figures',
7
+ 'figures',
8
+ 'plots',
9
+ 'output/figures',
10
+ 'output/plots'
11
+ ],
12
+ refreshInterval: 30000,
13
+ watcherTimeout: 3600000,
14
+ openOnStartup: true
15
+ };
16
+
17
+ function getConfig(dir = process.cwd()) {
18
+ // Check for config in current directory first
19
+ const localConfig = path.join(dir, '.figure-viewer.json');
20
+ if (fs.existsSync(localConfig)) {
21
+ try {
22
+ return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(localConfig, 'utf8')) };
23
+ } catch (e) {
24
+ console.warn('Invalid config file, using defaults');
25
+ }
26
+ }
27
+
28
+ // Check in home directory
29
+ const homeConfig = path.join(process.env.HOME || process.env.USERPROFILE, '.figure-viewer.json');
30
+ if (fs.existsSync(homeConfig)) {
31
+ try {
32
+ return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(homeConfig, 'utf8')) };
33
+ } catch (e) {
34
+ // ignore
35
+ }
36
+ }
37
+
38
+ return DEFAULT_CONFIG;
39
+ }
40
+
41
+ module.exports = {
42
+ getConfig,
43
+ DEFAULT_CONFIG
44
+ };
@@ -0,0 +1,54 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { getConfig, DEFAULT_CONFIG } = require('./config');
4
+
5
+ function discoverFiguresDirectory(startPath, config = null) {
6
+ // If explicit path provided, validate it
7
+ if (startPath) {
8
+ const resolvedPath = path.resolve(startPath);
9
+ if (!fs.existsSync(resolvedPath)) {
10
+ throw new Error(`Directory not found: ${resolvedPath}`);
11
+ }
12
+ const stats = fs.statSync(resolvedPath);
13
+ if (!stats.isDirectory()) {
14
+ throw new Error(`Not a directory: ${resolvedPath}`);
15
+ }
16
+ return resolvedPath;
17
+ }
18
+
19
+ const cfg = config || getConfig();
20
+ const figureDirs = cfg.figuresDirs || DEFAULT_CONFIG.figuresDirs;
21
+
22
+ // Start at current working directory
23
+ let currentDir = process.cwd();
24
+ const root = path.parse(currentDir).root;
25
+
26
+ while (currentDir !== root) {
27
+ for (const figureDir of figureDirs) {
28
+ const candidatePath = path.join(currentDir, figureDir);
29
+ if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
30
+ return candidatePath;
31
+ }
32
+ }
33
+ currentDir = path.dirname(currentDir);
34
+ }
35
+
36
+ // Check root level as well
37
+ for (const figureDir of figureDirs) {
38
+ const candidatePath = path.join(root, figureDir);
39
+ if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isDirectory()) {
40
+ return candidatePath;
41
+ }
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ function getErrorMessage(suggestedDirs) {
48
+ return `No figures directory found. Pass explicit path or create one of: ${suggestedDirs.join(', ')}`;
49
+ }
50
+
51
+ module.exports = {
52
+ discoverFiguresDirectory,
53
+ getErrorMessage
54
+ };