getobsrv 0.6.0 → 0.7.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
@@ -91,6 +91,13 @@ root cannot scroll, and takes a `scrollSelector` when you need to name the
91
91
  container yourself. With no app running, everything falls back to the headless
92
92
  render automatically.
93
93
 
94
+ To photograph a scrolled or panned state, pass `capture: 'window' | 'pane'` to
95
+ `obsrv_drive`: it captures after its commands run, and nothing in that tool
96
+ navigates unless you pass `url`, so the scroll survives the shutter. A live
97
+ `obsrv_snap` only navigates when the app is showing a *different* URL — its
98
+ `navigated` field says which happened — and navigating is a fresh load, which
99
+ starts at the top of the page.
100
+
94
101
  A headless `snap` returns `settled: true` when the page went paint-quiet and
95
102
  every pixel painted. `settled: false` is still a usable capture, not a
96
103
  failure — a page that kept animating, or one whose repaint never completed,
@@ -128,6 +135,11 @@ yet notarised, so macOS falsely reports it as "damaged"):
128
135
  xattr -cr /Applications/Obsrv.app
129
136
  ```
130
137
 
138
+ Obsrv checks GitHub for a newer release once a day and, when there is one, shows
139
+ it in the toolbar; clicking opens the release page. It is a single
140
+ unauthenticated request carrying no identifiers, and Settings → Updates turns it
141
+ off.
142
+
131
143
  ## Distribution
132
144
 
133
145
  Publish via a packed tarball, never bare `npm publish`: `npm publish` snapshots
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-BKW32VA5.js");
6
+ const targetSource = require("./targetSource-CbJwfugi.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);
@@ -370,6 +370,7 @@ function inkRows(img) {
370
370
  }
371
371
  return rows;
372
372
  }
373
+ const UNSETTLED_FINDING = "renders did not go paint-quiet within the budget (animation or video), so the two captures are different frames — the band deltas below are frame-to-frame noise, not evidence about rasterisation. Compare a static page, or pass a longer --timeout if the page merely settles late.";
373
374
  function bandInk(img, y0, y1) {
374
375
  let ink = 0;
375
376
  for (let y = y0; y < y1; y++) {
@@ -381,7 +382,7 @@ function bandInk(img, y0, y1) {
381
382
  return pixels === 0 ? 0 : ink / pixels;
382
383
  }
383
384
  const pct = (v) => `${(v * 100).toFixed(2)}%`;
384
- function diffMetrics(target, reference, referenceDeviceRows) {
385
+ function diffMetrics(target, reference, referenceDeviceRows, settled = true) {
385
386
  if (target.width !== reference.width || target.height !== reference.height) {
386
387
  throw new RangeError(
387
388
  `diffMetrics: mismatched dimensions (target ${target.width}x${target.height}, reference ${reference.width}x${reference.height})`
@@ -407,6 +408,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
407
408
  }
408
409
  }
409
410
  return {
411
+ settled,
410
412
  inkCoverage: { target: targetCoverage, reference: referenceCoverage, delta: targetCoverage - referenceCoverage },
411
413
  rows: {
412
414
  target: targetRows,
@@ -414,7 +416,7 @@ function diffMetrics(target, reference, referenceDeviceRows) {
414
416
  ratio: referenceDeviceRows > 0 ? targetRows / referenceDeviceRows : null
415
417
  },
416
418
  bands,
417
- findings
419
+ findings: settled ? findings : [UNSETTLED_FINDING]
418
420
  };
419
421
  }
420
422
  function profileToParams(p, hostNits) {
@@ -587,7 +589,12 @@ async function runDiff(cmd) {
587
589
  const referenceFull = bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height);
588
590
  const referenceDeviceRows = inkRows(referenceFull);
589
591
  const reference = boxDownsample(referenceFull, 2);
590
- const metrics = diffMetrics(target, reference, referenceDeviceRows);
592
+ const settled = t.frame.settled && r.frame.settled;
593
+ const warnings = [
594
+ ...t.warnings.map((w) => `target: ${w}`),
595
+ ...r.warnings.map((w) => `reference: ${w}`)
596
+ ];
597
+ const metrics = diffMetrics(target, reference, referenceDeviceRows, settled);
591
598
  let files;
592
599
  if (cmd.outDir) {
593
600
  const dir = node_path.resolve(cmd.outDir);
@@ -598,14 +605,15 @@ async function runDiff(cmd) {
598
605
  }
599
606
  const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
600
607
  human(
601
- `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)`
608
+ `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"}`
602
609
  );
603
610
  await machine({
604
611
  url: cmd.url,
605
612
  preset: cmd.spec.presetId,
606
613
  profile: profile.id,
607
614
  ...files ? { files } : {},
608
- ...metrics
615
+ ...metrics,
616
+ warnings
609
617
  });
610
618
  }
611
619
  const userData = process.env.OBSRV_CLI_USER_DATA ?? node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "obsrv-cli-"));
package/out/main/index.js CHANGED
@@ -4,7 +4,7 @@ const node_fs = require("node:fs");
4
4
  const promises = require("node:fs/promises");
5
5
  const node_path = require("node:path");
6
6
  const node_crypto = require("node:crypto");
7
- const targetSource = require("./targetSource-BKW32VA5.js");
7
+ const targetSource = require("./targetSource-CbJwfugi.js");
8
8
  const node_http = require("node:http");
9
9
  const node_url = require("node:url");
