getobsrv 0.9.0 → 0.11.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
@@ -73,11 +73,16 @@ the advanced sliders. Enter your own monitor's diagonal in Settings once so the
73
73
  pane renders at true physical size. Drop a 2x/3x PNG or JPG export to check a design
74
74
  before it's built.
75
75
 
76
- The target pane opens at 1:1, which usually overflows the pane: pan with a
77
- middle-button drag, Option+drag or Option+wheel, or switch the toolbar's
78
- `1:1 / Fit` control to a fit-to-pane overview (smoothly minified, so not
79
- pixel-exact — the footer says so) and click anywhere in it to jump back to 1:1
80
- at that spot.
76
+ The target pane opens in **Fit**, a fit-to-pane view of the whole render. Fit
77
+ never enlarges past 1:1, so a render that already fits is shown at true size;
78
+ a larger one is smoothly minified to fit, which is not pixel-exact — the footer
79
+ says so, and names the magnification. Fit is fully interactive: clicks, drags,
80
+ the wheel and the keyboard all reach the page.
81
+
82
+ Switch the toolbar's `1:1 / Fit` control to **1:1** for true magnification,
83
+ which usually overflows the pane: pan with a middle-button drag, Option+drag or
84
+ Option+wheel. From fit, **Option+click** jumps straight to 1:1 with the clicked
85
+ spot centred (plain clicks belong to the page).
81
86
 
82
87
  The `Both / Target` control beside it hides the native pane so the target render
