getobsrv 0.3.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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/bin/obsrv-mcp.js +19 -0
  4. package/bin/obsrv.js +73 -0
  5. package/out/cli/args.js +204 -0
  6. package/out/main/cli.js +600 -0
  7. package/out/main/index.js +539 -0
  8. package/out/main/targetSource-w_vWw7zd.js +351 -0
  9. package/out/mcp/lib.js +175 -0
  10. package/out/mcp/server.js +301 -0
  11. package/out/preload/app.js +87 -0
  12. package/out/preload/sync.js +41 -0
  13. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  14. package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  15. package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
  16. package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
  17. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  18. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  19. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
  20. package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
  21. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  22. package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  23. package/out/renderer/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  24. package/out/renderer/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  25. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  26. package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  27. package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
  28. package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
  29. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  30. package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  31. package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
  32. package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
  33. package/out/renderer/assets/index-BAEO9_6W.js +14460 -0
  34. package/out/renderer/assets/index-VleEBVgL.css +446 -0
  35. package/out/renderer/index.html +13 -0
  36. package/out/shared/calibration.js +41 -0
  37. package/out/shared/presets.js +47 -0
  38. package/out/shared/types.js +2 -0
  39. package/package.json +62 -0
  40. package/skills/obsrv-screens/SKILL.md +80 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Opeyemi Ajagbe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Obsrv