10
10
  const IPC = {
@@ -35,7 +35,11 @@ const IPC = {
35
35
  readImageFile: "obsrv:read-image-file",
36
36
  uiState: "obsrv:ui-state",
37
37
  agentApply: "obsrv:agent-apply",
38
- agentActivity: "obsrv:agent-activity"
38
+ agentActivity: "obsrv:agent-activity",
39
+ getUpdate: "obsrv:get-update",
40
+ checkUpdate: "obsrv:check-update",
41
+ openRelease: "obsrv:open-release",
42
+ updateStatus: "obsrv:update-status"
39
43
  };
40
44
  function attachFrameBus(target, win) {
41
45
  let ready = false;
@@ -136,7 +140,11 @@ function parseSettings(raw) {
136
140
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
137
141
  const agentControl = raw.agentControl ?? false;
138
142
  if (typeof agentControl !== "boolean") return null;
139
- return { hostDiagonalInches, hostNits, agentControl };
143
+ const updateCheck = raw.updateCheck ?? true;
144
+ if (typeof updateCheck !== "boolean") return null;
145
+ const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
146
+ if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
147
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
140
148
  }
141
149
  function parseMode(raw) {
142
150
  return raw === "url" || raw === "image" ? raw : null;
@@ -262,6 +270,7 @@ function parseHighlight(raw) {
262
270
  };
263
271
  }
264
272
  const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
273
+ const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
265
274
  function loadSettings(file) {
266
275
  try {
267
276
  const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
@@ -270,7 +279,11 @@ function loadSettings(file) {
270
279
  hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits,
271
280
  // Anything but a literal true (older files have no key at all) means off:
272
281
  // a network-facing capability must never be enabled by a malformed file.
273
- agentControl: raw.agentControl === true
282
+ agentControl: raw.agentControl === true,
283
+ // The opposite default: only a literal false turns the update check off,
284
+ // so a file from before this feature keeps it on.
285
+ updateCheck: raw.updateCheck !== false,
286
+ lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
274
287
  };
275
288
  } catch {
276
289
  return { ...targetSource.DEFAULT_SETTINGS };
@@ -279,9 +292,67 @@ function loadSettings(file) {
279
292
  function saveSettings(file, s) {
280
293
  if (!isPositive(s.hostDiagonalInches) || !isPositive(s.hostNits)) throw new RangeError("settings values must be finite and > 0");
281
294
  if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
295
+ if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
296
+ if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
282
297
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
283
298
  node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
284
299
  }
300
+ const RELEASES_API = "https://api.github.com/repos/vibesyemmy/obsrv/releases/latest";
301
+ const RELEASE_URL_PREFIX = "https://github.com/vibesyemmy/obsrv/releases/";
302
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
303
+ const CHECK_TIMEOUT_MS = 5e3;
304
+ function parseVersion(raw) {
305
+ const trimmed = raw.trim().replace(/^v/i, "");
306
+ if (trimmed === "") return null;
307
+ const [core = "", ...rest] = trimmed.split("-");
308
+ const parts = core.split(".").map(Number);
309
+ if (parts.length === 0 || parts.some((n) => !Number.isInteger(n) || n < 0)) return null;
310
+ return { parts, pre: rest.length > 0 };
311
+ }
312
+ function isNewer(latest, current) {
313
+ const a = parseVersion(latest);
314
+ const b = parseVersion(current);
315
+ if (!a || !b) return false;
316
+ const len = Math.max(a.parts.length, b.parts.length);
317
+ for (let i = 0; i < len; i++) {
318
+ const x = a.parts[i] ?? 0;
319
+ const y = b.parts[i] ?? 0;
320
+ if (x !== y) return x > y;
321
+ }
322
+ return !a.pre && b.pre;
323
+ }
324
+ function isReleaseUrl(url) {
325
+ let parsed;
326
+ try {
327
+ parsed = new URL(url);
328
+ } catch {
329
+ return false;
330
+ }
331
+ const allowed = new URL(RELEASE_URL_PREFIX);
332
+ return parsed.protocol === allowed.protocol && parsed.host === allowed.host && parsed.pathname.startsWith(allowed.pathname);
333
+ }
334
+ function readRelease(body, current, now) {
335
+ let raw;
336
+ try {
337
+ raw = JSON.parse(body);
338
+ } catch {
339
+ return null;
340
+ }
341
+ if (typeof raw !== "object" || raw === null) return null;
342
+ const { tag_name: tag, html_url: url } = raw;
343
+ if (typeof tag !== "string" || parseVersion(tag) === null) return null;
344
+ if (!isNewer(tag, current)) return { state: { status: "current", current, checkedAt: now }, url: "" };
345
+ if (typeof url !== "string" || !isReleaseUrl(url)) return null;
346
+ return {
347
+ state: { status: "available", current, latest: tag.trim().replace(/^v/i, ""), checkedAt: now },
348
+ url
349
+ };
350
+ }
351
+ function isCheckDue(lastUpdateCheck, now) {
352
+ if (!Number.isFinite(lastUpdateCheck) || lastUpdateCheck <= 0) return true;
353
+ if (lastUpdateCheck > now) return true;
354
+ return now - lastUpdateCheck >= CHECK_INTERVAL_MS;
355
+ }
285
356
  const MAX_BODY_BYTES = 64 * 1024;
286
357
  const APPLY_WAIT_MS = 2e3;
287
358
  const APPLY_POLL_MS = 25;
@@ -482,6 +553,49 @@ class ControlServer {
482
553
  });
483
554
  }
484
555
  }
556
+ async function checkForUpdate(current, now) {
557
+ const failed = { state: { status: "error", current, checkedAt: now }, url: "" };
558
+ const endpoint = process.env.OBSRV_RELEASES_API ?? RELEASES_API;
559
+ return new Promise((resolve) => {
560
+ let settled = false;
561
+ const done = (v) => {
562
+ if (settled) return;
563
+ settled = true;
564
+ clearTimeout(timer);
565
+ resolve(v);
566
+ };
567
+ let request;
568
+ try {
569
+ request = electron.net.request({ method: "GET", url: endpoint });
570
+ } catch {
571
+ resolve(failed);
572
+ return;
573
+ }
574
+ const timer = setTimeout(() => {
575
+ request.abort();
576
+ done(failed);
577
+ }, CHECK_TIMEOUT_MS);
578
+ request.setHeader("accept", "application/vnd.github+json");
579
+ request.setHeader("user-agent", "obsrv-update-check");
580
+ request.on("response", (response) => {
581
+ if (response.statusCode !== 200) {
582
+ response.on("data", () => void 0);
583
+ response.on("end", () => done(failed));
584
+ return;
585
+ }
586
+ const chunks = [];
587
+ response.on("data", (c) => chunks.push(Buffer.from(c)));
588
+ response.on("end", () => {
589
+ const parsed = readRelease(Buffer.concat(chunks).toString("utf8"), current, now);
590
+ done(parsed ?? failed);
591
+ });
592
+ response.on("error", () => done(failed));
593
+ });
594
+ request.on("error", () => done(failed));
595
+ request.on("abort", () => done(failed));
596
+ request.end();
597
+ });
598
+ }
485
599
  const TOOLBAR_H = 44;
486
600
  const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
487
601
  const SCROLL_REPLY_TIMEOUT_MS = 1e3;
@@ -671,6 +785,40 @@ function registerIpc(ctx) {
671
785
  return electron.app.getVersion();
672
786
  }
673
787
  })();
