getobsrv 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -90,6 +90,24 @@ sit in half a window, and for agent captures. The native pane stays loaded while
90
90
  hidden, so the URL bar, back/forward and link clicks keep working exactly as
91
91
  they do side by side.
92
92
 
93
+ ### Tabs
94
+
95
+ The strip above the URL bar holds several independent sessions at once. A tab is
96
+ its own URL, its own screen preset and panel profile, its own view mode and pan,
97
+ and its own page state — so the same page on a 1366×768 laptop and on a budget
98
+ Android sit in two tabs and you flip between them without either one reloading.
99
+
100
+ `Cmd+T` opens a tab, `Cmd+W` closes one, `Cmd+1`–`Cmd+8` select by position and
101
+ `Cmd+9` selects the last. Closing the last tab leaves a fresh blank one rather
102
+ than closing the window. Tabs come back on relaunch — their URLs, their screens
103
+ and which one was in front.
104
+
105
+ Every tab is two Chromium renderers (a native pane and an offscreen 1x surface),
106
+ so the count is capped: 12 by default, settable from 2 to 32 in Settings. At the
107
+ cap the new-tab button dims and says why. Background tabs stay loaded and keep
108
+ their scroll position, but stop rasterising until you return to them — the cost
109
+ of a background tab is memory, not GPU.
110
+
93
111
  ## Agent & CI use
94
112
 
95
113
  The same rendering pipeline runs headless — no window, JSON on stdout, humans
@@ -144,6 +162,15 @@ navigates unless you pass `url`, so the scroll survives the shutter. A live
144
162
  `navigated` field says which happened — and navigating is a fresh load, which
145
163
  starts at the top of the page.
146
164
 
165
+ **Tabs and the agent.** `obsrv_drive` and a live `obsrv_snap` act on the tab
166
+ that is in front, resolved per command rather than fixed when the drive starts —
167
+ a command that quietly succeeded on a tab you could not see would not surface
168
+ until the drive ended. Both report `tabId` and `tabIndex`, so an agent that
169
+ cares can compare them across calls and notice you switched tabs under it. While
170
+ Agent control is on, the driven tab carries a neutral rule on its leading edge
171
+ that brightens for ~3 s on each command, so it is visible which session is being
172
+ driven. An agent cannot open, close or switch tabs; that stays yours.
173
+
147
174
  A headless `snap` returns `settled: true` when the page went paint-quiet and
148
175
  every pixel painted. `settled: false` is still a usable capture, not a
149
176
  failure — a page that kept animating, or one whose repaint never completed,
@@ -220,3 +247,10 @@ belongs to an unrelated package.
220
247
  hides its scroller in a shadow root has no escape hatch.
221
248
  - Frame delivery has no renderer-side backpressure mailbox (see plan header); at 30 fps
222
249
  with dirty rects it has not been needed.
250
+ - Tabs are a first cut. They cannot be reordered, dragged out into another window, or
251
+ reopened after closing (no `Cmd+Shift+T`), and there is no tab overflow menu — a strip
252
+ longer than the window scrolls. An agent can only reach the tab in front: there is no
253
+ way to name another tab in `obsrv_drive`, and no way for an agent to open, close or
254
+ switch tabs at all. The URL bar's visited-URL suggestions are one window-wide list
255
+ rather than one per tab (back/forward *are* per tab), and a restored tab comes back at
256
+ the top of its page — the scroll position is not persisted.
package/out/cli/args.js CHANGED
@@ -30,6 +30,18 @@ Shared flags:
30
30
  ${presets}
31
31
  --width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
32
32
  Custom CSS viewport instead of --preset (dsf defaults to 1).