83
88
  takes the whole window — useful for a small mobile preset that would otherwise
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-CbJwfugi.js");
6
+ const targetSource = require("./targetSource-nbfc-Fe6.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);
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-CbJwfugi.js");
7
+ const targetSource = require("./targetSource-nbfc-Fe6.js");
8
8
  const node_http = require("node:http");
9
9
  const node_url = require("node:url");
10
10
  const IPC = {
@@ -41,7 +41,10 @@ const IPC = {
41
41
  getUpdate: "obsrv:get-update",
42
42
  checkUpdate: "obsrv:check-update",
43
43
  openRelease: "obsrv:open-release",
44
- updateStatus: "obsrv:update-status"
44
+ updateStatus: "obsrv:update-status",
45
+ getHistory: "obsrv:get-history",
46
+ clearHistory: "obsrv:clear-history",
47
+ historyChanged: "obsrv:history-changed"
45
48
  };
46
49
  function attachFrameBus(target, win) {
47
50
  let ready = false;
@@ -79,9 +82,9 @@ const MAX_SCROLL_SELECTOR = 512;
79
82
  const MAX_RECT = 16384;
80
83
  const MAX_SCROLL_WARNINGS = 4;
81
84
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
82
- const isRecord$1 = (v) => typeof v === "object" && v !== null;
85
+ const isRecord$2 = (v) => typeof v === "object" && v !== null;
83
86
  function parseRect(raw) {
84
- if (!isRecord$1(raw)) return null;
87
+ if (!isRecord$2(raw)) return null;
85
88
  const { x, y, width, height } = raw;
86
89
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) return null;
87
90
  const r = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height) };
@@ -103,7 +106,7 @@ function parseModifiers(raw) {
103
106
  return raw.filter((m) => typeof m === "string" && MODIFIERS.has(m));
104
107
  }
105
108
  function parseInputEvent(raw) {
106
- if (!isRecord$1(raw)) return null;
109
+ if (!isRecord$2(raw)) return null;
107
110
  const modifiers = parseModifiers(raw.modifiers);
108
111
  switch (raw.type) {
109
112
  case "mouseDown":
@@ -136,7 +139,7 @@ function parseDeviceScaleFactor(raw) {
136
139
  return raw;
137
140
  }
138
141
  function parseSettings(raw) {
139
- if (!isRecord$1(raw)) return null;
142
+ if (!isRecord$2(raw)) return null;
140
143
  const { hostDiagonalInches, hostNits } = raw;
141
144
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
142
145
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
@@ -146,14 +149,18 @@ function parseSettings(raw) {
146
149
  if (typeof updateCheck !== "boolean") return null;
147
150
  const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
148
151
  if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0) return null;
149
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
152
+ const recordHistory = raw.recordHistory ?? true;
153
+ if (typeof recordHistory !== "boolean") return null;
154
+ const split = raw.split ?? targetSource.DEFAULT_SETTINGS.split;
155
+ if (!isFiniteNumber(split) || split < targetSource.SPLIT_MIN || split > targetSource.SPLIT_MAX) return null;
156
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
150
157
  }
151
158
  function parseMode(raw) {
152
159
  return raw === "url" || raw === "image" ? raw : null;
153
160
  }
154
161
  const MAX_UI_ID = 64;
155
162
  function parseUiState(raw) {
156
- if (!isRecord$1(raw)) return null;
163
+ if (!isRecord$2(raw)) return null;
157
164
  const { presetId, profileId, viewMode, mode } = raw;
158
165
  if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
159
166
  if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
@@ -172,7 +179,7 @@ function parseUiState(raw) {
172
179
  };
173
180
  }
174
181
  function parseScrollPos(raw) {
175
- if (!isRecord$1(raw)) return null;
182
+ if (!isRecord$2(raw)) return null;
176
183
  const { x, y } = raw;
177
184
  if (!isFiniteNumber(x) || !isFiniteNumber(y) || x < 0 || y < 0) return null;
178
185
  return { x, y };
@@ -189,7 +196,7 @@ function parseScrollRequest(raw) {
189
196
  return { ...pos, selector: trimmed };
190
197
  }
191
198
  function parseScrollReport(raw) {
192
- if (!isRecord$1(raw)) return null;
199
+ if (!isRecord$2(raw)) return null;
193
200
  const { id, x, y, scroller } = raw;
194
201
  if (!isFiniteNumber(id)) return null;
195
202
  if (!isFiniteNumber(x) || !isFiniteNumber(y)) return null;
@@ -222,7 +229,7 @@ const CONTROL_COMMANDS = [
222
229
  function isControlCommand(v) {
223
230
  return typeof v === "string" && CONTROL_COMMANDS.includes(v);
224
231
  }
225
- const isRecord = (v) => typeof v === "object" && v !== null;
232
+ const isRecord$1 = (v) => typeof v === "object" && v !== null;
226
233
  function tokenEqual(expected, provided) {
227
234
  if (typeof provided !== "string") return false;
228
235
  const a = node_crypto.createHash("sha256").update(expected).digest();
@@ -258,7 +265,7 @@ function pixelExactApplyError(v) {
258
265
  }
259
266
  function parseClick(raw, viewport) {
260
267
  const shape = "click payload must be { x, y, button? } with finite CSS-pixel coordinates";
261
- if (!isRecord(raw)) return shape;
268
+ if (!isRecord$1(raw)) return shape;
262
269
  const { x, y } = raw;
263
270
  if (typeof x !== "number" || !Number.isFinite(x) || typeof y !== "number" || !Number.isFinite(y)) return shape;
264
271
  if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) {
@@ -285,8 +292,71 @@ function parseHighlight(raw) {
285
292
  durationMs: Math.min(Math.max(Math.round(d), HIGHLIGHT_DURATION_MIN_MS), HIGHLIGHT_DURATION_MAX_MS)
286
293
  };
287
294
  }
295
+ const HISTORY_MAX = 500;
296
+ const MAX_URL_LENGTH = 2048;
297
+ const isRecord = (v) => typeof v === "object" && v !== null;
298
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
299
+ const isStamp$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
300
+ function isStorableUrl(v) {
301
+ if (typeof v !== "string" || v === "" || v.length > MAX_URL_LENGTH) return false;
302
+ try {
303
+ return targetSource.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+ function byRank(a, b) {
309
+ if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
310
+ if (a.visits !== b.visits) return b.visits - a.visits;
311
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
312
+ }
313
+ function recordVisit(entries, url, now) {
314
+ if (!isStorableUrl(url) || !isStamp$1(now)) return entries;
315
+ const previous = entries.find((e) => e.url === url);
316
+ const next = {
317
+ url,
318
+ visits: previous ? previous.visits + 1 : 1,
319
+ lastVisit: now
320
+ };
321
+ return [next, ...entries.filter((e) => e.url !== url)].sort(byRank).slice(0, HISTORY_MAX);
322
+ }
323
+ function loadHistory(file) {
324
+ try {
325
+ const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
326
+ if (!Array.isArray(raw)) return [];
327
+ const seen = /* @__PURE__ */ new Set();
328
+ const out = [];
329
+ for (const item of raw) {
330
+ if (!isRecord(item)) continue;
331
+ if (!isStorableUrl(item.url) || seen.has(item.url)) continue;
332
+ seen.add(item.url);
333
+ out.push({
334
+ url: item.url,
335
+ // These two do fall back: a row whose counter got mangled is still a
336
+ // page that was visited, and losing its rank beats losing the row.
337
+ visits: isCount(item.visits) ? item.visits : 1,
338
+ lastVisit: isStamp$1(item.lastVisit) ? item.lastVisit : 0
339
+ });
340
+ }
341
+ return out.sort(byRank).slice(0, HISTORY_MAX);
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+ function saveHistory(file, entries) {
347
+ if (!Array.isArray(entries)) throw new RangeError("history must be an array");
348
+ if (entries.length > HISTORY_MAX) throw new RangeError(`history must hold at most ${HISTORY_MAX} entries`);
349
+ for (const e of entries) {
350
+ if (!isRecord(e) || !isStorableUrl(e.url)) throw new RangeError("history entries need a loadable url");
351
+ if (!isCount(e.visits)) throw new RangeError("visits must be an integer >= 1");
352
+ if (!isStamp$1(e.lastVisit)) throw new RangeError("lastVisit must be a finite epoch ms >= 0");
353
+ }
354
+ node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
355
+ node_fs.writeFileSync(file, JSON.stringify(entries, null, 2));
356
+ }
288
357
  const isPositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
289
358
  const isStamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
359
+ const isRatio = (v) => typeof v === "number" && Number.isFinite(v) && v >= targetSource.SPLIT_MIN && v <= targetSource.SPLIT_MAX;
290
360
  function loadSettings(file) {
291
361
  try {
292
362
  const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
@@ -299,7 +369,16 @@ function loadSettings(file) {
299
369
  // The opposite default: only a literal false turns the update check off,
300
370
  // so a file from before this feature keeps it on.
301
371
  updateCheck: raw.updateCheck !== false,
302
- lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0
372
+ lastUpdateCheck: isStamp(raw.lastUpdateCheck) ? raw.lastUpdateCheck : 0,
373
+ // Same default as `updateCheck`, for the same reason: a file written
374
+ // before this feature existed has no key, and history is on by default.
375
+ recordHistory: raw.recordHistory !== false,
376
+ // A ratio outside 0.1–0.9 is treated as absent, not rejected: unlike a
377
+ // nits or diagonal figure it is a layout preference, and a file that
378
+ // was hand-edited to 0.97 means "I want the target tiny", not "refuse
379
+ // to start". Falling back to an even split loses one preference; the
380
+ // panes stay usable, which is the point of the band.
381
+ split: isRatio(raw.split) ? raw.split : targetSource.DEFAULT_SETTINGS.split
303
382
  };
304
383
  } catch {
305
384
  return { ...targetSource.DEFAULT_SETTINGS };
@@ -310,6 +389,8 @@ function saveSettings(file, s) {
310
389
  if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
311
390
  if (typeof s.updateCheck !== "boolean") throw new RangeError("updateCheck must be a boolean");
312
391
  if (!isStamp(s.lastUpdateCheck)) throw new RangeError("lastUpdateCheck must be a finite epoch ms >= 0");
392
+ if (typeof s.recordHistory !== "boolean") throw new RangeError("recordHistory must be a boolean");
393
+ if (!isRatio(s.split)) throw new RangeError(`split must be a finite ratio in ${targetSource.SPLIT_MIN}..${targetSource.SPLIT_MAX}`);
313
394
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
314
395
  node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
315
396
  }
@@ -792,7 +873,7 @@ function registerIpc(ctx) {
792
873
  settings = s;
793
874
  if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
794
875
  });
795
- const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", panes: "both", mode: "url" };
876
+ const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "fit", panes: "both", mode: "url" };
796
877
  let targetBounds = null;
797
878
  let canvasBounds = null;
798
879
  const MAX_PENDING_APPLIES = 32;
@@ -904,6 +985,35 @@ function registerIpc(ctx) {
904
985
  await electron.shell.openExternal(releaseUrl);
905
986
  return true;
906
987
  });
988
+ const historyFile = node_path.join(electron.app.getPath("userData"), "history.json");
989
+ let history = loadHistory(historyFile);
990
+ const publishHistory = () => {
991
+ if (!win.isDestroyed()) win.webContents.send(IPC.historyChanged, history);
992
+ };
993
+ const commitHistory = (next) => {
994
+ try {
995
+ saveHistory(historyFile, next);
996
+ } catch (e) {
997
+ console.warn("obsrv: could not write history.json", e);
998
+ return;
999
+ }
1000
+ history = next;
1001
+ publishHistory();
1002
+ };
1003
+ native.webContents.on("did-navigate", (_e, url) => {
1004
+ if (!settings.recordHistory) return;
1005
+ const next = recordVisit(history, url, Date.now());
1006
+ if (next === history) return;
1007
+ commitHistory(next);
1008
+ });
1009
+ electron.ipcMain.handle(IPC.getHistory, (e) => {
1010
+ assertRenderer(e);
1011
+ return history;
1012
+ });
1013
+ electron.ipcMain.handle(IPC.clearHistory, (e) => {
1014
+ assertRenderer(e);
1015
+ commitHistory([]);
1016
+ });
907
1017
  const bootCheckAllowed = process.env.OBSRV_TEST !== "1" || process.env.OBSRV_RELEASES_API !== void 0;
908
1018
  if (bootCheckAllowed && settings.updateCheck && isCheckDue(settings.lastUpdateCheck, Date.now())) {
909
1019
  void runUpdateCheck();
@@ -3,12 +3,16 @@ 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 SPLIT_MIN = 0.1;
7
+ const SPLIT_MAX = 0.9;
6
8
  const DEFAULT_SETTINGS = {
7
9
  hostDiagonalInches: 27,
8
10
  hostNits: 500,
9
11
  agentControl: false,
10
12
  updateCheck: true,
11
- lastUpdateCheck: 0
13
+ lastUpdateCheck: 0,
14
+ recordHistory: true,
15
+ split: 0.5
12
16
  };
13
17
  const SCREEN_PRESETS = [
14
18
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -365,10 +369,13 @@ class TargetSource extends node_events.EventEmitter {
365
369
  if (!this.win.isDestroyed()) this.win.destroy();
366
370
  }
367
371
  }
372
+ exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
368
373
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
369
374
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
370
375
  exports.PANEL_PROFILES = PANEL_PROFILES;
371
376
  exports.SCREEN_PRESETS = SCREEN_PRESETS;
377
+ exports.SPLIT_MAX = SPLIT_MAX;
378
+ exports.SPLIT_MIN = SPLIT_MIN;
372
379
  exports.TargetSource = TargetSource;
373
380
  exports.classifyFileNavigation = classifyFileNavigation;
374
381
  exports.findPreset = findPreset;
@@ -31,7 +31,10 @@ const IPC = {
31
31
  getUpdate: "obsrv:get-update",
32
32
  checkUpdate: "obsrv:check-update",
33
33
  openRelease: "obsrv:open-release",
34
- updateStatus: "obsrv:update-status"
34
+ updateStatus: "obsrv:update-status",
35
+ getHistory: "obsrv:get-history",
36
+ clearHistory: "obsrv:clear-history",
37
+ historyChanged: "obsrv:history-changed"
35
38
  };
36
39
  function subscribe(channel, cb) {
37
40
  const listener = (_e, v) => cb(v);
@@ -112,6 +115,9 @@ const api = {
112
115
  getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
113
116
  checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
114
117
  openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
115
- onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
118
+ onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
119
+ getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
120
+ clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
121
+ onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb)
116
122
  };
117
123
  electron.contextBridge.exposeInMainWorld("obsrv", api);
@@ -129,7 +129,43 @@
129
129
  :root[data-surround='grey50'] { --surround: #808080; }
130
130
 
131
131
  html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color: var(--text-0); }
132
- .app { height: 100%; display: flex; flex-direction: column; }
132
+ /* `--split` (the native pane's share), `--pane-min` and `--seam` live here
133
+ rather than on `.panes` because the chrome needs them too — see
134
+ `--native-right` below. `App` writes the first two inline; the fallbacks
135
+ keep the layout sane if either is ever missing. */
136
+ .app {
137
+ height: 100%;
138
+ display: flex;
139
+ flex-direction: column;
140
+ --split: 0.5;
141
+ --pane-min: 240px;
142
+ --seam: 1px;
143
+ --drawer-w: 0px;
144
+ }
145
+
146
+ /* The drawer's whole box. Declared here so the panes row's width can be
147
+ derived from the window's without measuring either; `.drawer` sizes itself
148
+ from the same value, so the two cannot drift. */
149
+ .app:has(.drawer) { --drawer-w: 309px; }
150
+
151
+ /* Where the native pane's right edge falls, in window coordinates: the very
152
+ clamp `.panes > .pane:first-child` is laid out with, applied to the width
153
+ the row gets (the window, less whatever a drawer takes). A style rule, not
154
+ a measurement — the split is already a custom property, so nothing here
155
+ observes the layout it is describing.
156
+ Solo target has no native view, so the clamp collapses to zero. */
157
+ .app[data-panes='both'] {
158
+ --native-right: round(
159
+ down,
160
+ clamp(
161
+ var(--pane-min),
162
+ calc((100vw - var(--drawer-w) - var(--seam)) * var(--split)),
163
+ calc(100vw - var(--drawer-w) - var(--seam) - var(--pane-min))
164
+ ),
165
+ 1px
166
+ );
167
+ }
168
+ .app[data-panes='target'] { --native-right: 0px; }
133
169
  .muted { color: var(--text-1); }
134
170
  .warn { color: var(--warn); }
135
171
  .chrome .warn { white-space: nowrap; }
@@ -148,6 +184,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
148
184
  border-bottom: 1px solid var(--line);
149
185
  }
150
186
  .chrome-browse { height: 44px; background: var(--chrome-1); }
187
+ /* The containing block the URL-history list falls back to. Its padding box
188
+ starts at the window's left edge (the row's only border is at the bottom),
189
+ which is the frame `--native-right` is expressed in. */
190
+ .chrome-browse { position: relative; }
151
191
  .chrome-screen { height: 38px; background: var(--chrome-0); }
152
192
  .chrome-spacer { flex: 1 1 auto; }
153
193
 
@@ -168,7 +208,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
168
208
  cursor: pointer;
169
209
  }
170
210
 
171
- .url-form { flex: 1 1 auto; min-width: 160px; }
211
+ /* Static in Both, so the history list below resolves against `.chrome-browse`
212
+ and can be placed in window coordinates. In solo target there is no native
213
+ view to dodge and the list belongs under the field, so the form becomes the
214
+ containing block instead. */
215
+ .url-form { position: static; flex: 1 1 auto; min-width: 160px; }
216
+ .app[data-panes='target'] .url-form { position: relative; }
172
217
  .url-form input {
173
218
  width: 100%;
174
219
  height: 30px;
@@ -182,6 +227,61 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
182
227
  }
183
228
  .url-form input[readonly] { color: var(--text-1); }
184
229
 
230
+ /* Visited-URL type-ahead. Its left edge is clamped to the native pane's right
231
+ edge because that pane is an OS-level WebContentsView painting over
232
+ anything the renderer draws beneath it: a list anchored to the field, which
233
+ starts near x=120, would be entirely invisible. The cost is cosmetic — with
234
+ a wide native pane the list sits offset from the field it belongs to — and
235
+ it is the same constraint that pushed the overflow menu rightward.
236
+ `right` bounds it against the window so a very wide split cannot push it
237
+ off the edge; `max-width` keeps it from spanning a whole empty pane. */
238
+ .url-history {
239
+ position: absolute;
240
+ top: calc(100% + 4px);
241
+ left: var(--native-right, 0px);
242
+ right: 10px;
243
+ max-width: 560px;
244
+ z-index: 10;
245
+ margin: 0;
246
+ padding: 4px 0;
247
+ list-style: none;
248
+ font-size: 11.5px;
249
+ background: var(--chrome-2);
250
+ border: 1px solid var(--line);
251
+ border-radius: 6px;
252
+ }
253
+ /* Solo target: the list is the field's own dropdown, so it tracks its width. */
254
+ .app[data-panes='target'] .url-history { left: 0; right: 0; max-width: none; }
255
+ .url-history-row {
256
+ display: flex;
257
+ align-items: baseline;
258
+ gap: 10px;
259
+ height: 26px;
260
+ padding: 0 11px;
261
+ cursor: pointer;
262
+ }
263
+ /* The chrome's one selected idiom, shared with `.menu-row[aria-pressed]`. */
264
+ .url-history-row.active { background: var(--chrome-3); box-shadow: inset 2px 0 0 var(--text-0); }
265
+ /* Truncated at the *start*, not the end. What distinguishes one of these
266
+ addresses from another is the tail — the port, the path, the filename —
267
+ and right-truncating a list of localhost URLs or file paths yields six
268
+ identical rows, which is what the first screenshot of this list showed.
269
+ `direction: rtl` puts the ellipsis on the leading edge; the `bdi` around
270
+ the URL keeps the characters themselves in left-to-right order. */
271
+ .url-history-url {
272
+ flex: 1 1 auto;
273
+ min-width: 0;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ direction: rtl;
278
+ /* The box is right-to-left only to move the ellipsis; a URL short enough to
279
+ fit must still start at the left edge like every other row. */
280
+ text-align: left;
281
+ font-family: var(--mono);
282
+ }
283
+ .url-history-age { flex: 0 0 auto; color: var(--text-1); font-size: 10px; }
284
+
185
285
  .status-cluster { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; font-size: 10px; }
186
286
  .badge-error {
187
287
  color: var(--error);
@@ -335,13 +435,71 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
335
435
 
336
436
  .panes { flex: 1 1 auto; display: flex; min-height: 0; }
337
437
  .pane {
338
- flex: 1 1 50%;
438
+ flex: 1 1 0;
339
439
  min-width: 0;
340
440
  display: flex;
341
441
  flex-direction: column;
342
442
  background: var(--surround);
343
443
  }
344
- .target-pane { border-left: 1px solid var(--line); }
444
+ /* The native (or image) pane takes its share of everything the seam leaves;
445
+ the target pane grows into the rest, so the 1px divider comes out of the
446
+ row rather than out of one pane's half and skewing the ratio.
447
+
448
+ The `clamp()` is the *stored* ratio's floor, and the reason it lives in CSS
449
+ rather than in the drag: a ratio saved on a wide monitor and restored on a
450
+ narrow window is applied as close as 240px a side allows, without being
451
+ written back — grow the window and the preference returns on its own, with
452
+ no resize plumbing to keep in step. The drag enforces the same floor in JS,
453
+ where it also decides what to persist. */
454
+ .app[data-panes='both'] .panes > .pane:first-child {
455
+ flex-grow: 0;
456
+ flex-shrink: 0;
457
+ /* Rounded to whole pixels, and this is load-bearing. `TargetCanvas`
458
+ measures its pane with `clientWidth`, which *rounds*: a 1129.5px pane
459
+ reads as 1130, fit sizes the canvas to 1130, and the render then
460
+ overflows its own pane by half a pixel — which quietly turns off the
461
+ `safe center` centring and leaves the pane scrollable. Whole-pixel panes
462
+ keep the measurement honest. (The old `flex: 1 1 50%` hit the same thing
463
+ on an odd-width window; taking the seam out of the row would have made
464
+ it every window.) */
465
+ flex-basis: round(
466
+ down,
467
+ clamp(
468
+ var(--pane-min),
469
+ calc((100% - var(--seam)) * var(--split)),
470
+ calc(100% - var(--seam) - var(--pane-min))
471
+ ),
472
+ 1px
473
+ );
474
+ }
475
+
476
+ /* The seam. A flex item exactly one pixel wide — the hairline the target pane
477
+ used to draw as its own `border-left` — with the grab area straddling it as
478
+ an overflowing pseudo-element, so the handle is 6px to the pointer and 1px
479
+ to the eye. The style spec forbids decorating anything that touches a pane,
480
+ and a resize handle drawn as furniture would be decoration. */
481
+ .pane-divider {
482
+ flex: 0 0 var(--seam);
483
+ /* Positioned *and* raised: `.target-wrap` next door is positioned too and
484
+ comes later in the DOM, so without a `z-index` the target pane's canvas
485
+ paints over the half of the grab area that overhangs it and swallows the
486
+ press. */
487
+ position: relative;
488
+ z-index: 1;
489
+ background: var(--line);
490
+ cursor: col-resize;
491
+ /* Or the drag scrolls the pane instead of moving the seam. */
492
+ touch-action: none;
493
+ }
494
+ .pane-divider::before {
495
+ content: '';
496
+ position: absolute;
497
+ inset: 0 -2.5px;
498
+ }
499
+ .pane-divider:hover, .pane-divider:focus-visible { background: var(--text-1); }
500
+ /* The hairline is the affordance; a ring around a 1px element would be a box
501
+ floating on the seam. Same reasoning as `.target-canvas:focus`. */
502
+ .pane-divider:focus-visible { outline: none; }
345
503
  /* Square corners, no shadow, no blur: a rounded or blurred pane edge would
346
504
  destroy the very corner pixels the user opened this app to inspect. */
347
505
  .pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
@@ -353,7 +511,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
353
511
  on the very pixels under inspection. Keyboard focus in the target pane is
354
512
  shown on the pane's own hairlines instead, which touch no page pixel. */
355
513
  .target-canvas:focus { outline: none; }
356
- .target-pane:has(.target-canvas:focus-visible) { border-left-color: var(--text-1); }
514
+ /* The seam moved out of `.target-pane`'s border and into its own element, so
515
+ the focus signal reaches it from the row both panes share — `:has` on the
516
+ ancestor, since the divider is the canvas's *earlier* sibling's sibling and
517
+ no combinator walks backwards. The footer rule is unchanged, and carries
518
+ the whole signal on its own in solo target, where there is no seam. */
519
+ .panes:has(.target-canvas:focus-visible) .pane-divider { background: var(--text-1); }
357
520
  .target-pane:has(.target-canvas:focus-visible) .pane-footer { border-top-color: var(--text-1); }
358
521
 
359
522
  /* Signature: each pane always states what it is showing, so a screenshot of
@@ -406,9 +569,6 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
406
569
  .surround-control button[aria-pressed='true'] { box-shadow: inset 0 0 0 2px var(--text-0); }
407
570
  .surround-swatch { display: block; width: 12px; height: 12px; margin: 0 auto; }
408
571
 
409
- /* Nothing sits to the target pane's left in solo view, so the divider goes. */
410
- .panes[data-panes='target'] .target-pane { border-left: 0; }
411
-
412
572
  /* Numbers never jitter as a slider moves. */
413
573
  .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
414
574
 
@@ -431,7 +591,11 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
431
591
  .body { flex: 1 1 auto; display: flex; min-height: 0; }
432
592
  .panes { min-width: 0; }
433
593
  .drawer {
434
- flex: 0 0 280px;
594
+ /* Border-box against `--drawer-w`: 309 - 28 padding - 1 border = the 280px
595
+ of content this has always had, now stated once for both this and the
596
+ URL-history clamp. */
597
+ box-sizing: border-box;
598
+ flex: 0 0 var(--drawer-w);
435
599
  overflow: auto;
436
600
  padding: 12px 14px;
437
601
  background: var(--chrome-1);
@@ -551,6 +715,27 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
551
715
  `left: 0` holds while a wide canvas is scrolled horizontally. The fill is a
552
716
  neutral grey: only the rule under it is red, so the one chromatic thing on
553
717
  screen is the error itself. */
718
+ /* At 1:1 the render usually overflows and the pane is a scroll box, so it
719
+ starts at the top-left and you pan around it. When it is *smaller* than the
720
+ pane — a small preset, fit mode, or solo target on a wide window — hugging
721
+ the corner leaves it stranded against the chrome with dead field to the
722
+ right. Centre it instead.
723
+
724
+ `safe` is what lets one rule serve both cases: it falls back to start
725
+ alignment the moment the content overflows, so the top-left of an oversized
726
+ render stays reachable by scrolling. Plain `center` would push it out of
727
+ reach. The image pane already centres this way. */
728
+ .target-pane .pane-body {
729
+ display: flex;
730
+ flex-direction: column;
731
+ align-items: safe center;
732
+ justify-content: safe center;
733
+ }
734
+ /* The stall notice spans the pane, not the canvas: it is sticky at `left: 0`
735
+ and must stay pane-width while a wide canvas scrolls under it, so it opts
736
+ out of the centring its sibling wants. */
737
+ .target-pane .pane-body > .stall { align-self: stretch; }
738
+
554
739
  /* position: relative anchors the agent highlight to the canvas origin. */
555
740
  .target-wrap { min-width: min-content; position: relative; }
556
741
 
@@ -598,8 +783,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
598
783
  and being (0,1,1) and later in the file they beat `.segmented button` and
599
784
  split the two groups apart. Nothing about 1:1/Fit is control-specific. */
600
785
 
601
- /* The overview invites the jump-to-1:1 click; the pan chords (middle drag,
602
- Option+drag) show the grab affordances. Cursors only no overlay may sit
786
+ /* Option is what a view gesture needs, so Option is what the cursor answers:
787
+ the jump-to-1:1 zoom in fit, the grab affordances for the pan chords
788
+ (middle drag, Option+drag) at 1:1. Unmodified, both views are interactive
789
+ and the page's own cursors show through. Cursors only — no overlay may sit
603
790
  on the pixels under inspection. */
604
791
  .target-canvas.fit { cursor: zoom-in; }
605
792
  .target-canvas.pan-ready { cursor: grab; }
@@ -641,7 +828,8 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
641
828
  cursor: pointer;
642
829
  font: inherit;
643
830
  }
644
- .check-now {
831
+ .check-now,
832
+ .clear-history {
645
833
  width: 100%;
646
834
  height: 24px;
647
835
  margin-top: 8px;
@@ -651,3 +839,5 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
651
839
  border-radius: 4px;
652
840
  cursor: pointer;
653
841
  }
842
+ /* Nothing to erase: the button says so rather than pretending to act. */
843
+ .clear-history:disabled { color: var(--text-1); cursor: default; }
@@ -12688,12 +12688,16 @@ const createImpl = (createState) => {
12688
12688
  };
12689
12689
  const create = ((createState) => createImpl);
12690
12690
  const MAX_VIEWPORT = 4096;
12691
+ const SPLIT_MIN = 0.1;
12692
+ const SPLIT_MAX = 0.9;
12691
12693
  const DEFAULT_SETTINGS = {
12692
12694
  hostDiagonalInches: 27,
12693
12695
  hostNits: 500,
12694
12696
  agentControl: false,
12695
12697
  updateCheck: true,
12696
- lastUpdateCheck: 0
12698
+ lastUpdateCheck: 0,
12699
+ recordHistory: true,
12700
+ split: 0.5
12697
12701
  };
12698
12702
  const SCREEN_PRESETS = [
12699
12703
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -12780,9 +12784,15 @@ const useStore = create()((set) => ({
12780
12784
  error: null,
12781
12785
  toast: null,
12782
12786
  update: null,
12787
+ history: [],
12783
12788
  image: null,
12784
12789
  surround: "graphite",
12785
- viewMode: "1:1",
12790
+ // Fit, not 1:1: fit never enlarges past 1:1, so a render that already fits
12791
+ // its pane opens at true magnification anyway, while one that does not is
12792
+ // shown whole instead of as its top-left corner. Fit is interactive, so
12793
+ // this costs nothing — and the footer names the actual magnification
12794
+ // whenever fit is minifying.
12795
+ viewMode: "fit",
12786
12796
  panes: "both",
12787
12797
  fitScale: null,
12788
12798
  agentPan: null,
@@ -12807,6 +12817,7 @@ const useStore = create()((set) => ({
12807
12817
  // duplicate must not replace the object and re-render everything twice.
12808
12818
  setError: (error) => set((s) => sameError(s.error, error) ? {} : { error }),
12809
12819
  setUpdate: (update) => set({ update }),
12820
+ setHistory: (history) => set({ history }),
12810
12821
  setToast: (toast) => set({ toast }),
12811
12822
  setImage: (image) => set({ image }),
12812
12823
  setSurround: (surround) => set({ surround }),
@@ -13110,6 +13121,98 @@ function NativeSlot() {
13110
13121
  /* @__PURE__ */ jsxRuntimeExports.jsx(NativeFooter, { width: size.width, height: size.height })
13111
13122
  ] });
13112
13123
  }
13124
+ const MIN_PANE_PX = 240;
13125
+ const KEY_STEP = 0.02;
13126
+ const KEY_STEP_COARSE = 0.1;
13127
+ function PaneDivider() {
13128
+ const split = useStore((s) => s.settings.split);
13129
+ const ref = reactExports.useRef(null);
13130
+ const drag = reactExports.useRef(null);
13131
+ const geometry = () => {
13132
+ const el = ref.current;
13133
+ const row = el?.parentElement;
13134
+ if (!el || !row) return { left: 0, usable: 1, min: 0.5, max: 0.5 };
13135
+ const r = row.getBoundingClientRect();
13136
+ const usable2 = Math.max(1, r.width - el.getBoundingClientRect().width);
13137
+ const floor = MIN_PANE_PX / usable2;
13138
+ return {
13139
+ left: r.left,
13140
+ usable: usable2,
13141
+ min: Math.min(Math.max(SPLIT_MIN, floor), 0.5),
13142
+ max: Math.max(Math.min(SPLIT_MAX, 1 - floor), 0.5)
13143
+ };
13144
+ };
13145
+ const clamp = (ratio) => {
13146
+ const g = geometry();
13147
+ return Math.min(Math.max(ratio, g.min), g.max);
13148
+ };
13149
+ const preview = (ratio) => {
13150
+ const s = useStore.getState();
13151
+ const next = clamp(ratio);
13152
+ if (next === s.settings.split) return;
13153
+ s.setSettings({ ...s.settings, split: next });
13154
+ };
13155
+ const persist = (before) => {
13156
+ const now = useStore.getState().settings;
13157
+ if (now.split === before.split) return;
13158
+ window.obsrv.setSettings(now).catch(() => useStore.getState().setSettings(before));
13159
+ };
13160
+ const commit = (ratio) => {
13161
+ const before = useStore.getState().settings;
13162
+ preview(ratio);
13163
+ persist(before);
13164
+ };
13165
+ const onPointerDown = (e) => {
13166
+ if (e.button !== 0) return;
13167
+ e.preventDefault();
13168
+ const el = e.currentTarget;
13169
+ el.setPointerCapture(e.pointerId);
13170
+ drag.current = {
13171
+ before: useStore.getState().settings,
13172
+ grab: e.clientX - el.getBoundingClientRect().left
13173
+ };
13174
+ };
13175
+ const onPointerMove = (e) => {
13176
+ const d = drag.current;
13177
+ if (!d) return;
13178
+ const g = geometry();
13179
+ preview((e.clientX - d.grab - g.left) / g.usable);
13180
+ };
13181
+ const onPointerUp = (e) => {
13182
+ const d = drag.current;
13183
+ if (!d) return;
13184
+ drag.current = null;
13185
+ e.currentTarget.releasePointerCapture(e.pointerId);
13186
+ persist(d.before);
13187
+ };
13188
+ const onKeyDown = (e) => {
13189
+ const step = e.shiftKey ? KEY_STEP_COARSE : KEY_STEP;
13190
+ const delta = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
13191
+ if (!delta) return;
13192
+ e.preventDefault();
13193
+ commit(useStore.getState().settings.split + delta);
13194
+ };
13195
+ return /* @__PURE__ */ jsxRuntimeExports.jsx(
13196
+ "div",
13197
+ {
13198
+ ref,
13199
+ className: "pane-divider",
13200
+ role: "separator",
13201
+ "aria-orientation": "vertical",
13202
+ "aria-label": "Pane split",
13203
+ tabIndex: 0,
13204
+ "aria-valuenow": Math.round(split * 100),
13205
+ "aria-valuemin": Math.round(SPLIT_MIN * 100),
13206
+ "aria-valuemax": Math.round(SPLIT_MAX * 100),
13207
+ onPointerDown,
13208
+ onPointerMove,
13209
+ onPointerUp,
13210
+ onPointerCancel: onPointerUp,
13211
+ onDoubleClick: () => commit(0.5),
13212
+ onKeyDown
13213
+ }
13214
+ );
13215
+ }
13113
13216
  const CONTRAST_MAX = 3e3;
13114
13217
  const CUSTOM_PROFILE_ID = "custom";
13115
13218
  function profileToControls(p, hostNits) {
@@ -13303,6 +13406,7 @@ function SettingsPanel() {
13303
13406
  const host = useStore(useShallow((s) => s.host));
13304
13407
  const settings = useStore(useShallow((s) => s.settings));
13305
13408
  const update = useStore((s) => s.update);
13409
+ const history = useStore((s) => s.history);
13306
13410
  const custom = useStore(useShallow((s) => s.custom));
13307
13411
  const viewport = useStore(useShallow(selectViewport));
13308
13412
  const scale = useStore(selectScale);
@@ -13459,7 +13563,34 @@ function SettingsPanel() {
13459
13563
  /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Check for updates automatically" })
13460
13564
  ] }),
13461
13565
  /* @__PURE__ */ jsxRuntimeExports.jsx("button", { type: "button", className: "check-now", onClick: () => void window.obsrv.checkUpdate(), children: "Check now" }),
13462
- /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." })
13566
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "One unauthenticated request to GitHub, at most once a day. No identifiers are sent." }),
13567
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "History" }),
13568
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("label", { className: "control inline record-history-toggle", children: [
13569
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13570
+ "input",
13571
+ {
13572
+ type: "checkbox",
13573
+ checked: settings.recordHistory,
13574
+ onChange: (e) => commit({ ...settings, recordHistory: e.target.checked })
13575
+ }
13576
+ ),
13577
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { children: "Remember visited addresses" })
13578
+ ] }),
13579
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
13580
+ "button",
13581
+ {
13582
+ type: "button",
13583
+ className: "clear-history",
13584
+ disabled: history.length === 0,
13585
+ onClick: () => void window.obsrv.clearHistory(),
13586
+ children: "Clear history"
13587
+ }
13588
+ ),
13589
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "readout", children: [
13590
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "history-count num", children: history.length }),
13591
+ history.length === 1 ? " address remembered" : " addresses remembered"
13592
+ ] }),
13593
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Typed into the URL bar as suggestions and nowhere else. Turning this off stops recording and keeps what is stored; Clear erases it." })
13463
13594
  ] });
13464
13595
  }