788
+ let update = { status: "current", current: appVersion, checkedAt: 0 };
789
+ let releaseUrl = "";
790
+ const runUpdateCheck = async () => {
791
+ const now = Date.now();
792
+ const { state, url } = await checkForUpdate(appVersion, now);
793
+ update = state;
794
+ releaseUrl = state.status === "available" && isReleaseUrl(url) ? url : "";
795
+ const next = { ...settings, lastUpdateCheck: now };
796
+ try {
797
+ saveSettings(settingsFile, next);
798
+ settings = next;
799
+ } catch {
800
+ }
801
+ if (!win.isDestroyed()) win.webContents.send(IPC.updateStatus, state);
802
+ return state;
803
+ };
804
+ electron.ipcMain.handle(IPC.getUpdate, (e) => {
805
+ assertRenderer(e);
806
+ return update;
807
+ });
808
+ electron.ipcMain.handle(IPC.checkUpdate, (e) => {
809
+ assertRenderer(e);
810
+ return runUpdateCheck();
811
+ });
812
+ electron.ipcMain.handle(IPC.openRelease, async (e) => {
813
+ assertRenderer(e);
814
+ if (releaseUrl === "") return false;
815
+ await electron.shell.openExternal(releaseUrl);
816
+ return true;
817
+ });
818
+ const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
819
+ if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
820
+ void runUpdateCheck();
821
+ }
674
822
  const control = new ControlServer(node_path.join(electron.app.getPath("userData"), CONTROL_FILE_NAME), {
675
823
  status: () => {
676
824
  let url = "";
@@ -3,7 +3,13 @@ const electron = require("electron");
3
3
  const node_events = require("node:events");
4
4
  const node_path = require("node:path");
5
5
  const MAX_VIEWPORT = 4096;
6
- const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
6
+ const DEFAULT_SETTINGS = {
7
+ hostDiagonalInches: 27,
8
+ hostNits: 500,
9
+ agentControl: false,
10
+ updateCheck: true,
11
+ lastUpdateCheck: 0
12
+ };
7
13
  const SCREEN_PRESETS = [
8
14
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
9
15
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
package/out/mcp/server.js CHANGED
@@ -12,6 +12,7 @@ const args_1 = require("../cli/args");
12
12
  const control_1 = require("../shared/control");
13
13
  const presets_1 = require("../shared/presets");
14
14
  const types_1 = require("../shared/types");
15
+ const url_1 = require("../shared/url");
15
16
  const control_2 = require("./control");
16
17
  const lib_1 = require("./lib");
17
18
  /**
@@ -144,7 +145,14 @@ const snapOutputShape = {
144
145
  .boolean()
145
146
  .describe('Headless: the page went paint-quiet and every pixel painted. False is still a usable capture — a page that ' +
146
147
  'kept animating, or one whose repaint never completed, is returned as-is with a warning saying what was ' +
147
- 'missing. Live: the app confirmed the navigation before the capture.'),
148
+ 'missing. Live: the app confirmed the navigation before the capture (trivially true when the app was ' +
149
+ 'already showing the URL and nothing was navigated).'),
150
+ navigated: zod_1.z
151
+ .boolean()
152
+ .optional()
153
+ .describe('Live only: false when the app was already showing this URL, so no reload was issued and the capture kept ' +
154
+ 'the current scroll position, pan and in-page state. True when the app was pointed somewhere new — that is ' +
155
+ 'a fresh load, which starts at the top of the page.'),
148
156
  warnings: zod_1.z.array(zod_1.z.string()),
149
157
  pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
150
158
  url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
@@ -182,6 +190,12 @@ const diffInputShape = {
182
190
  .describe(`Per-render budget for load + paint quiescence, in ms. Default ${args_1.DEFAULT_TIMEOUT_MS}.`),
183
191
  };
184
192
  const diffOutputShape = {
193
+ settled: zod_1.z
194
+ .boolean()
195
+ .describe('False when either render was a best-effort capture of a page that never stopped painting. The two captures ' +
196
+ 'are then different frames, so the band deltas are frame-to-frame noise rather than rendering evidence — ' +
197
+ '`findings` says so instead of interpreting them.'),
198
+ warnings: zod_1.z.array(zod_1.z.string()).describe('Anything either render warned about, prefixed target: / reference:.'),
185
199
  url: zod_1.z.string(),
186
200
  preset: zod_1.z.string(),
187
201
  profile: zod_1.z.string(),
@@ -273,6 +287,13 @@ const driveInputShape = {
273
287
  .optional()
274
288
  .describe('Draw a temporary neutral marker over this target-pixel rect in the pane (durationMs default 2000, clamped ' +
275
289
  '250-10000). A new highlight replaces the previous one.'),
290
+ capture: zod_1.z
291
+ .enum(['window', 'pane'])
292
+ .optional()
293
+ .describe("Capture the app after the commands run: 'pane' crops to the target pane (the 1x render on its own), " +
294
+ "'window' takes the whole app window. This is how you see a scrolled or panned state — unlike obsrv_snap, " +
295
+ 'nothing is navigated, so the scroll position survives. The PNG comes back inline when it is within the ' +
296
+ '1.5 MiB cap, and always as pngPath.'),
276
297
  };
277
298
  const driveOutputShape = {
278
299
  version: zod_1.z.string().describe('The running app version.'),
@@ -293,6 +314,15 @@ const driveOutputShape = {
293
314
  .optional()
294
315
  .describe("Only when `scroll` was requested: 'root' if the document scrolled, 'element' if an inner scroll container did."),
295
316
  warnings: zod_1.z.array(zod_1.z.string()).optional().describe('Anything worth knowing about the commands that ran (e.g. a scrollSelector that matched nothing).'),
317
+ pngPath: zod_1.z.string().optional().describe('Only when `capture` was requested: absolute path of the PNG (kept in a per-call temp dir).'),
318
+ width: zod_1.z
319
+ .number()
320
+ .optional()
321
+ .describe('Only when `capture` was requested: captured width in device-independent px; the raster is this times the display scale.'),
322
+ height: zod_1.z
323
+ .number()
324
+ .optional()
325
+ .describe('Only when `capture` was requested: captured height in device-independent px.'),
296
326
  };
297
327
  // --- live drive --------------------------------------------------------------
298
328
  /** Budget for one control `status` round-trip once the app is known live. */
@@ -317,20 +347,83 @@ function liveFailure(e) {
317
347
  }
318
348
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
319
349
  /**
320
- * The live `obsrv_snap` path: navigate the visible app (plus preset/profile
321
- * when given), wait bounded for the app to report the navigation, then
322
- * capture the window exactly as the user sees it.
350
+ * One short grace before a live capture: the renderer repaints the pane a
351
+ * frame or two after the store confirms, and a capture racing that would show
352
+ * a half-applied flip.
353
+ */
354
+ const LIVE_CAPTURE_GRACE_MS = 300;
355
+ /**
356
+ * Is the app already showing this page? Compared as parsed URLs so a request
357
+ * for `http://host:5173` matches the `http://host:5173/` the browser commits,
358
+ * and through the same normaliser the URL bar uses so a bare host works too.
359
+ * Anything unparseable falls back to a trimmed string compare.
360
+ */
361
+ function sameUrl(a, b) {
362
+ const norm = (raw) => {
363
+ const t = raw.trim();
364
+ if (t === '')
365
+ return '';
366
+ try {
367
+ return new URL((0, url_1.normalizeUrl)(t)).href;
368
+ }
369
+ catch {
370
+ return t;
371
+ }
372
+ };
373
+ const x = norm(a);
374
+ const y = norm(b);
375
+ return x !== '' && x === y;
376
+ }
377
+ /**
378
+ * Capture the app window — or just the target pane — over the control server
379
+ * and write it to a per-call temp PNG. Shared by the live `obsrv_snap` path
380
+ * and `obsrv_drive`'s `capture`, so both produce byte-identical results.
381
+ */
382
+ async function liveCapture(info, what) {
383
+ // `pane` crops to the target pane; both answer with the same
384
+ // { data, width, height } shape plus their own warnings (e.g. the pre-mount
385
+ // full-window fallback), which join the tool's.
386
+ const command = what === 'pane' ? 'captureTarget' : 'captureVisible';
387
+ const capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
388
+ const { data, width, height } = capture;
389
+ if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
390
+ throw new Error('the control server returned a malformed capture');
391
+ }
392
+ const warnings = [];
393
+ if (Array.isArray(capture['warnings'])) {
394
+ for (const w of capture['warnings'])
395
+ if (typeof w === 'string')
396
+ warnings.push(w);
397
+ }
398
+ const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
399
+ const pngPath = (0, node_path_1.join)(dir, 'live.png');
400
+ await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
401
+ return { pngPath, width, height, warnings };
402
+ }
403
+ /**
404
+ * The live `obsrv_snap` path: point the visible app at the URL (plus
405
+ * preset/profile when given), wait — bounded — for it to report the
406
+ * navigation, then capture the window exactly as the user sees it.
407
+ *
408
+ * When the app is already showing that URL the navigation is skipped entirely.
409
+ * A navigate is a fresh `loadURL`, which resets the scroll position, so
410
+ * reloading here would make `obsrv_drive { scroll }` followed by a snap of the
411
+ * same page always capture the top. `navigated: false` says which happened.
323
412
  */
324
413
  async function liveSnap(app, input, notes) {
325
414
  const { info } = app;
326
415
  const warnings = [...notes];
327
416
  const before = app.status.url;
328
- let applied = '';
417
+ // Already there? Then leave the page alone — see the note above.
418
+ const navigated = !sameUrl(before, input.url);
419
+ let applied = before;
329
420
  try {
330
- // The navigate command answers once both panes finished loading, so it
331
- // carries the same per-render budget the headless path polices.
332
- const nav = await (0, control_2.controlCall)(info, 'navigate', { url: input.url.trim() }, (input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS) + 10_000);
333
- applied = typeof nav['url'] === 'string' ? nav['url'] : '';
421
+ if (navigated) {
422
+ // The navigate command answers once both panes finished loading, so it
423
+ // carries the same per-render budget the headless path polices.
424
+ const nav = await (0, control_2.controlCall)(info, 'navigate', { url: input.url.trim() }, (input.timeoutMs ?? args_1.DEFAULT_TIMEOUT_MS) + 10_000);
425
+ applied = typeof nav['url'] === 'string' ? nav['url'] : '';
426
+ }
334
427
  if (input.preset !== undefined)
335
428
  await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
336
429
  if (input.profile !== undefined)
@@ -341,15 +434,18 @@ async function liveSnap(app, input, notes) {
341
434
  }
342
435
  // The app settles when it reports the applied URL — or, after a redirect,
343
436
  // any committed non-blank URL that is no longer the pre-navigation one.
437
+ // Nothing to settle when no navigation was issued; one status read still
438
+ // refreshes the preset/profile/view the result reports.
344
439
  let status = app.status;
345
- let settled = false;
440
+ let settled = !navigated;
346
441
  const deadline = Date.now() + LIVE_SETTLE_MS;
347
442
  for (;;) {
348
443
  try {
349
444
  const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
350
445
  if (s) {
351
446
  status = s;
352
- settled = s.url === applied || (applied !== '' && s.url !== before && s.url !== 'about:blank');
447
+ if (navigated)
448
+ settled = s.url === applied || (applied !== '' && s.url !== before && s.url !== 'about:blank');
353
449
  }
354
450
  }
355
451
  catch (e) {
@@ -361,33 +457,16 @@ async function liveSnap(app, input, notes) {
361
457
  }
362
458
  if (!settled)
363
459
  warnings.push('the app did not confirm the navigation before capture; the PNG may show the previous page.');
364
- // One short grace after the state settles: the renderer repaints the pane
365
- // (and any preset resize) a frame or two after the store confirms, and a
366
- // capture racing that would show a half-applied flip.
367
- await sleep(300);
368
- // `capture: 'pane'` crops to the target pane; the command answers with the
369
- // same { data, width, height } shape plus its own warnings (e.g. the
370
- // pre-mount full-window fallback), which join the tool's.
460
+ await sleep(LIVE_CAPTURE_GRACE_MS);
371
461
  let capture;
372
462
  try {
373
- const command = input.capture === 'pane' ? 'captureTarget' : 'captureVisible';
374
- capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
463
+ capture = await liveCapture(info, input.capture === 'pane' ? 'pane' : 'window');
375
464
  }
376
465
  catch (e) {
377
466
  return toolError(liveFailure(e));
378
467
  }
379
- const { data, width, height } = capture;
380
- if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
381
- return toolError('the control server returned a malformed capture');
382
- }
383
- if (Array.isArray(capture['warnings'])) {
384
- for (const w of capture['warnings'])
385
- if (typeof w === 'string')
386
- warnings.push(w);
387
- }
388
- const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
389
- const pngPath = (0, node_path_1.join)(dir, 'live.png');
390
- await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
468
+ warnings.push(...capture.warnings);
469
+ const { pngPath, width, height } = capture;
391
470
  const structured = {
392
471
  mode: 'live',
393
472
  url: status.url,
@@ -397,6 +476,7 @@ async function liveSnap(app, input, notes) {
397
476
  width,
398
477
  height,
399
478
  settled,
479
+ navigated,
400
480
  warnings,
401
481
  pngPath,
402
482
  };
@@ -428,7 +508,10 @@ server.registerTool('obsrv_snap', {
428
508
  `ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
429
509
  `touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
430
510
  `the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
431
- `capture; that visible steering is the point of live mode.`,
511
+ `capture; that visible steering is the point of live mode.\n\n` +
512
+ `A live snap only navigates when the app is showing a different URL; the result's \`navigated\` says which ` +
513
+ `happened. Navigating is a fresh load, so it starts at the top of the page — to photograph a scrolled or ` +
514
+ `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.`,
432
515
  inputSchema: snapInputShape,
433
516
  outputSchema: snapOutputShape,
434
517
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -492,6 +575,9 @@ server.registerTool('obsrv_diff', {
492
575
  `8 horizontal band deltas with humanised findings (informational — apply your own thresholds), and the ` +
493
576
  `paths of target.png / reference.png in a per-call temp dir. \`includeImages: true\` also inlines both ` +
494
577
  `PNGs (1.5 MiB cap each).\n\n` +
578
+ `Check \`settled\` before believing the bands: a page that never stops painting (animation, video) yields ` +
579
+ `two captures of *different frames*, so every delta is frame-to-frame noise. When it is false the numbers ` +
580
+ `are still returned but \`findings\` says so instead of interpreting them.\n\n` +
495
581
  `1x presets only (e.g. laptop-768, 1080p-24): dense presets (phones) and CSS viewports over 2048px are ` +
496
582
  `refused with an explanatory error — use obsrv_snap for those.\n\n` +
497
583
  `Headless-only: a diff always performs its own two renders and never drives a running Obsrv app window ` +
@@ -532,7 +618,8 @@ server.registerTool('obsrv_drive', {
532
618
  `both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
533
619
  `neutral marker, all while the user watches.\n\n` +
534
620
  `Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
535
- `preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight. ` +
621
+ `preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight ` +
622
+ `capture. ` +
536
623
  `The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
537
624
  `click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
538
625
  `scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
@@ -541,9 +628,12 @@ server.registerTool('obsrv_drive', {
541
628
  `Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
542
629
  `viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
543
630
  `on 1x presets); scroll takes page CSS px.\n\n` +
631
+ `Pass \`capture\` to get a PNG back once the commands have run. Nothing in this tool navigates unless you ` +
632
+ `pass \`url\`, so this is how you photograph a scrolled or panned state: scroll, then capture, in one call. ` +
633
+ `obsrv_snap is the other way round — it points the app at a URL first, and pointing it somewhere new is a ` +
634
+ `fresh load that starts at the top.\n\n` +
544
635
  `Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
545
- `mutates visible app state (it changes what the user's window shows, and a click can act on the live page) ` +
546
- `but renders nothing itself — use obsrv_snap for a capture.`,
636
+ `mutates visible app state (it changes what the user's window shows, and a click can act on the live page).`,
547
637
  inputSchema: driveInputShape,
548
638
  outputSchema: driveOutputShape,
549
639
  // Honest annotation: this changes what the user's window is showing.
@@ -621,6 +711,15 @@ server.registerTool('obsrv_drive', {
621
711
  }
622
712
  if (input.highlight !== undefined)
623
713
  await (0, control_2.controlCall)(live.info, 'highlight', input.highlight, LIVE_APPLY_TIMEOUT_MS);
714
+ // Capture last, so the PNG shows everything the commands above did.
715
+ // Nothing here navigates, so a scroll or pan applied in this same call
716
+ // is still in place when the shutter fires.
717
+ let capture = null;
718
+ if (input.capture !== undefined) {
719
+ await sleep(LIVE_CAPTURE_GRACE_MS);
720
+ capture = await liveCapture(live.info, input.capture);
721
+ warnings.push(...capture.warnings);
722
+ }
624
723
  const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
625
724
  if (!status)
626
725
  return toolError('the control server returned a malformed status');
@@ -629,11 +728,14 @@ server.registerTool('obsrv_drive', {
629
728
  ...(input.scroll !== undefined ? { scrolled: scrolled ?? null } : {}),
630
729
  ...(scroller !== undefined ? { scroller } : {}),
631
730
  ...(warnings.length > 0 ? { warnings } : {}),
731
+ ...(capture !== null ? { pngPath: capture.pngPath, width: capture.width, height: capture.height } : {}),
632
732
  };
633
- return {
634
- content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
635
- structuredContent: structured,
636
- };
733
+ const content = [{ type: 'text', text: JSON.stringify(structured, null, 2) }];
734
+ if (capture !== null) {
735
+ const label = input.capture === 'pane' ? 'The captured target pane' : 'The captured app window';
736
+ content.push(await imageOrNote(capture.pngPath, label, 'read the file at pngPath'));
737
+ }
738
+ return { content, structuredContent: structured };
637
739
  }
638
740
  catch (e) {
639
741
  return toolError(liveFailure(e));
@@ -25,7 +25,11 @@ const IPC = {
25
25
  readImageFile: "obsrv:read-image-file",
26
26
  uiState: "obsrv:ui-state",
27
27
  agentApply: "obsrv:agent-apply",
28
- agentActivity: "obsrv:agent-activity"
28
+ agentActivity: "obsrv:agent-activity",
29
+ getUpdate: "obsrv:get-update",
30
+ checkUpdate: "obsrv:check-update",
31
+ openRelease: "obsrv:open-release",
32
+ updateStatus: "obsrv:update-status"
29
33
  };
30
34
  function subscribe(channel, cb) {
31
35
  const listener = (_e, v) => cb(v);
@@ -94,6 +98,10 @@ const api = {
94
98
  return () => {
95
99
  electron.ipcRenderer.removeListener(IPC.agentActivity, listener);
96
100
  };
97
- }
101
+ },
102
+ getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
103
+ checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
104
+ openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
105
+ onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
98
106
  };
99
107
  electron.contextBridge.exposeInMainWorld("obsrv", api);
@@ -12688,7 +12688,13 @@ const createImpl = (createState) => {
12688
12688
  };
12689
12689
  const create = ((createState) => createImpl);
12690
12690
  const MAX_VIEWPORT = 4096;
12691
- const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
12691
+ const DEFAULT_SETTINGS = {
12692
+ hostDiagonalInches: 27,
12693
+ hostNits: 500,
12694
+ agentControl: false,
12695
+ updateCheck: true,
12696
+ lastUpdateCheck: 0
12697
+ };
12692
12698
  const SCREEN_PRESETS = [
12693
12699
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
12694
12700
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -12773,6 +12779,7 @@ const useStore = create()((set) => ({
12773
12779
  targetLoading: false,
12774
12780
  error: null,
12775
12781
  toast: null,
12782
+ update: null,
12776
12783
  image: null,
12777
12784
  surround: "graphite",
12778
12785
  viewMode: "1:1",
@@ -12798,6 +12805,7 @@ const useStore = create()((set) => ({
12798
12805
  // Both panes report the same `loadError` for one failed navigation; the
12799
12806
  // duplicate must not replace the object and re-render everything twice.
12800
12807
  setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12808
+ setUpdate: (update) => set({ update }),
12801
12809
  setToast: (toast) => set({ toast }),
12802
12810
  setImage: (image) => set({ image }),
12803
12811
  setSurround: (surround) => set({ surround }),
@@ -13228,6 +13236,18 @@ function PanelControls() {
13228
13236
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Choosing a profile in the toolbar resets these." })
13229
13237
  ] });
13230
13238
  }
13239
+ const MINUTE = 6e4;
13240
+ const HOUR = 60 * MINUTE;
13241
+ const DAY = 24 * HOUR;
13242
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"} ago`;
13243
+ function formatAge(checkedAt, now) {
13244
+ if (!Number.isFinite(checkedAt) || checkedAt <= 0) return "never";
13245
+ const age = now - checkedAt;
13246
+ if (age < MINUTE) return "just now";
13247
+ if (age < HOUR) return plural(Math.floor(age / MINUTE), "minute");
13248
+ if (age < DAY) return plural(Math.floor(age / HOUR), "hour");
13249
+ return plural(Math.floor(age / DAY), "day");
13250
+ }
13231
13251
  function NumberField({ className, label, unit, value, min, step, onCommit, onInvalid }) {
13232
13252
  const ref = reactExports.useRef(null);
13233
13253
  const [draft, setDraft] = reactExports.useState(String(value));
@@ -13278,6 +13298,7 @@ function NumberField({ className, label, unit, value, min, step, onCommit, onInv
13278
13298
  function SettingsPanel() {
13279
13299
  const host = useStore(useShallow((s) => s.host));
13280
13300
  const settings = useStore(useShallow((s) => s.settings));
13301
+ const update = useStore((s) => s.update);
13281
13302
  const custom = useStore(useShallow((s) => s.custom));
13282
13303
  const viewport = useStore(useShallow(selectViewport));
13283
13304
  const scale = useStore(selectScale);
@@ -13396,7 +13417,45 @@ function SettingsPanel() {
13396
13417
  viewport.height,
13397
13418
  "."
13398
13419
  ] }),
13399
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "readout", children: custom.diagonalInches > 0 ? `${ppi(custom.width, custom.height, custom.diagonalInches).toFixed(0)} PPI` : "Enter a diagonal to compute PPI" })
13420
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "readout", children: custom.diagonalInches > 0 ? `${ppi(custom.width, custom.height, custom.diagonalInches).toFixed(0)} PPI` : "Enter a diagonal to compute PPI" }),
13421
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Updates" }),
13422
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-block", children: [
13423
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
13424
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Version" }),
13425
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "version-current num", children: update?.current ?? "—" })
13426
+ ] }),
13427
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
13428
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Latest" }),
13429
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "version-latest", children: [
13430
+ update === null && "Not checked yet",
13431
+ update?.status === "current" && update.checkedAt === 0 && "Not checked yet",
13432
+ update?.status === "current" && update.checkedAt > 0 && "Up to date",
13433
+ update?.status === "error" && "Couldn’t check",
13434
+ update?.status === "available" && update.latest !== void 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
13435
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "num", children: update.latest }),
13436
+ " · ",
13437
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "link", onClick: () => void window.obsrv.openRelease(), children: "Download" })
13438
+ ] })
13439
+ ] })
13440
+ ] }),
13441
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
13442
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Last checked" }),
13443
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "version-checked num", children: update === null ? "never" : formatAge(update.checkedAt, Date.now()) })
13444
+ ] })
13445
+ ] }),
13446
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline update-check-toggle", children: [
13447
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13448
+ "input",
13449
+ {
13450
+ type: "checkbox",
13451
+ checked: settings.updateCheck,
13452
+ onChange: (e) => commit({ ...settings, updateCheck: e.target.checked })
13453
+ }
13454
+ ),
13455
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
13456
+ ] }),
13457
+ /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13458
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
13400
13459
  ] });