33
+ --orientation <o> portrait | landscape (default ${presets_1.DEFAULT_ORIENTATION}). This names the
34
+ preset's *stored* orientation, not the shape you get:
35
+ portrait = the preset exactly as the table above lists it
36
+ landscape = that rotated a quarter turn (width and height swap)
37
+ Every mobile preset is stored portrait, so for those the two
38
+ readings agree. The laptop and desktop presets are stored
39
+ landscape-natural, so --orientation landscape turns them into a
40
+ portrait screen — which is how you render a 1080p monitor stood on
41
+ end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
42
+ dims too. The diagonal, raster density and physical size never
43
+ change: it is the same panel turned sideways. Each render's JSON
44
+ and log line name the resulting shape.
33
45
  --profile <id> Panel profile: ${profiles} (default reference).
34
46
  --wait <ms> Extra settle time after load (default 0).
35
47
  --timeout <ms> Per-render budget for load + paint quiescence (default ${exports.DEFAULT_TIMEOUT_MS}).
@@ -55,7 +67,7 @@ warning naming what was missing. Only a render that painted nothing errors.`;
55
67
  /** Flags that take no value. */
56
68
  const BOOLEAN_FLAGS = new Set(['full-page', 'json']);
57
69
  /** Flags that consume the next token. */
58
- const VALUE_FLAGS = new Set(['preset', 'profile', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
70
+ const VALUE_FLAGS = new Set(['preset', 'profile', 'orientation', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
59
71
  const SNAP_ONLY = new Set(['out', 'full-page', 'matrix']);
60
72
  const DIFF_ONLY = new Set(['out-dir', 'json']);
61
73
  function collect(command, argv) {
@@ -131,9 +143,30 @@ function presetSpec(id) {
131
143
  cssHeight: preset.height,
132
144
  deviceScaleFactor: preset.deviceScaleFactor,
133
145
  diagonalInches: preset.diagonalInches,
146
+ orientation: presets_1.DEFAULT_ORIENTATION,
134
147
  };
135
148
  }
149
+ function resolveOrientation(flags) {
150
+ const raw = flags.get('orientation');
151
+ if (raw === undefined)
152
+ return presets_1.DEFAULT_ORIENTATION;
153
+ if (!(0, presets_1.isOrientation)(raw))
154
+ throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
155
+ return raw;
156
+ }
157
+ /**
158
+ * Rotation swaps the CSS axes and nothing else — the diagonal and the raster
159
+ * density are orientation-independent, so the render is the same screen turned
160
+ * sideways rather than a different one. Applied here, before the diff bounds
161
+ * are checked, so those check the viewport that will actually be rendered.
162
+ */
163
+ function orientSpec(spec, orientation) {
164
+ if (orientation !== 'landscape')
165
+ return { ...spec, orientation };
166
+ return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
167
+ }
136
168
  function resolveSpecs(flags) {
169
+ const orientation = resolveOrientation(flags);
137
170
  const custom = ['width', 'height', 'dsf', 'diagonal'].some(f => flags.has(f));
138
171
  if (custom && flags.has('preset'))
139
172
  throw new ArgError('--preset and --width/--height are mutually exclusive');
@@ -153,17 +186,25 @@ function resolveSpecs(flags) {
153
186
  throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
154
187
  }
155
188
  const diagonal = flags.has('diagonal') ? float(flags, 'diagonal', 0, 0.1) : null;
156
- return { specs: [{ presetId: 'custom', cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
189
+ const spec = {
190
+ presetId: 'custom',
191
+ cssWidth,
192
+ cssHeight,
193
+ deviceScaleFactor,
194
+ diagonalInches: diagonal,
195
+ orientation: presets_1.DEFAULT_ORIENTATION,
196
+ };
197
+ return { specs: [orientSpec(spec, orientation)], matrix: false };
157
198
  }
158
199
  const matrixRaw = flags.get('matrix');
159
200
  if (typeof matrixRaw === 'string') {
160
201
  const ids = matrixRaw.split(',').map(s => s.trim()).filter(s => s.length > 0);
161
202
  if (ids.length === 0)
162
203
  throw new ArgError('--matrix: expected a comma-separated list of preset ids');
163
- return { specs: ids.map(presetSpec), matrix: true };
204
+ return { specs: ids.map(id => orientSpec(presetSpec(id), orientation)), matrix: true };
164
205
  }
165
206
  const id = typeof flags.get('preset') === 'string' ? flags.get('preset') : exports.DEFAULT_PRESET;
166
- return { specs: [presetSpec(id)], matrix: false };
207
+ return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
167
208
  }
168
209
  function resolveProfile(flags) {
169
210
  const raw = flags.get('profile');
@@ -201,8 +242,14 @@ function parseArgs(argv) {
201
242
  }
202
243
  const referenceMax = (0, calibration_1.maxCssViewport)(2);
203
244
  if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
245
+ // Named as rendered, not as stored: the dims here are post-rotation, and
246
+ // attributing them to the bare preset id would print "1440p-27 is
247
+ // 1440×2560" — a shape that id never has. The bound itself is per-axis
248
+ // symmetric, so rotation can never sneak a too-large viewport past it;
249
+ // this is the message telling the truth about which one it measured.
250
+ const as = spec.orientation === 'landscape' ? ' rotated a quarter turn' : '';
204
251
  throw new ArgError(`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis ` +
205
- `(4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. ` +
252
+ `(4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. ` +
206
253
  `Use \`obsrv snap\` for this preset instead.`);
207
254
  }