2
+
3
+ See your site the way 1x screens see it.
4
+
5
+ Designers and developers work on HiDPI (2x–3x) monitors. A large share of users are on
6
+ 1x screens — 1080p desktops, 1366×768 laptops, cheap TN panels. On those screens, thin
7
+ font weights go weak, 0.5px hairlines vanish, low-contrast grey text becomes illegible,
8
+ gradients band and P3 colours clip. Browser dev tools emulate the *viewport* of a 1x
9
+ screen but still rasterise on your monitor's physical pixels, so you never see any of it.
10
+
11
+ Obsrv shows any URL (or a dropped 2x design export) two ways, side by side:
12
+
13
+ - **Native** — rendered at your host DPR, the way you normally see it.
14
+ - **Target** — rendered at a true 1x by an offscreen Chromium surface
15
+ (`deviceScaleFactor: 1`), displayed at the physical size it would have on a chosen
16
+ target monitor, with optional cheap-panel simulation (contrast floor, sRGB coverage,
17
+ 6-bit + FRC dithering, brightness) applied in a WebGL2 shader.
18
+
19
+ Both panes stay in lock-step (scroll, navigation) and the 1x pane is fully interactive.
20
+
21
+ - **Mobile presets** — phone and tablet presets (iPhone 6.1" @3x, budget Android @2x, …)
22
+ rasterise at the device's *true* 2x/3x DPR, wear a mobile user agent, get real mobile
23
+ viewport semantics (a page without a viewport meta lays out at 980px and shrinks to
24
+ fit), and are shown at true physical size — usually a small, dense render on a
25
+ desktop monitor, exactly like the phone in your hand.
26
+
27
+ ## Use
28
+
29
+ ```bash
30
+ npm install
31
+ npm run dev
32
+ ```
33
+
34
+ Type a URL (localhost is fine), pick a target screen preset (1080p 24", 1366×768
35
+ laptop, iPhone 6.1" @3x, …), pick a panel profile (Reference, Office IPS, Budget TN, Old laptop) or open
36
+ the advanced sliders. Enter your own monitor's diagonal in Settings once so the target
37
+ pane renders at true physical size. Drop a 2x/3x PNG or JPG export to check a design
38
+ before it's built.
39
+
40
+ The target pane opens at 1:1, which usually overflows the pane: pan with a
41
+ middle-button drag, Option+drag or Option+wheel, or switch the toolbar's
42
+ `1:1 / Fit` control to a fit-to-pane overview (smoothly minified, so not
43
+ pixel-exact — the footer says so) and click anywhere in it to jump back to 1:1
44
+ at that spot.
45
+
46
+ ## Agent & CI use
47
+
48
+ The same rendering pipeline runs headless — no window, JSON on stdout, humans
49
+ on stderr — so agents (Claude Code) and CI can ask "how does this URL look on
50
+ a 1366×768 laptop / budget Android?" without the GUI. Build first
51
+ (`npm run build`; the CLI runs the built `out/`), then:
52
+
53
+ ```bash
54
+ # One PNG at a preset's true raster density (+ metadata JSON on stdout):
55
+ node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --out shot.png
56
+
57
+ # A matrix of screens, cheap-panel simulation, full-page capture:
58
+ node bin/obsrv.js snap http://localhost:5173 --matrix laptop-768,android-65,1080p-24 --out shots/
59
+ node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --profile budget-tn --out tn.png
60
+ node bin/obsrv.js snap http://localhost:5173 --preset laptop-768 --full-page --out full.png
61
+
62
+ # Machine-readable 1x-vs-2x comparison (ink coverage, row ratios, band deltas):
63
+ node bin/obsrv.js diff http://localhost:5173 --preset laptop-768 --out-dir diffout
64
+ ```
65
+
66
+ `node bin/obsrv.js --help` lists every preset, profile and flag. Diff findings
67
+ are informational (exit 0); CI thresholds are the caller's job. A ready-made
68
+ Claude Code skill that wraps the loop (snap matrix → read the PNGs → diff →
69
+ fix → re-snap) lives at [skills/obsrv-screens/SKILL.md](skills/obsrv-screens/SKILL.md).
70
+
71
+ ### MCP server
72
+
73
+ The same CLI is also wrapped as an MCP server (stdio, stateless) so MCP
74
+ clients get the tools natively: `obsrv_snap` (render a URL at a preset's true
75
+ raster density — the PNG comes back as an inline image up to 1.5 MiB),
76
+ `obsrv_diff` (the 1x-vs-2x metrics as structured output) and `obsrv_presets`
77
+ (every preset and panel profile, no render). Build first, then register:
78
+
79
+ ```bash
80
+ claude mcp add --scope user obsrv -- node /Users/opeyemiajagbe/Documents/Projects/Obsrv/bin/obsrv-mcp.js
81
+ ```
82
+
83
+ ## Develop
84
+
85
+ ```bash
86
+ npm run typecheck # tsc, both processes
87
+ npm test # unit (Vitest, node)
88
+ npm run test:browser # shader parity vs the TS reference (Vitest browser mode)
89
+ npm run test:e2e # Playwright driving the real Electron app
90
+ npm run dist # build a macOS DMG (unsigned without a Developer ID identity)
91
+ ```
92
+
93
+ Architecture, decisions and the full spec live in
94
+ [docs/superpowers/specs/2026-08-22-obsrv-design.md](docs/superpowers/specs/2026-08-22-obsrv-design.md);
95
+ the UI style rationale (why the chrome is strictly neutral) is in
96
+ [docs/superpowers/specs/2026-08-23-obsrv-ui-style.md](docs/superpowers/specs/2026-08-23-obsrv-ui-style.md).
97
+
98
+ ## Distribution
99
+
100
+ Obsrv will publish to npm as **`getobsrv`** (the installed commands remain `obsrv`
101
+ and `obsrv-mcp`; the app's display name remains Obsrv). The bare `obsrv` npm name
102
+ belongs to an unrelated package.
103
+
104
+ ## Known v1 limits
105
+
106
+ - Rendering truth is the host OS's 1x rasteriser (macOS today). Windows ClearType at 1x
107
+ looks different again; a Windows build would show Windows truth natively.
108
+ - Panel simulation is an approximation, not colourimetric.
109
+ - Non-ASCII text input does not type into the target pane (Electron `sendInputEvent`
110
+ limitation); nested scroll containers aren't mirrored.
111
+ - Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
112
+ with dirty rects it has not been needed.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Plain-Node launcher for the Obsrv MCP server (stdio transport): checks for
3
+ // the compiled entry and runs it in-process so stdin/stdout stay the protocol
4
+ // channel. Register with an MCP client as `node /path/to/bin/obsrv-mcp.js`.
5
+ //
6
+ // Prerequisites: `npm install` and `npm run build` in the Obsrv repo — the
7
+ // launcher runs the *built* out/mcp/server.js, never the TypeScript sources.
8
+ 'use strict'
9
+
10
+ const { existsSync } = require('node:fs')
11
+ const { join } = require('node:path')
12
+
13
+ const serverEntry = join(__dirname, '..', 'out', 'mcp', 'server.js')
14
+ if (!existsSync(serverEntry)) {
15
+ console.error('obsrv-mcp: out/mcp/server.js is missing — run `npm run build` in the Obsrv repo first')
16
+ process.exit(1)
17
+ }
18
+
19
+ require(serverEntry)
package/bin/obsrv.js ADDED
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // Plain-Node launcher for the headless Obsrv CLI: resolves the Electron
3
+ // binary and re-runs the built entry inside it, forwarding argv (behind `--`
4
+ // so Chromium never eats our flags), stdio, and the exit code.
5
+ //
6
+ // Prerequisites: `npm install` and `npm run build` in the Obsrv repo — the
7
+ // launcher runs the *built* out/main/cli.js, never the TypeScript sources.
8
+ 'use strict'
9
+
10
+ const { spawn } = require('node:child_process')
11
+ const { existsSync, mkdtempSync, rmSync } = require('node:fs')
12
+ const { tmpdir } = require('node:os')
13
+ const { join } = require('node:path')
14
+
15
+ const cliEntry = join(__dirname, '..', 'out', 'main', 'cli.js')
16
+ if (!existsSync(cliEntry)) {
17
+ console.error('obsrv: out/main/cli.js is missing — run `npm run build` in the Obsrv repo first')
18
+ process.exit(1)
19
+ }
20
+
21
+ let electron
22
+ try {
23
+ // Under plain node, require('electron') resolves to the binary's path.
24
+ electron = require('electron')
25
+ } catch {
26
+ console.error('obsrv: electron is not installed — run `npm install` in the Obsrv repo first')
27
+ process.exit(1)
28
+ }
29
+ if (typeof electron !== 'string') {
30
+ console.error('obsrv: require("electron") did not resolve to a binary path (already inside Electron?)')
31
+ process.exit(1)
32
+ }
33
+
34
+ const env = { ...process.env }
35
+ // Must boot the real Electron runtime, not Node-mode.
36
+ delete env.ELECTRON_RUN_AS_NODE
37
+
38
+ // The launcher owns the throwaway user-data dir: Chromium flushes profile
39
+ // files (Session Storage, Local State) *after* the last main-process JS runs,
40
+ // so the Electron child cannot reliably delete its own profile — the plain
41
+ // Node parent, which outlives Chromium, can.
42
+ const userData = mkdtempSync(join(tmpdir(), 'obsrv-cli-'))
43
+ env.OBSRV_CLI_USER_DATA = userData
44
+ const cleanup = () => {
45
+ try {
46
+ rmSync(userData, { recursive: true, force: true })
47
+ } catch {
48
+ // Best-effort removal of a tmp dir.
49
+ }
50
+ }
51
+
52
+ const child = spawn(electron, [cliEntry, '--', ...process.argv.slice(2)], { stdio: 'inherit', env })
53
+ child.on('error', err => {
54
+ console.error(`obsrv: failed to launch electron: ${err.message}`)
55
+ cleanup()
56
+ process.exit(1)
57
+ })
58
+ child.on('exit', (code, signal) => {
59
+ cleanup()
60
+ // A run we forwarded a signal into is not a success, even though Chromium's
61
+ // native SIGTERM shutdown reports exit code 0.
62
+ process.exit(signal || signalled ? 1 : code ?? 1)
63
+ })
64
+
65
+ // Forward termination to the Electron child rather than dying and orphaning
66
+ // it; the child's exit then drives our own (and the cleanup) above.
67
+ let signalled = false
68
+ for (const signal of ['SIGINT', 'SIGTERM']) {
69
+ process.on(signal, () => {
70
+ signalled = true
71
+ child.kill(signal)
72
+ })
73
+ }
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_PRESET = exports.ArgError = void 0;
4
+ exports.usage = usage;
5
+ exports.parseArgs = parseArgs;
6
+ const calibration_1 = require("../shared/calibration");
7
+ const presets_1 = require("../shared/presets");
8
+ /**
9
+ * Pure argv parsing for the headless CLI (`bin/obsrv.js` → `out/main/cli.js`).
10
+ * No Electron imports: everything here is unit-testable under plain node.
11
+ */
12
+ class ArgError extends Error {
13
+ }
14
+ exports.ArgError = ArgError;
15
+ exports.DEFAULT_PRESET = '1080p-24';
16
+ exports.DEFAULT_TIMEOUT_MS = 30_000;
17
+ function usage() {
18
+ const presets = presets_1.SCREEN_PRESETS.map(p => ` ${p.id.padEnd(14)} ${p.label}`).join('\n');
19
+ const profiles = presets_1.PANEL_PROFILES.map(p => p.id).join(' | ');
20
+ return `obsrv — see your site the way 1x screens see it (headless CLI)
21
+
22
+ Usage:
23
+ obsrv snap <url> [flags] Render <url> on a target screen; write a PNG, print JSON.
24
+ obsrv diff <url> [flags] Render <url> at 1x and against a 2x reference; print JSON metrics.
25
+
26
+ Shared flags:
27
+ --preset <id> Screen preset (default ${exports.DEFAULT_PRESET}):
28
+ ${presets}
29
+ --width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
30
+ Custom CSS viewport instead of --preset (dsf defaults to 1).
31
+ --profile <id> Panel profile: ${profiles} (default reference).
32
+ --wait <ms> Extra settle time after load (default 0).
33
+ --timeout <ms> Per-render budget for load + paint quiescence (default ${exports.DEFAULT_TIMEOUT_MS}).
34
+
35
+ snap flags:
36
+ --out <file> Output PNG (default ./obsrv-<preset>.png). Under --matrix: an
37
+ output directory, or a pattern containing {preset}.
38
+ --full-page Capture the full page height (device pixels capped at 4096).
39
+ --matrix <id,id,…> Render each listed preset in one run.
40
+
41
+ diff flags:
42
+ --out-dir <dir> Also write target.png and reference.png.
43
+ --json JSON metrics to stdout (already the default; accepted for clarity).
44
+
45
+ Repeated flags: the last occurrence wins.
46
+ Machine output (JSON) goes to stdout; everything human goes to stderr.
47
+ Exit code 0 on success — diff findings are informational, never a failure.`;
48
+ }
49
+ /** Flags that take no value. */
50
+ const BOOLEAN_FLAGS = new Set(['full-page', 'json']);
51
+ /** Flags that consume the next token. */
52
+ const VALUE_FLAGS = new Set(['preset', 'profile', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
53
+ const SNAP_ONLY = new Set(['out', 'full-page', 'matrix']);
54
+ const DIFF_ONLY = new Set(['out-dir', 'json']);
55
+ function collect(command, argv) {
56
+ let url = null;
57
+ const flags = new Map();
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const token = argv[i];
60
+ if (token.startsWith('--')) {
61
+ const name = token.slice(2);
62
+ if (BOOLEAN_FLAGS.has(name)) {
63
+ flags.set(name, true);
64
+ }
65
+ else if (VALUE_FLAGS.has(name)) {
66
+ const value = argv[++i];
67
+ if (value === undefined || value.startsWith('--'))
68
+ throw new ArgError(`--${name} requires a value`);
69
+ flags.set(name, value);
70
+ }
71
+ else {
72
+ throw new ArgError(`unknown flag: --${name}\n\n${usage()}`);
73
+ }
74
+ }
75
+ else if (url === null) {
76
+ url = token;
77
+ }
78
+ else {
79
+ throw new ArgError(`unexpected argument: ${token}`);
80
+ }
81
+ }
82
+ if (url === null)
83
+ throw new ArgError(`usage: obsrv ${command} <url> [flags] — run \`obsrv --help\` for the flag list`);
84
+ const wrongCommand = command === 'snap' ? DIFF_ONLY : SNAP_ONLY;
85
+ for (const name of flags.keys()) {
86
+ if (wrongCommand.has(name)) {
87
+ throw new ArgError(`--${name} is a ${command === 'snap' ? 'diff' : 'snap'} flag; \`obsrv ${command}\` does not take it`);
88
+ }
89
+ }
90
+ return { url, flags };
91
+ }
92
+ function integer(flags, name, fallback, min) {
93
+ const raw = flags.get(name);
94
+ if (raw === undefined)
95
+ return fallback;
96
+ const n = Number(raw);
97
+ if (!Number.isFinite(n))
98
+ throw new ArgError(`--${name}: expected a number, got "${String(raw)}"`);
99
+ if (!Number.isInteger(n))
100
+ throw new ArgError(`--${name}: expected an integer, got "${String(raw)}"`);
101
+ if (n < min)
102
+ throw new ArgError(`--${name}: must be >= ${min}`);
103
+ return n;
104
+ }
105
+ function float(flags, name, fallback, min) {
106
+ const raw = flags.get(name);
107
+ if (raw === undefined)
108
+ return fallback;
109
+ const n = Number(raw);
110
+ if (!Number.isFinite(n) || n < min)
111
+ throw new ArgError(`--${name}: expected a number >= ${min}, got "${String(raw)}"`);
112
+ return n;
113
+ }
114
+ function presetSpec(id) {
115
+ let preset;
116
+ try {
117
+ preset = (0, presets_1.findPreset)(id);
118
+ }
119
+ catch {
120
+ throw new ArgError(`unknown preset: ${id} (valid: ${presets_1.SCREEN_PRESETS.map(p => p.id).join(', ')})`);
121
+ }
122
+ return {
123
+ presetId: preset.id,
124
+ cssWidth: preset.width,
125
+ cssHeight: preset.height,
126
+ deviceScaleFactor: preset.deviceScaleFactor,
127
+ diagonalInches: preset.diagonalInches,
128
+ };
129
+ }
130
+ function resolveSpecs(flags) {
131
+ const custom = ['width', 'height', 'dsf', 'diagonal'].some(f => flags.has(f));
132
+ if (custom && flags.has('preset'))
133
+ throw new ArgError('--preset and --width/--height are mutually exclusive');
134
+ if (custom && flags.has('matrix'))
135
+ throw new ArgError('--matrix lists presets; it cannot be combined with custom --width/--height dims');
136
+ if (flags.has('matrix') && flags.has('preset'))
137
+ throw new ArgError('--matrix already lists presets; drop --preset');
138
+ if (custom) {
139
+ if (!flags.has('width') || !flags.has('height')) {
140
+ throw new ArgError('custom dims need both --width and --height (with optional --dsf and --diagonal)');
141
+ }
142
+ const cssWidth = integer(flags, 'width', 0, 1);
143
+ const cssHeight = integer(flags, 'height', 0, 1);
144
+ const deviceScaleFactor = float(flags, 'dsf', 1, 1);
145
+ const max = (0, calibration_1.maxCssViewport)(deviceScaleFactor);
146
+ if (cssWidth > max || cssHeight > max) {
147
+ throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
148
+ }
149
+ const diagonal = flags.has('diagonal') ? float(flags, 'diagonal', 0, 0.1) : null;
150
+ return { specs: [{ presetId: 'custom', cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
151
+ }
152
+ const matrixRaw = flags.get('matrix');
153
+ if (typeof matrixRaw === 'string') {
154
+ const ids = matrixRaw.split(',').map(s => s.trim()).filter(s => s.length > 0);
155
+ if (ids.length === 0)
156
+ throw new ArgError('--matrix: expected a comma-separated list of preset ids');
157
+ return { specs: ids.map(presetSpec), matrix: true };
158
+ }
159
+ const id = typeof flags.get('preset') === 'string' ? flags.get('preset') : exports.DEFAULT_PRESET;
160
+ return { specs: [presetSpec(id)], matrix: false };
161
+ }
162
+ function resolveProfile(flags) {
163
+ const raw = flags.get('profile');
164
+ if (raw === undefined)
165
+ return 'reference';
166
+ try {
167
+ return (0, presets_1.findProfile)(raw).id;
168
+ }
169
+ catch {
170
+ throw new ArgError(`unknown profile: ${String(raw)} (valid: ${presets_1.PANEL_PROFILES.map(p => p.id).join(', ')})`);
171
+ }
172
+ }
173
+ function parseArgs(argv) {
174
+ const [command, ...rest] = argv;
175
+ if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
176
+ return { command: 'help', text: usage() };
177
+ }
178
+ if (command !== 'snap' && command !== 'diff') {
179
+ throw new ArgError(`unknown command: ${command}\n\n${usage()}`);
180
+ }
181
+ const { url, flags } = collect(command, rest);
182
+ const { specs, matrix } = resolveSpecs(flags);
183
+ const profileId = resolveProfile(flags);
184
+ const waitMs = integer(flags, 'wait', 0, 0);
185
+ const timeoutMs = integer(flags, 'timeout', exports.DEFAULT_TIMEOUT_MS, 1);
186
+ if (command === 'snap') {
187
+ const out = typeof flags.get('out') === 'string' ? flags.get('out') : matrix ? '.' : `obsrv-${specs[0].presetId}.png`;
188
+ return { command, url, specs, profileId, out, matrix, fullPage: flags.has('full-page'), waitMs, timeoutMs };
189
+ }
190
+ const spec = specs[0];
191
+ if (spec.deviceScaleFactor !== 1) {
192
+ throw new ArgError(`diff compares the target against a 2x reference downsampled onto the target's 1x grid, ` +
193
+ `so it only supports 1x targets in v1 — "${spec.presetId}" renders at ${spec.deviceScaleFactor}x. ` +
194
+ `Use a 1x preset (e.g. laptop-768, 1080p-24) or plain \`obsrv snap\` for dense presets.`);
195
+ }
196
+ const referenceMax = (0, calibration_1.maxCssViewport)(2);
197
+ if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
198
+ throw new ArgError(`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis ` +
199
+ `(4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. ` +
200
+ `Use \`obsrv snap\` for this preset instead.`);
201
+ }
202
+ const outDir = typeof flags.get('out-dir') === 'string' ? flags.get('out-dir') : null;
203
+ return { command, url, spec, profileId, outDir, waitMs, timeoutMs };
204
+ }