13401
13460
  }
13402
13461
  const VERT_SRC = `#version 300 es
@@ -14237,6 +14296,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14237
14296
  const setPixelExact = useStore((s) => s.setPixelExact);
14238
14297
  const setError = useStore((s) => s.setError);
14239
14298
  const surround = useStore((s) => s.surround);
14299
+ const update = useStore((s) => s.update);
14240
14300
  const setSurround = useStore((s) => s.setSurround);
14241
14301
  const viewMode = useStore((s) => s.viewMode);
14242
14302
  const setViewMode = useStore((s) => s.setViewMode);
@@ -14367,6 +14427,20 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14367
14427
  ),
14368
14428
  "Pixel-exact"
14369
14429
  ] }),
14430
+ update?.status === "available" && update.latest !== void 0 && /* @__PURE__ */ jsxRuntimeExports.jsxs(
14431
+ "button",
14432
+ {
14433
+ className: "update-button",
14434
+ type: "button",
14435
+ title: `Obsrv ${update.latest} is available — opens the download page`,
14436
+ onClick: () => void window.obsrv.openRelease(),
14437
+ children: [
14438
+ "v",
14439
+ update.latest,
14440
+ " ↓"
14441
+ ]
14442
+ }
14443
+ ),
14370
14444
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "surround-control", role: "group", "aria-label": "Pane surround", children: SURROUNDS.map((s) => /* @__PURE__ */ jsxRuntimeExports.jsx(
14371
14445
  "button",
14372
14446
  {
@@ -14431,6 +14505,7 @@ function App() {
14431
14505
  const setUrl = useStore((s) => s.setUrl);
14432
14506
  const setError = useStore((s) => s.setError);
14433
14507
  const setTargetLoading = useStore((s) => s.setTargetLoading);
14508
+ const setUpdate = useStore((s) => s.setUpdate);
14434
14509
  const setImageMeta = useStore((s) => s.setImage);
14435
14510
  const setMode = useStore((s) => s.setMode);
14436
14511
  const setToast = useStore((s) => s.setToast);
@@ -14444,6 +14519,7 @@ function App() {
14444
14519
  reactExports.useEffect(() => {
14445
14520
  window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
14446
14521
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
14522
+ window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
14447
14523
  const offs = [
14448
14524
  window.obsrv.onHostChanged(setHost),
14449
14525
  // A committed navigation — back, forward, reload, a link — supersedes
@@ -14454,12 +14530,13 @@ function App() {
14454
14530
  setUrl(url);
14455
14531
  }),
14456
14532
  window.obsrv.onLoadError(setError),
14457
- window.obsrv.onTargetLoading(setTargetLoading)
14533
+ window.obsrv.onTargetLoading(setTargetLoading),
14534
+ window.obsrv.onUpdateStatus(setUpdate)
14458
14535
  ];
14459
14536
  return () => {
14460
14537
  for (const off of offs) off();
14461
14538
  };
14462
- }, [setHost, setSettings, setUrl, setError, setTargetLoading]);
14539
+ }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
14463
14540
  reactExports.useEffect(() => {
14464
14541
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
14465
14542
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -477,3 +477,49 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
477
477
  }
478
478
  .browser-notice p { margin: 0.75rem 0 0; color: var(--text-1); }
479
479
  .browser-notice code { font-family: var(--mono); }
480
+
481
+ /* An update is neither a warning nor an error, so it carries no colour: the
482
+ only chromatic pixels in this chrome stay reserved for real attention. */
483
+ /* `.toolbar button` is (0,1,1) and pins every button to a 26px square, so this
484
+ needs two classes to outrank it — a bare `.update-button` renders as "v0.". */
485
+ .toolbar .update-button {
486
+ font-family: var(--mono);
487
+ font-variant-numeric: tabular-nums;
488
+ white-space: nowrap;
489
+ width: auto;
490
+ flex: 0 0 auto;
491
+ padding: 0 8px;
492
+ }
493
+ .version-block { margin: 8px 0; }
494
+ .version-row {
495
+ display: flex;
496
+ justify-content: space-between;
497
+ gap: 10px;
498
+ padding: 3px 0;
499
+ color: var(--text-1);
500
+ }
501
+ .version-row .version-current,
502
+ .version-row .version-latest,
503
+ .version-row .version-checked {
504
+ color: var(--text-0);
505
+ text-align: right;
506
+ }
507
+ .link {
508
+ background: none;
509
+ border: 0;
510
+ padding: 0;
511
+ color: var(--text-0);
512
+ text-decoration: underline;
513
+ cursor: pointer;
514
+ font: inherit;
515
+ }
516
+ .check-now {
517
+ width: 100%;
518
+ height: 24px;
519
+ margin-top: 8px;
520
+ background: var(--chrome-2);
521
+ color: var(--text-0);
522
+ border: 1px solid var(--line);
523
+ border-radius: 4px;
524
+ cursor: pointer;
525
+ }
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:" />
6
6
  <title>Obsrv</title>
7
- <script type="module" crossorigin src="./assets/index-DyCD4ih_.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-CHF-G97L.css">
7
+ <script type="module" crossorigin src="./assets/index-BP1S2N6S.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-BeroS4wv.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -99,9 +99,10 @@ function parseDeviceScaleFactor(raw) {
99
99
  return raw;
100
100
  }
101
101
  /**
102
- * Copies exactly the three known keys; the numbers must be finite and
103
- * positive. A missing `agentControl` means false (the pre-live-drive wire
104
- * shape); any non-boolean value is refused, never coerced.
102
+ * Copies exactly the five known keys; the numbers must be finite and
103
+ * positive. A missing `agentControl` means false and a missing `updateCheck`
104
+ * means true (the pre-feature wire shapes); any non-boolean value is refused,
105
+ * never coerced.
105
106
  */
106
107
  function parseSettings(raw) {
107
108
  if (!isRecord(raw))
@@ -114,7 +115,13 @@ function parseSettings(raw) {
114
115
  const agentControl = raw.agentControl ?? false;
115
116
  if (typeof agentControl !== 'boolean')
116
117
  return null;
117
- return { hostDiagonalInches, hostNits, agentControl };
118
+ const updateCheck = raw.updateCheck ?? true;
119
+ if (typeof updateCheck !== 'boolean')
120
+ return null;
121
+ const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
122
+ if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
123
+ return null;
124
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
118
125
  }
119
126
  function parseMode(raw) {
120
127
  return raw === 'url' || raw === 'image' ? raw : null;
@@ -4,7 +4,13 @@ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exp
4
4
  exports.findPreset = findPreset;
5
5
  exports.findProfile = findProfile;
6
6
  exports.MAX_VIEWPORT = 4096;
7
- exports.DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
7
+ exports.DEFAULT_SETTINGS = {
8
+ hostDiagonalInches: 27,
9
+ hostNits: 500,
10
+ agentControl: false,
11
+ updateCheck: true,
12
+ lastUpdateCheck: 0,
13
+ };
8
14
  exports.SCREEN_PRESETS = [
9
15
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
10
16
  { id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {
@@ -54,6 +54,13 @@ comes back inline. If the Obsrv desktop app is open with "Agent control" on
54
54
  click, pan and highlight to walk the user through what it found; no app
55
55
  means the usual headless render.
56
56
 
57
+ To see anything below the fold, scroll and capture in the **same**
58
+ `obsrv_drive` call — `{ scroll: { x: 0, y: 1500 }, capture: 'pane' }`. That
59
+ tool never navigates unless you pass `url`, so the scroll is still in place
60
+ when the PNG is taken. Reaching for `obsrv_snap` after a scroll works only
61
+ when the app is already on that exact URL (it answers `navigated: false`);
62
+ snapping a different URL is a fresh load and lands back at the top.
63
+
57
64
  ## The loop that catches real regressions
58
65
 
59
66
  1. Snap the dev URL across `--matrix laptop-768,android-65,1080p-24`, plus a
@@ -80,6 +87,9 @@ happened on the matrix snaps.
80
87
  not colorimetry of one specific panel.
81
88
  - `diff` is 1x-only in v1: dsf>1 presets and CSS viewports over 2048px exit
82
89
  with an error. Its findings are informational — apply your own thresholds.
90
+ - `diff` on an animating page compares two different frames. Check `settled`
91
+ in its output: when false the band deltas are frame-to-frame noise and
92
+ `findings` says so rather than interpreting them. Snap that page instead.
83
93
  - `diff` cannot say "the hairline vanished": a 0.5px hairline renders one
84
94
  device row at 1x *and* 2x. It reports ink deltas and row ratios; vanishing
85
95
  is judged by reading the PNG.