208
255
  const outDir = typeof flags.get('out-dir') === 'string' ? flags.get('out-dir') : null;
package/out/main/cli.js CHANGED
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_fs = require("node:fs");
4
4
  const node_os = require("node:os");
5
5
  const node_path = require("node:path");
6
- const targetSource = require("./targetSource-nbfc-Fe6.js");
6
+ const targetSource = require("./targetSource-DuRYHUo8.js");
7
7
  function boxDownsample(src, factor) {
8
8
  if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
9
9
  const width = Math.floor(src.width / factor);
@@ -61,6 +61,18 @@ Shared flags:
61
61
  ${presets}
62
62
  --width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
63
63
  Custom CSS viewport instead of --preset (dsf defaults to 1).
64
+ --orientation <o> portrait | landscape (default ${targetSource.DEFAULT_ORIENTATION}). This names the
65
+ preset's *stored* orientation, not the shape you get:
66
+ portrait = the preset exactly as the table above lists it
67
+ landscape = that rotated a quarter turn (width and height swap)
68
+ Every mobile preset is stored portrait, so for those the two
69
+ readings agree. The laptop and desktop presets are stored
70
+ landscape-natural, so --orientation landscape turns them into a
71
+ portrait screen — which is how you render a 1080p monitor stood on
72
+ end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
73
+ dims too. The diagonal, raster density and physical size never
74
+ change: it is the same panel turned sideways. Each render's JSON
75
+ and log line name the resulting shape.
64
76
  --profile <id> Panel profile: ${profiles} (default reference).
65
77
  --wait <ms> Extra settle time after load (default 0).
66
78
  --timeout <ms> Per-render budget for load + paint quiescence (default ${DEFAULT_TIMEOUT_MS}).
@@ -84,7 +96,7 @@ painted. False is a rescued capture, not a failure: a page that kept animating
84
96
  warning naming what was missing. Only a render that painted nothing errors.`;
85
97
  }
86
98
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["full-page", "json"]);
87
- const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
99
+ const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "orientation", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
88
100
  const SNAP_ONLY = /* @__PURE__ */ new Set(["out", "full-page", "matrix"]);
89
101
  const DIFF_ONLY = /* @__PURE__ */ new Set(["out-dir", "json"]);
90
102
  function collect(command, argv) {
@@ -148,10 +160,22 @@ function presetSpec(id) {
148
160
  cssWidth: preset.width,
149
161
  cssHeight: preset.height,
150
162
  deviceScaleFactor: preset.deviceScaleFactor,
151
- diagonalInches: preset.diagonalInches
163
+ diagonalInches: preset.diagonalInches,
164
+ orientation: targetSource.DEFAULT_ORIENTATION
152
165
  };
153
166
  }
167
+ function resolveOrientation(flags) {
168
+ const raw = flags.get("orientation");
169
+ if (raw === void 0) return targetSource.DEFAULT_ORIENTATION;
170
+ if (!targetSource.isOrientation(raw)) throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
171
+ return raw;
172
+ }
173
+ function orientSpec(spec, orientation) {
174
+ if (orientation !== "landscape") return { ...spec, orientation };
175
+ return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
176
+ }
154
177
  function resolveSpecs(flags) {
178
+ const orientation = resolveOrientation(flags);
155
179
  const custom = ["width", "height", "dsf", "diagonal"].some((f) => flags.has(f));
156
180
  if (custom && flags.has("preset")) throw new ArgError("--preset and --width/--height are mutually exclusive");
157
181
  if (custom && flags.has("matrix")) throw new ArgError("--matrix lists presets; it cannot be combined with custom --width/--height dims");
@@ -168,16 +192,24 @@ function resolveSpecs(flags) {
168
192
  throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
169
193
  }
170
194
  const diagonal = flags.has("diagonal") ? float(flags, "diagonal", 0, 0.1) : null;
171
- return { specs: [{ presetId: "custom", cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
195
+ const spec = {
196
+ presetId: "custom",
197
+ cssWidth,
198
+ cssHeight,
199
+ deviceScaleFactor,
200
+ diagonalInches: diagonal,
201
+ orientation: targetSource.DEFAULT_ORIENTATION
202
+ };
203
+ return { specs: [orientSpec(spec, orientation)], matrix: false };
172
204
  }
173
205
  const matrixRaw = flags.get("matrix");
174
206
  if (typeof matrixRaw === "string") {
175
207
  const ids = matrixRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
176
208
  if (ids.length === 0) throw new ArgError("--matrix: expected a comma-separated list of preset ids");
177
- return { specs: ids.map(presetSpec), matrix: true };
209
+ return { specs: ids.map((id2) => orientSpec(presetSpec(id2), orientation)), matrix: true };
178
210
  }
179
211
  const id = typeof flags.get("preset") === "string" ? flags.get("preset") : DEFAULT_PRESET;
180
- return { specs: [presetSpec(id)], matrix: false };
212
+ return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
181
213
  }
182
214
  function resolveProfile(flags) {
183
215
  const raw = flags.get("profile");
@@ -215,8 +247,9 @@ ${usage()}`);
215
247
  }
216
248
  const referenceMax = targetSource.maxCssViewport(2);
217
249
  if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
250
+ const as = spec.orientation === "landscape" ? " rotated a quarter turn" : "";
218
251
  throw new ArgError(
219
- `diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
252
+ `diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
220
253
  );
221
254
  }
222
255
  const outDir = typeof flags.get("out-dir") === "string" ? flags.get("out-dir") : null;
@@ -559,8 +592,9 @@ async function runSnap(cmd) {
559
592
  const img = applyPanelProfile(bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height), profile);
560
593
  node_fs.mkdirSync(node_path.dirname(out), { recursive: true });
561
594
  node_fs.writeFileSync(out, encodePng(img));
595
+ const shape = targetSource.screenShape(r.cssWidth, r.cssHeight);
562
596
  human(
563
- `snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, preset ${spec.presetId}, profile ${profile.id})`
597
+ `snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, ${r.cssWidth}×${r.cssHeight} CSS ${shape}, preset ${spec.presetId}, profile ${profile.id})`
564
598
  );
565
599
  results.push({
566
600
  out,
@@ -607,7 +641,7 @@ async function runDiff(cmd) {
607
641
  }
608
642
  const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
609
643
  human(
610
- `diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
644
+ `diff ${cmd.url} @ ${cmd.spec.presetId} (${cmd.spec.cssWidth}×${cmd.spec.cssHeight} CSS ${targetSource.screenShape(cmd.spec.cssWidth, cmd.spec.cssHeight)}, profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
611
645
  );
612
646
  await machine({
613
647
  url: cmd.url,