13465
13596
  const VERT_SRC = `#version 300 es
@@ -14025,7 +14156,6 @@ function TargetCanvas({ onFatal, imageFrame }) {
14025
14156
  canvas.addEventListener("wheel", onWheel, { passive: false });
14026
14157
  const onWindowUp = (e) => {
14027
14158
  if (useStore.getState().mode !== "url") return;
14028
- if (useStore.getState().viewMode !== "1:1") return;
14029
14159
  if (panRef.current || e.button === 1) return;
14030
14160
  if (e.altKey && !forwardDrag.current) return;
14031
14161
  if (e.target === canvas) return;
@@ -14099,7 +14229,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14099
14229
  return body instanceof HTMLElement ? body : null;
14100
14230
  };
14101
14231
  const startPan = (e) => {
14102
- if (viewMode !== "1:1") return;
14232
+ if (fit) return;
14103
14233
  if (!(e.button === 1 || e.button === 0 && e.altKey)) return;
14104
14234
  const body = paneBody();
14105
14235
  if (!body) return;
@@ -14148,7 +14278,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14148
14278
  pendingJump.current = null;
14149
14279
  }, [viewMode]);
14150
14280
  const jumpTo1x = (e) => {
14151
- if (viewMode !== "fit") return;
14281
+ if (!fit || !e.altKey) return;
14152
14282
  const r = e.currentTarget.getBoundingClientRect();
14153
14283
  pendingJump.current = jumpScroll(
14154
14284
  e.clientX - r.left,
@@ -14189,7 +14319,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14189
14319
  }, [agentHighlight, clearAgentHighlight]);
14190
14320
  const send = (type) => (e) => {
14191
14321
  if (mode !== "url") return;
14192
- if (viewMode !== "1:1" || panRef.current || e.button === 1) return;
14322
+ if (panRef.current || e.button === 1) return;
14193
14323
  if (e.altKey && !forwardDrag.current) return;
14194
14324
  const out = mouseEvent(type, e, e.currentTarget.getBoundingClientRect(), scale * dsf / dpr);
14195
14325
  if (out) {
@@ -14220,7 +14350,7 @@ function TargetCanvas({ onFatal, imageFrame }) {
14220
14350
  "canvas",
14221
14351
  {
14222
14352
  ref: canvasRef,
14223
- className: `target-canvas${fit ? " fit" : panning ? " panning" : altHeld ? " pan-ready" : ""}`,
14353
+ className: `target-canvas${panning ? " panning" : altHeld ? fit ? " fit" : " pan-ready" : ""}`,
14224
14354
  tabIndex: 0,
14225
14355
  style: { width: `${cssW}px`, height: `${cssH}px` },
14226
14356
  onClick: jumpTo1x,
@@ -14232,12 +14362,12 @@ function TargetCanvas({ onFatal, imageFrame }) {
14232
14362
  onMouseUp: send("mouseUp"),
14233
14363
  onMouseMove: send("mouseMove"),
14234
14364
  onKeyDown: (e) => {
14235
- if (mode !== "url" || viewMode !== "1:1") return;
14365
+ if (mode !== "url") return;
14236
14366
  if (!e.metaKey && !e.ctrlKey) e.preventDefault();
14237
14367
  for (const ev of keyDownEvents(e)) window.obsrv.sendInput(ev);
14238
14368
  },
14239
14369
  onKeyUp: (e) => {
14240
- if (mode !== "url" || viewMode !== "1:1") return;
14370
+ if (mode !== "url") return;
14241
14371
  const ev = keyUpEvent(e);
14242
14372
  if (ev) window.obsrv.sendInput(ev);
14243
14373
  }
@@ -14275,6 +14405,16 @@ function Toast() {
14275
14405
  if (!toast) return null;
14276
14406
  return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "toast", role: "status", children: toast });
14277
14407
  }
14408
+ const HISTORY_SUGGESTIONS = 6;
14409
+ function byRank(a, b) {
14410
+ if (a.lastVisit !== b.lastVisit) return b.lastVisit - a.lastVisit;
14411
+ if (a.visits !== b.visits) return b.visits - a.visits;
14412
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
14413
+ }
14414
+ function matchHistory(entries, query, limit = HISTORY_SUGGESTIONS) {
14415
+ const needle = query.trim().toLowerCase();
14416
+ return entries.filter((e) => e.url.toLowerCase().includes(needle)).sort(byRank).slice(0, Math.max(0, limit));
14417
+ }
14278
14418
  /**
14279
14419
  * @license lucide-react v1.34.0 - ISC
14280
14420
  *
@@ -14627,8 +14767,18 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14627
14767
  const setPanes = useStore((s) => s.setPanes);
14628
14768
  const agentControl = useStore((s) => s.settings.agentControl);
14629
14769
  const setSettings = useStore((s) => s.setSettings);
14770
+ const history = useStore((s) => s.history);
14630
14771
  const inputRef = reactExports.useRef(null);
14631
14772
  const [draft, setDraft] = reactExports.useState(barText);
14773
+ const [open, setOpen] = reactExports.useState(false);
14774
+ const [highlight, setHighlight] = reactExports.useState(-1);
14775
+ const matches = reactExports.useMemo(() => open ? matchHistory(history, draft) : [], [open, history, draft]);
14776
+ const listOpen = open && matches.length > 0;
14777
+ const closeList = () => {
14778
+ setOpen(false);
14779
+ setHighlight(-1);
14780
+ };
14781
+ const picked = highlight >= 0 && highlight < matches.length ? matches[highlight] : null;
14632
14782
  const [agentActive, setAgentActive] = reactExports.useState(false);
14633
14783
  reactExports.useEffect(() => {
14634
14784
  let timer;
@@ -14661,14 +14811,50 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14661
14811
  });
14662
14812
  }, []);
14663
14813
  const readOnly = mode === "image";
14664
- const submit = async (e) => {
14665
- e.preventDefault();
14666
- if (readOnly || draft.trim() === "") return;
14814
+ reactExports.useEffect(() => {
14815
+ if (readOnly) closeList();
14816
+ }, [readOnly]);
14817
+ const go = async (url) => {
14818
+ closeList();
14667
14819
  setError(null);
14668
- const applied = await window.obsrv.navigate(draft);
14820
+ const applied = await window.obsrv.navigate(url);
14669
14821
  setUrl(applied);
14670
14822
  setDraft(applied);
14671
14823
  };
14824
+ const submit = (e) => {
14825
+ e.preventDefault();
14826
+ if (readOnly || draft.trim() === "") return;
14827
+ void go(draft);
14828
+ };
14829
+ const onKeyDown = (e) => {
14830
+ if (readOnly) return;
14831
+ if (e.key === "ArrowDown") {
14832
+ e.preventDefault();
14833
+ if (!open) {
14834
+ setOpen(true);
14835
+ setHighlight(0);
14836
+ return;
14837
+ }
14838
+ setHighlight((h) => h + 1 >= matches.length ? -1 : h + 1);
14839
+ return;
14840
+ }
14841
+ if (e.key === "ArrowUp" && listOpen) {
14842
+ e.preventDefault();
14843
+ setHighlight((h) => h <= -1 ? matches.length - 1 : h - 1);
14844
+ return;
14845
+ }
14846
+ if (e.key === "Enter" && picked) {
14847
+ e.preventDefault();
14848
+ void go(picked.url);
14849
+ return;
14850
+ }
14851
+ if (e.key === "Escape") {
14852
+ const dismissed = listOpen;
14853
+ closeList();
14854
+ if (dismissed) return;
14855
+ setDraft(barText);
14856
+ }
14857
+ };
14672
14858
  const presetLabel = SCREEN_PRESETS.find((p) => p.id === presetId)?.label ?? "Custom";
14673
14859
  const profileLabel = PANEL_PROFILES.find((p) => p.id === profileId)?.label ?? profileId;
14674
14860
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome", children: [
@@ -14706,20 +14892,49 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14706
14892
  children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "reload" })
14707
14893
  }
14708
14894
  ),
14709
- /* @__PURE__ */ jsxRuntimeExports.jsx("form", { className: "url-form", onSubmit: submit, children: /* @__PURE__ */ jsxRuntimeExports.jsx(
14710
- "input",
14711
- {
14712
- ref: inputRef,
14713
- value: draft,
14714
- readOnly,
14715
- spellCheck: false,
14716
- placeholder: "Enter a URL, or drop a PNG",
14717
- onChange: (e) => setDraft(e.target.value),
14718
- onKeyDown: (e) => {
14719
- if (e.key === "Escape") setDraft(barText);
14895
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("form", { className: "url-form", onSubmit: submit, children: [
14896
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
14897
+ "input",
14898
+ {
14899
+ ref: inputRef,
14900
+ value: draft,
14901
+ readOnly,
14902
+ spellCheck: false,
14903
+ placeholder: "Enter a URL, or drop a PNG",
14904
+ role: "combobox",
14905
+ "aria-expanded": listOpen,
14906
+ "aria-controls": "url-history",
14907
+ "aria-autocomplete": "list",
14908
+ "aria-activedescendant": picked ? `url-history-${highlight}` : void 0,
14909
+ onChange: (e) => {
14910
+ setDraft(e.target.value);
14911
+ setOpen(true);
14912
+ setHighlight(-1);
14913
+ },
14914
+ onKeyDown,
14915
+ onBlur: closeList
14720
14916
  }
14721
- }
14722
- ) }),
14917
+ ),
14918
+ listOpen && /* @__PURE__ */ jsxRuntimeExports.jsx("ul", { className: "url-history", id: "url-history", role: "listbox", "aria-label": "Visited addresses", children: matches.map((m, i) => /* @__PURE__ */ jsxRuntimeExports.jsxs(
14919
+ "li",
14920
+ {
14921
+ id: `url-history-${i}`,
14922
+ className: `url-history-row${i === highlight ? " active" : ""}`,
14923
+ role: "option",
14924
+ "aria-selected": i === highlight,
14925
+ onMouseDown: (e) => {
14926
+ e.preventDefault();
14927
+ void go(m.url);
14928
+ },
14929
+ onMouseEnter: () => setHighlight(i),
14930
+ children: [
14931
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-url", children: /* @__PURE__ */ jsxRuntimeExports.jsx("bdi", { children: m.url }) }),
14932
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "url-history-age", children: formatAge(m.lastVisit, Date.now()) })
14933
+ ]
14934
+ },
14935
+ m.url
14936
+ )) })
14937
+ ] }),
14723
14938
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "status-cluster", children: [
14724
14939
  mode === "image" && /* @__PURE__ */ jsxRuntimeExports.jsx(
14725
14940
  "button",
@@ -14886,6 +15101,7 @@ function App() {
14886
15101
  const setError = useStore((s) => s.setError);
14887
15102
  const setTargetLoading = useStore((s) => s.setTargetLoading);
14888
15103
  const setUpdate = useStore((s) => s.setUpdate);
15104
+ const setHistory = useStore((s) => s.setHistory);
14889
15105
  const setImageMeta = useStore((s) => s.setImage);
14890
15106
  const setMode = useStore((s) => s.setMode);
14891
15107
  const setToast = useStore((s) => s.setToast);
@@ -14897,27 +15113,34 @@ function App() {
14897
15113
  const profileId = useStore((s) => s.profileId);
14898
15114
  const viewMode = useStore((s) => s.viewMode);
14899
15115
  const panes = useStore((s) => s.panes);
15116
+ const split = useStore((s) => s.settings.split);
14900
15117
  reactExports.useEffect(() => {
14901
15118
  window.obsrv.getHostInfo().then(setHost, (e) => console.warn("obsrv: getHostInfo failed", e));
14902
15119
  window.obsrv.getSettings().then(setSettings, (e) => console.warn("obsrv: getSettings failed", e));
14903
15120
  window.obsrv.getUpdate().then(setUpdate, (e) => console.warn("obsrv: getUpdate failed", e));
15121
+ window.obsrv.getHistory().then(setHistory, (e) => console.warn("obsrv: getHistory failed", e));
14904
15122
  const offs = [
14905
15123
  window.obsrv.onHostChanged(setHost),
14906
15124
  // A committed navigation — back, forward, reload, a link — supersedes
14907
- // the last load failure. A *failed* load commits Chromium's error page
14908
- // first and reports its error after, so the badge still lands last.
15125
+ // the last load failure, and only a committed one: Chromium commits its
15126
+ // error page without emitting `did-navigate`, so a failed load reports
15127
+ // no URL at all (measured on Electron 43; a bad host, a refused
15128
+ // connection, a missing file and a Back onto an error page all fire
15129
+ // `did-fail-load` alone). The badge therefore lands last by having
15130
+ // nothing race it.
14909
15131
  window.obsrv.onUrlChanged((url) => {
14910
15132
  setError(null);
14911
15133
  setUrl(url);
14912
15134
  }),
14913
15135
  window.obsrv.onLoadError(setError),
14914
15136
  window.obsrv.onTargetLoading(setTargetLoading),
14915
- window.obsrv.onUpdateStatus(setUpdate)
15137
+ window.obsrv.onUpdateStatus(setUpdate),
15138
+ window.obsrv.onHistoryChanged(setHistory)
14916
15139
  ];
14917
15140
  return () => {
14918
15141
  for (const off of offs) off();
14919
15142
  };
14920
- }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate]);
15143
+ }, [setHost, setSettings, setUrl, setError, setTargetLoading, setUpdate, setHistory]);
14921
15144
  reactExports.useEffect(() => {
14922
15145
  void window.obsrv.setViewport(viewport.width, viewport.height, deviceScaleFactor);
14923
15146
  }, [viewport.width, viewport.height, deviceScaleFactor]);
@@ -15027,29 +15250,46 @@ function App() {
15027
15250
  };
15028
15251
  }, [mode, image]);
15029
15252
  if (fatal) return /* @__PURE__ */ jsxRuntimeExports.jsx(Fatal, { message: fatal });
15030
- return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "app", children: [
15031
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15032
- /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15033
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15034
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", "data-panes": panes, children: [
15035
- panes === "both" && (mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15036
- ImagePane,
15037
- {
15038
- src: image.objectUrl,
15039
- width: image.natural.width,
15040
- height: image.natural.height
15041
- }
15042
- ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {})),
15043
- /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15044
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15045
- /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15046
- ] })
15047
- ] }),
15048
- drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15049
- drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15050
- ] }),
15051
- /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15052
- ] });
15253
+ return (
15254
+ // The split and the panes mode are published here rather than on `.panes`
15255
+ // because the chrome needs them too: the URL bar's history dropdown is
15256
+ // clamped to the native pane's right edge, and a custom property set on
15257
+ // `.panes` reaches nothing above it. Everything below still inherits them.
15258
+ /* @__PURE__ */ jsxRuntimeExports.jsxs(
15259
+ "div",
15260
+ {
15261
+ className: "app",
15262
+ "data-panes": panes,
15263
+ style: { "--split": split, "--pane-min": `${MIN_PANE_PX}px` },
15264
+ children: [
15265
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toolbar, { drawer, onTogglePanel: toggle("panel"), onToggleSettings: toggle("settings") }),
15266
+ /* @__PURE__ */ jsxRuntimeExports.jsx(DropZone, { onImage }),
15267
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "body", children: [
15268
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "panes", children: [
15269
+ panes === "both" && /* @__PURE__ */ jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, { children: [
15270
+ mode === "image" && image ? /* @__PURE__ */ jsxRuntimeExports.jsx(
15271
+ ImagePane,
15272
+ {
15273
+ src: image.objectUrl,
15274
+ width: image.natural.width,
15275
+ height: image.natural.height
15276
+ }
15277
+ ) : /* @__PURE__ */ jsxRuntimeExports.jsx(NativeSlot, {}),
15278
+ /* @__PURE__ */ jsxRuntimeExports.jsx(PaneDivider, {})
15279
+ ] }),
15280
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "pane target-pane", ref: targetPaneRef, children: [
15281
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "pane-body", children: /* @__PURE__ */ jsxRuntimeExports.jsx(TargetCanvas, { onFatal: setFatal, imageFrame }) }),
15282
+ /* @__PURE__ */ jsxRuntimeExports.jsx(TargetFooter, {})
15283
+ ] })
15284
+ ] }),
15285
+ drawer === "panel" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(PanelControls, {}) }),
15286
+ drawer === "settings" && /* @__PURE__ */ jsxRuntimeExports.jsx("aside", { className: "drawer", children: /* @__PURE__ */ jsxRuntimeExports.jsx(SettingsPanel, {}) })
15287
+ ] }),
15288
+ /* @__PURE__ */ jsxRuntimeExports.jsx(Toast, {})
15289
+ ]
15290
+ }
15291
+ )
15292
+ );
15053
15293
  }
15054
15294
  function BrowserNotice() {
15055
15295
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "browser-notice", role: "note", children: [
@@ -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-CpeTWcl6.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-CasW01OP.css">
7
+ <script type="module" crossorigin src="./assets/index-BlbBg6lm.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-B61fbgqt.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isStamp = exports.isCount = exports.isRecord = exports.MAX_URL_LENGTH = exports.HISTORY_SUGGESTIONS = exports.HISTORY_MAX = void 0;
4
+ exports.isStorableUrl = isStorableUrl;
5
+ exports.byRank = byRank;
6
+ exports.recordVisit = recordVisit;
7
+ exports.matchHistory = matchHistory;
8
+ const url_1 = require("./url");
9
+ /**
10
+ * The file is read and written whole, so it needs a bound. 500 is far beyond
11
+ * what this app's usage produces — a handful of dev and staging addresses
12
+ * returned to over and over — and small enough to parse instantly.
13
+ */
14
+ exports.HISTORY_MAX = 500;
15
+ /** Rows the URL bar offers. Past six, a longer list is a worse tool than a better query. */
16
+ exports.HISTORY_SUGGESTIONS = 6;
17
+ /**
18
+ * The other half of the bound: 500 entries of unbounded length is not a
19
+ * bounded file. Far longer than any address this tool is pointed at, and
20
+ * short enough that the whole file stays trivial to parse.
21
+ */
22
+ exports.MAX_URL_LENGTH = 2048;
23
+ const isRecord = (v) => typeof v === 'object' && v !== null;
24
+ exports.isRecord = isRecord;
25
+ const isCount = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1;
26
+ exports.isCount = isCount;
27
+ const isStamp = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
28
+ exports.isStamp = isStamp;
29
+ /**
30
+ * Is this an address worth remembering? Only the schemes the app can load
31
+ * back (`shared/url.ts`), so `about:blank` — which the native pane commits on
32
+ * every launch — never becomes a suggestion, and a scheme the URL bar would
33
+ * refuse can never be offered by it.
34
+ */
35
+ function isStorableUrl(v) {
36
+ if (typeof v !== 'string' || v === '' || v.length > exports.MAX_URL_LENGTH)
37
+ return false;
38
+ try {
39
+ return url_1.ALLOWED_URL_SCHEMES.includes(new URL(v).protocol);
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ /**
46
+ * Most recent first, visit count breaking ties — so the address used
47
+ * constantly stays at the top without a stale favourite outranking what was
48
+ * on screen ten minutes ago. URL breaks the remaining tie only to keep the
49
+ * order stable across reloads of the same file.
50
+ */
51
+ function byRank(a, b) {
52
+ if (a.lastVisit !== b.lastVisit)
53
+ return b.lastVisit - a.lastVisit;
54
+ if (a.visits !== b.visits)
55
+ return b.visits - a.visits;
56
+ return a.url < b.url ? -1 : a.url > b.url ? 1 : 0;
57
+ }
58
+ /**
59
+ * The list after a committed navigation to `url`. Returns `entries`
60
+ * *unchanged* — the same reference — when the URL is not one we store, which
61
+ * is what lets the caller write the file only when something actually
62
+ * changed.
63
+ *
64
+ * Eviction is by rank, so the least recently visited entry is the one that
65
+ * goes; a busy day cannot push out the address visited an hour ago in favour
66
+ * of one visited last year.
67
+ */
68
+ function recordVisit(entries, url, now) {
69
+ if (!isStorableUrl(url) || !(0, exports.isStamp)(now))
70
+ return entries;
71
+ const previous = entries.find(e => e.url === url);
72
+ const next = {
73
+ url,
74
+ visits: previous ? previous.visits + 1 : 1,
75
+ lastVisit: now,
76
+ };
77
+ return [next, ...entries.filter(e => e.url !== url)].sort(byRank).slice(0, exports.HISTORY_MAX);
78
+ }
79
+ /**
80
+ * The rows to offer for what has been typed: case-insensitive substring
81
+ * against the whole URL, in rank order. An empty query matches everything,
82
+ * so pressing Down in an empty field offers the most recent addresses.
83
+ */
84
+ function matchHistory(entries, query, limit = exports.HISTORY_SUGGESTIONS) {
85
+ const needle = query.trim().toLowerCase();
86
+ return entries
87
+ .filter(e => e.url.toLowerCase().includes(needle))
88
+ .sort(byRank)
89
+ .slice(0, Math.max(0, limit));
90
+ }
@@ -10,6 +10,7 @@ exports.parseUiState = parseUiState;
10
10
  exports.parseScrollPos = parseScrollPos;
11
11
  exports.parseScrollRequest = parseScrollRequest;
12
12
  exports.parseScrollReport = parseScrollReport;
13
+ const presets_1 = require("./presets");
13
14
  const types_1 = require("./types");
14
15
  /**
15
16
  * Parsers for everything the renderer sends main over IPC. Each returns a
@@ -99,7 +100,7 @@ function parseDeviceScaleFactor(raw) {
99
100
  return raw;
100
101
  }
101
102
  /**
102
- * Copies exactly the five known keys; the numbers must be finite and
103
+ * Copies exactly the known keys; the numbers must be finite and
103
104
  * positive. A missing `agentControl` means false and a missing `updateCheck`
104
105
  * means true (the pre-feature wire shapes); any non-boolean value is refused,
105
106
  * never coerced.
@@ -121,7 +122,17 @@ function parseSettings(raw) {
121
122
  const lastUpdateCheck = raw.lastUpdateCheck ?? 0;
122
123
  if (!isFiniteNumber(lastUpdateCheck) || lastUpdateCheck < 0)
123
124
  return null;
124
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck };
125
+ const recordHistory = raw.recordHistory ?? true;
126
+ if (typeof recordHistory !== 'boolean')
127
+ return null;
128
+ // A missing `split` is the pre-feature wire shape and means the default.
129
+ // Out of band it is refused rather than clamped: `loadSettings` forgives a
130
+ // hand-edited file because it must, but the renderer clamps before it
131
+ // sends, so a bad ratio arriving here is a bug worth surfacing.
132
+ const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
133
+ if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
134
+ return null;
135
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
125
136
  }
126
137
  function parseMode(raw) {
127
138
  return raw === 'url' || raw === 'image' ? raw : null;
@@ -1,15 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.MAX_VIEWPORT = void 0;
3
+ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
4
4
  exports.findPreset = findPreset;
5
5
  exports.findProfile = findProfile;
6
6
  exports.MAX_VIEWPORT = 4096;
7
+ /** The loose sanity band for `Settings.split`; the clamp that matters is the
8
+ * drag's 240px-a-side floor, which only the renderer knows the pixels for. */
9
+ exports.SPLIT_MIN = 0.1;
10
+ exports.SPLIT_MAX = 0.9;
7
11
  exports.DEFAULT_SETTINGS = {
8
12
  hostDiagonalInches: 27,
9
13
  hostNits: 500,
10
14
  agentControl: false,
11
15
  updateCheck: true,
12
16
  lastUpdateCheck: 0,
17
+ recordHistory: true,
18
+ split: 0.5,
13
19
  };
14
20
  exports.SCREEN_PRESETS = [
15
21
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {