getobsrv 0.10.0 → 0.12.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.
@@ -3,12 +3,19 @@ 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;
8
+ const MAX_TABS_MIN = 2;
9
+ const MAX_TABS_MAX = 32;
6
10
  const DEFAULT_SETTINGS = {
7
11
  hostDiagonalInches: 27,
8
12
  hostNits: 500,
9
13
  agentControl: false,
10
14
  updateCheck: true,
11
- lastUpdateCheck: 0
15
+ lastUpdateCheck: 0,
16
+ recordHistory: true,
17
+ split: 0.5,
18
+ maxTabs: 12
12
19
  };
13
20
  const SCREEN_PRESETS = [
14
21
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
@@ -140,6 +147,12 @@ class TargetSource extends node_events.EventEmitter {
140
147
  */
141
148
  internal = false;
142
149
  disposed = false;
150
+ /**
151
+ * What the owner asked for, not what the current window happens to be doing.
152
+ * See `setPainting` — `recreate()` swaps in a fresh webContents that starts
153
+ * painting, so the wish has to outlive the window that was serving it.
154
+ */
155
+ paintingWanted = true;
143
156
  constructor(fps = DEFAULT_FPS, options = {}) {
144
157
  super();
145
158
  this.fps = fps;
@@ -167,6 +180,7 @@ class TargetSource extends node_events.EventEmitter {
167
180
  this.firstNavDone = false;
168
181
  const wc = win.webContents;
169
182
  wc.setFrameRate(this.fps);
183
+ if (!this.paintingWanted) wc.stopPainting();
170
184
  wc.setAudioMuted(true);
171
185
  this.defaultUserAgent ??= wc.getUserAgent();
172
186
  wc.setUserAgent(this.dsf > 1 && this.mobileEmulation ? MOBILE_USER_AGENT : this.defaultUserAgent);
@@ -241,6 +255,16 @@ class TargetSource extends node_events.EventEmitter {
241
255
  if (this.win === win) this.firstNavDone = true;
242
256
  });
243
257
  }
258
+ /**
259
+ * Resolves once this window's own initial `about:blank` has committed.
260
+ * A navigation issued before that lands is undone by it — the commit
261
+ * arrives late and `SyncBus` mirrors it into the native pane — so a caller
262
+ * that builds a source and immediately drives it waits here first. Follows
263
+ * the current window: a dsf change swaps in a fresh one with a fresh gate.
264
+ */
265
+ get ready() {
266
+ return this.firstNavigation;
267
+ }
244
268
  /**
245
269
  * Mobile viewport semantics for dsf > 1 (see class doc). Post-commit only:
246
270
  * enabling emulation before a window's first navigation commits segfaults
@@ -340,6 +364,26 @@ class TargetSource extends node_events.EventEmitter {
340
364
  getDeviceScaleFactor() {
341
365
  return this.dsf;
342
366
  }
367
+ /**
368
+ * Stops or resumes rasterisation without touching the page. Offscreen
369
+ * rendering runs at a fixed frame rate with `backgroundThrottling: false`,
370
+ * so a source nobody is looking at would otherwise paint a full viewport
371
+ * forever for nobody. The page keeps its DOM, timers, network and scroll —
372
+ * only pixel production stops.
373
+ */
374
+ setPainting(painting) {
375
+ if (this.paintingWanted === painting) return;
376
+ this.paintingWanted = painting;
377
+ if (this.win.isDestroyed()) return;
378
+ const wc = this.win.webContents;
379
+ if (wc.isDestroyed()) return;
380
+ if (painting) wc.startPainting();
381
+ else wc.stopPainting();
382
+ }
383
+ /** What was last asked of `setPainting`, not what the window is doing. */
384
+ get painting() {
385
+ return this.paintingWanted;
386
+ }
343
387
  /** Forces a full-frame repaint, e.g. after the renderer loses its texture. */
344
388
  invalidate() {
345
389
  if (!this.win.isDestroyed()) this.win.webContents.invalidate();
@@ -365,10 +409,15 @@ class TargetSource extends node_events.EventEmitter {
365
409
  if (!this.win.isDestroyed()) this.win.destroy();
366
410
  }
367
411
  }
412
+ exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
368
413
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
369
414
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
415
+ exports.MAX_TABS_MAX = MAX_TABS_MAX;
416
+ exports.MAX_TABS_MIN = MAX_TABS_MIN;
370
417
  exports.PANEL_PROFILES = PANEL_PROFILES;
371
418
  exports.SCREEN_PRESETS = SCREEN_PRESETS;
419
+ exports.SPLIT_MAX = SPLIT_MAX;
420
+ exports.SPLIT_MIN = SPLIT_MIN;
372
421
  exports.TargetSource = TargetSource;
373
422
  exports.classifyFileNavigation = classifyFileNavigation;
374
423
  exports.findPreset = findPreset;
package/out/mcp/server.js CHANGED
@@ -161,6 +161,8 @@ const snapOutputShape = {
161
161
  profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
162
162
  viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
163
163
  panes: zod_1.z.string().optional().describe("Live only: 'both' (native pane beside the target) or 'target' (the target render has the whole window)."),
164
+ tabId: zod_1.z.string().optional().describe('Live only: which of the app\'s tabs was captured (the active one). Empty from an app older than tabs.'),
165
+ tabIndex: zod_1.z.number().optional().describe("Live only: that tab's 0-based position in the strip."),
164
166
  width: zod_1.z
165
167
  .number()
166
168
  .optional()
@@ -310,6 +312,12 @@ const driveOutputShape = {
310
312
  viewMode: zod_1.z.string(),
311
313
  panes: zod_1.z.string(),
312
314
  mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
315
+ tabId: zod_1.z
316
+ .string()
317
+ .describe('Which of the app\'s tabs this acted on. Every command resolves the active tab as it arrives, so a tabId ' +
318
+ 'that changed between two calls means the user switched tabs under you. Empty string from an app older ' +
319
+ 'than tabs, which has only one.'),
320
+ tabIndex: zod_1.z.number().describe('That tab\'s 0-based position in the strip.'),
313
321
  scrolled: zod_1.z
314
322
  .object({ x: zod_1.z.number(), y: zod_1.z.number() })
315
323
  .nullable()
@@ -482,6 +490,8 @@ async function liveSnap(app, input, notes) {
482
490
  profileId: status.profileId,
483
491
  viewMode: status.viewMode,
484
492
  panes: status.panes,
493
+ tabId: status.tabId,
494
+ tabIndex: status.tabIndex,
485
495
  width,
486
496
  height,
487
497
  settled,
@@ -520,7 +530,11 @@ server.registerTool('obsrv_snap', {
520
530
  `capture; that visible steering is the point of live mode.\n\n` +
521
531
  `A live snap only navigates when the app is showing a different URL; the result's \`navigated\` says which ` +
522
532
  `happened. Navigating is a fresh load, so it starts at the top of the page — to photograph a scrolled or ` +
523
- `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.`,
533
+ `panned state, use obsrv_drive with \`capture\` instead, which never navigates unless you ask it to.\n\n` +
534
+ `Tabs: the app can hold several sessions open as tabs, and a live snap acts on the one in front — which the ` +
535
+ `user can change at any moment. The result names it (\`tabId\`, \`tabIndex\`); compare across calls if you ` +
536
+ `need to know it did not move. There is no way to name a different tab, and no way to open, close or ` +
537
+ `switch tabs — those are the user's.`,
524
538
  inputSchema: snapInputShape,
525
539
  outputSchema: snapOutputShape,
526
540
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
@@ -641,6 +655,12 @@ server.registerTool('obsrv_drive', {
641
655
  `pass \`url\`, so this is how you photograph a scrolled or panned state: scroll, then capture, in one call. ` +
642
656
  `obsrv_snap is the other way round — it points the app at a URL first, and pointing it somewhere new is a ` +
643
657
  `fresh load that starts at the top.\n\n` +
658
+ `Tabs: the app can hold several sessions open as tabs, each with its own URL, screen preset and page state. ` +
659
+ `Every command here acts on whichever tab is in front *when that command arrives* — nothing is bound to a ` +
660
+ `tab for the length of the call — and the returned status names it (\`tabId\`, \`tabIndex\`). A \`tabId\` ` +
661
+ `that changed between two calls means the user switched tabs under you; re-read the state before trusting ` +
662
+ `what you knew. You cannot name a different tab, nor open, close or switch tabs — those are the user's. An ` +
663
+ `empty \`tabId\` means an app older than tabs, which has only the one.\n\n` +
644
664
  `Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
645
665
  `mutates visible app state (it changes what the user's window shows, and a click can act on the live page).`,
646
666
  inputSchema: driveInputShape,
@@ -16,6 +16,7 @@ const IPC = {
16
16
  frame: "obsrv:frame",
17
17
  frameSubscribe: "obsrv:frame-subscribe",
18
18
  urlChanged: "obsrv:url-changed",
19
+ titleChanged: "obsrv:title-changed",
19
20
  loadError: "obsrv:load-error",
20
21
  hostChanged: "obsrv:host-changed",
21
22
  targetLoading: "obsrv:target-loading",
@@ -31,7 +32,15 @@ const IPC = {
31
32
  getUpdate: "obsrv:get-update",
32
33
  checkUpdate: "obsrv:check-update",
33
34
  openRelease: "obsrv:open-release",
34
- updateStatus: "obsrv:update-status"
35
+ updateStatus: "obsrv:update-status",
36
+ getHistory: "obsrv:get-history",
37
+ clearHistory: "obsrv:clear-history",
38
+ historyChanged: "obsrv:history-changed",
39
+ getTabs: "obsrv:get-tabs",
40
+ addTab: "obsrv:add-tab",
41
+ closeTab: "obsrv:close-tab",
42
+ activateTab: "obsrv:activate-tab",
43
+ tabsChanged: "obsrv:tabs-changed"
35
44
  };
36
45
  function subscribe(channel, cb) {
37
46
  const listener = (_e, v) => cb(v);
@@ -66,24 +75,16 @@ const api = {
66
75
  getSettings: () => electron.ipcRenderer.invoke(IPC.getSettings),
67
76
  setSettings: (s) => electron.ipcRenderer.invoke(IPC.setSettings, s),
68
77
  onFrame: subscribeFrames,
78
+ // Each of these names the tab it describes: main no longer gates them on the
79
+ // tab being in front, so a background tab keeps its own strip entry current
80
+ // without touching the address bar of the tab that is showing.
69
81
  onUrlChanged: (cb) => subscribe(IPC.urlChanged, cb),
82
+ onTitleChanged: (cb) => subscribe(IPC.titleChanged, cb),
70
83
  onLoadError: (cb) => subscribe(IPC.loadError, cb),
71
84
  onHostChanged: (cb) => subscribe(IPC.hostChanged, cb),
72
85
  onTargetLoading: (cb) => subscribe(IPC.targetLoading, cb),
73
- onNativeFocused: (cb) => {
74
- const listener = () => cb();
75
- electron.ipcRenderer.on(IPC.nativeFocused, listener);
76
- return () => {
77
- electron.ipcRenderer.removeListener(IPC.nativeFocused, listener);
78
- };
79
- },
80
- onTargetNavigating: (cb) => {
81
- const listener = () => cb();
82
- electron.ipcRenderer.on(IPC.targetNavigating, listener);
83
- return () => {
84
- electron.ipcRenderer.removeListener(IPC.targetNavigating, listener);
85
- };
86
- },
86
+ onNativeFocused: (cb) => subscribe(IPC.nativeFocused, cb),
87
+ onTargetNavigating: (cb) => subscribe(IPC.targetNavigating, cb),
87
88
  onOpenImage: (cb) => {
88
89
  const listener = () => cb();
89
90
  electron.ipcRenderer.on(IPC.openImage, listener);
@@ -112,6 +113,14 @@ const api = {
112
113
  getUpdate: () => electron.ipcRenderer.invoke(IPC.getUpdate),
113
114
  checkUpdate: () => electron.ipcRenderer.invoke(IPC.checkUpdate),
114
115
  openRelease: () => electron.ipcRenderer.invoke(IPC.openRelease),
115
- onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb)
116
+ onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
117
+ getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
118
+ clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
119
+ onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb),
120
+ getTabs: () => electron.ipcRenderer.invoke(IPC.getTabs),
121
+ addTab: () => electron.ipcRenderer.invoke(IPC.addTab),
122
+ closeTab: (id) => electron.ipcRenderer.send(IPC.closeTab, id),
123
+ activateTab: (id) => electron.ipcRenderer.send(IPC.activateTab, id),
124
+ onTabsChanged: (cb) => subscribe(IPC.tabsChanged, cb)
116
125
  };
117
126
  electron.contextBridge.exposeInMainWorld("obsrv", api);
@@ -129,15 +129,53 @@
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; }
136
172
 
137
- /* Two strips. Browsing above; the controls that describe the simulated
138
- screen below, on a darker ground so the rows read as separate registers.
139
- 44 + 38 = 82 must equal TOOLBAR_H in src/main/ipc.ts. Pinned heights also
140
- keep a late reflow from resizing the panes under NativeSlot's observer. */
173
+ /* Three strips. The tab strip on top furthest from the panes, as every
174
+ browser puts it; browsing below it; the controls that describe the simulated
175
+ screen last, on a darker ground so the rows read as separate registers.
176
+ 32 + 44 + 38 = 114 must equal TOOLBAR_H in src/main/ipc.ts. Pinned heights
177
+ also keep a late reflow from resizing the panes under NativeSlot's
178
+ observer. */
141
179
  .chrome { flex: 0 0 auto; }
142
180
  .chrome-row {
143
181
  box-sizing: border-box;
@@ -148,9 +186,114 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
148
186
  border-bottom: 1px solid var(--line);
149
187
  }
150
188
  .chrome-browse { height: 44px; background: var(--chrome-1); }
189
+ /* The containing block the URL-history list falls back to. Its padding box
190
+ starts at the window's left edge (the row's only border is at the bottom),
191
+ which is the frame `--native-right` is expressed in. */
192
+ .chrome-browse { position: relative; }
151
193
  .chrome-screen { height: 38px; background: var(--chrome-0); }
152
194
  .chrome-spacer { flex: 1 1 auto; }
153
195
 
196
+ /* The tab strip. Ground is the darkest chrome step so an inactive tab (one
197
+ step up) already reads as a raised object, and the active one has somewhere
198
+ further to go. Neutral throughout: a coloured tab beside the panes would
199
+ shift the perceived hue of the render under test, which is the one thing
200
+ this chrome may never do. */
201
+ .chrome-tabs { height: 32px; gap: 4px; padding: 0 6px; background: var(--chrome-0); }
202
+ /* The tabs scroll rather than shrink past legibility; the new-tab button is
203
+ outside this box so it never scrolls away from the pointer. */
204
+ .tabs { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
205
+ .tabs::-webkit-scrollbar { display: none; }
206
+
207
+ .tab {
208
+ flex: 0 1 auto;
209
+ min-width: 0;
210
+ max-width: 180px;
211
+ height: 24px;
212
+ display: flex;
213
+ align-items: center;
214
+ background: var(--chrome-1);
215
+ border: 1px solid var(--line);
216
+ border-radius: 4px;
217
+ }
218
+ .tab-label {
219
+ flex: 1 1 auto;
220
+ min-width: 0;
221
+ height: 100%;
222
+ padding: 0 2px 0 8px;
223
+ background: none;
224
+ border: 0;
225
+ color: var(--text-1);
226
+ font: inherit;
227
+ text-align: left;
228
+ white-space: nowrap;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ cursor: pointer;
232
+ }
233
+ .tab:hover { background: var(--chrome-2); }
234
+ /* The chrome's one selected idiom: the `--chrome-3` fill step plus weight,
235
+ exactly as `.segmented button[aria-pressed]` and `.menu-row[aria-pressed]`
236
+ mark theirs. Selection is carried by the label button's `aria-selected`, so
237
+ the fill is derived from it rather than duplicated onto the wrapper. */
238
+ .tab:has(> .tab-label[aria-selected='true']) { background: var(--chrome-3); border-color: var(--chrome-3); }
239
+ .tab > .tab-label[aria-selected='true'] { color: var(--text-0); font-weight: 600; }
240
+
241
+ /* The driven tab, while agent control is on. The 2px inset rule `.menu-row`
242
+ and `.surround-control` already use to mark a choice — the same idiom, in
243
+ the same direction, so it reads as "this one" and not as a new language.
244
+ It sits on the leading edge rather than surrounding the tab, so it never
245
+ competes with the selected fill underneath it (the driven tab is always the
246
+ selected one, and two full outlines on one control would be noise).
247
+
248
+ Neutral, and only neutral: --text-1 standing, --text-0 for ~3s after each
249
+ command, exactly the step the AGENT chip makes. No hue and no glow — a blur
250
+ here would be the first shadow in the app, and the rule against it is what
251
+ stops the chrome biasing the render below. */
252
+ .tab.driven { box-shadow: inset 2px 0 0 var(--text-1); }
253
+ .tab.driven.busy { box-shadow: inset 2px 0 0 var(--text-0); }
254
+
255
+ .tab-close {
256
+ flex: 0 0 auto;
257
+ width: 18px;
258
+ height: 18px;
259
+ margin-right: 3px;
260
+ display: inline-flex;
261
+ align-items: center;
262
+ justify-content: center;
263
+ padding: 0;
264
+ background: none;
265
+ border: 0;
266
+ border-radius: 3px;
267
+ color: var(--text-1);
268
+ cursor: pointer;
269
+ }
270
+ /* Only on hover of the tab: sixteen ✕ glyphs at rest is a busier strip than
271
+ the titles they sit beside, and the titles are the content here. */
272
+ .tab-close { opacity: 0; }
273
+ .tab:hover .tab-close,
274
+ .tab:has(> .tab-label[aria-selected='true']) .tab-close,
275
+ .tab-close:focus-visible { opacity: 1; }
276
+ .tab-close:hover { background: var(--chrome-2); color: var(--text-0); }
277
+
278
+ .tab-new {
279
+ flex: 0 0 auto;
280
+ width: 24px;
281
+ height: 24px;
282
+ display: inline-flex;
283
+ align-items: center;
284
+ justify-content: center;
285
+ padding: 0;
286
+ background: var(--chrome-2);
287
+ border: 1px solid var(--line);
288
+ border-radius: 4px;
289
+ color: var(--text-0);
290
+ cursor: pointer;
291
+ }
292
+ .tab-new:hover:not(:disabled) { background: var(--chrome-3); }
293
+ /* At the cap. Dimmed rather than hidden, and the `title` says why and where to
294
+ raise it — a control that vanishes teaches nothing. */
295
+ .tab-new:disabled { color: var(--text-1); background: var(--chrome-1); cursor: default; }
296
+
154
297
  /* No blanket `button` rule here, ever. The old `.toolbar button { width:
155
298
  26px }` was specificity (0,1,1) and silently clipped the update button to
156
299
  "v0."; every control now sizes itself. */
@@ -168,7 +311,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
168
311
  cursor: pointer;
169
312
  }
170
313
 
171
- .url-form { flex: 1 1 auto; min-width: 160px; }
314
+ /* Static in Both, so the history list below resolves against `.chrome-browse`
315
+ and can be placed in window coordinates. In solo target there is no native
316
+ view to dodge and the list belongs under the field, so the form becomes the
317
+ containing block instead. */
318
+ .url-form { position: static; flex: 1 1 auto; min-width: 160px; }
319
+ .app[data-panes='target'] .url-form { position: relative; }
172
320
  .url-form input {
173
321
  width: 100%;
174
322
  height: 30px;
@@ -182,6 +330,61 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
182
330
  }
183
331
  .url-form input[readonly] { color: var(--text-1); }
184
332
 
333
+ /* Visited-URL type-ahead. Its left edge is clamped to the native pane's right
334
+ edge because that pane is an OS-level WebContentsView painting over
335
+ anything the renderer draws beneath it: a list anchored to the field, which
336
+ starts near x=120, would be entirely invisible. The cost is cosmetic — with
337
+ a wide native pane the list sits offset from the field it belongs to — and
338
+ it is the same constraint that pushed the overflow menu rightward.
339
+ `right` bounds it against the window so a very wide split cannot push it
340
+ off the edge; `max-width` keeps it from spanning a whole empty pane. */
341
+ .url-history {
342
+ position: absolute;
343
+ top: calc(100% + 4px);
344
+ left: var(--native-right, 0px);
345
+ right: 10px;
346
+ max-width: 560px;
347
+ z-index: 10;
348
+ margin: 0;
349
+ padding: 4px 0;
350
+ list-style: none;
351
+ font-size: 11.5px;
352
+ background: var(--chrome-2);
353
+ border: 1px solid var(--line);
354
+ border-radius: 6px;
355
+ }
356
+ /* Solo target: the list is the field's own dropdown, so it tracks its width. */
357
+ .app[data-panes='target'] .url-history { left: 0; right: 0; max-width: none; }
358
+ .url-history-row {
359
+ display: flex;
360
+ align-items: baseline;
361
+ gap: 10px;
362
+ height: 26px;
363
+ padding: 0 11px;
364
+ cursor: pointer;
365
+ }
366
+ /* The chrome's one selected idiom, shared with `.menu-row[aria-pressed]`. */
367
+ .url-history-row.active { background: var(--chrome-3); box-shadow: inset 2px 0 0 var(--text-0); }
368
+ /* Truncated at the *start*, not the end. What distinguishes one of these
369
+ addresses from another is the tail — the port, the path, the filename —
370
+ and right-truncating a list of localhost URLs or file paths yields six
371
+ identical rows, which is what the first screenshot of this list showed.
372
+ `direction: rtl` puts the ellipsis on the leading edge; the `bdi` around
373
+ the URL keeps the characters themselves in left-to-right order. */
374
+ .url-history-url {
375
+ flex: 1 1 auto;
376
+ min-width: 0;
377
+ overflow: hidden;
378
+ text-overflow: ellipsis;
379
+ white-space: nowrap;
380
+ direction: rtl;
381
+ /* The box is right-to-left only to move the ellipsis; a URL short enough to
382
+ fit must still start at the left edge like every other row. */
383
+ text-align: left;
384
+ font-family: var(--mono);
385
+ }
386
+ .url-history-age { flex: 0 0 auto; color: var(--text-1); font-size: 10px; }
387
+
185
388
  .status-cluster { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; font-size: 10px; }
186
389
  .badge-error {
187
390
  color: var(--error);
@@ -335,13 +538,71 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
335
538
 
336
539
  .panes { flex: 1 1 auto; display: flex; min-height: 0; }
337
540
  .pane {
338
- flex: 1 1 50%;
541
+ flex: 1 1 0;
339
542
  min-width: 0;
340
543
  display: flex;
341
544
  flex-direction: column;
342
545
  background: var(--surround);
343
546
  }
344
- .target-pane { border-left: 1px solid var(--line); }
547
+ /* The native (or image) pane takes its share of everything the seam leaves;
548
+ the target pane grows into the rest, so the 1px divider comes out of the
549
+ row rather than out of one pane's half and skewing the ratio.
550
+
551
+ The `clamp()` is the *stored* ratio's floor, and the reason it lives in CSS
552
+ rather than in the drag: a ratio saved on a wide monitor and restored on a
553
+ narrow window is applied as close as 240px a side allows, without being
554
+ written back — grow the window and the preference returns on its own, with
555
+ no resize plumbing to keep in step. The drag enforces the same floor in JS,
556
+ where it also decides what to persist. */
557
+ .app[data-panes='both'] .panes > .pane:first-child {
558
+ flex-grow: 0;
559
+ flex-shrink: 0;
560
+ /* Rounded to whole pixels, and this is load-bearing. `TargetCanvas`
561
+ measures its pane with `clientWidth`, which *rounds*: a 1129.5px pane
562
+ reads as 1130, fit sizes the canvas to 1130, and the render then
563
+ overflows its own pane by half a pixel — which quietly turns off the
564
+ `safe center` centring and leaves the pane scrollable. Whole-pixel panes
565
+ keep the measurement honest. (The old `flex: 1 1 50%` hit the same thing
566
+ on an odd-width window; taking the seam out of the row would have made
567
+ it every window.) */
568
+ flex-basis: round(
569
+ down,
570
+ clamp(
571
+ var(--pane-min),
572
+ calc((100% - var(--seam)) * var(--split)),
573
+ calc(100% - var(--seam) - var(--pane-min))
574
+ ),
575
+ 1px
576
+ );
577
+ }
578
+
579
+ /* The seam. A flex item exactly one pixel wide — the hairline the target pane
580
+ used to draw as its own `border-left` — with the grab area straddling it as
581
+ an overflowing pseudo-element, so the handle is 6px to the pointer and 1px
582
+ to the eye. The style spec forbids decorating anything that touches a pane,
583
+ and a resize handle drawn as furniture would be decoration. */
584
+ .pane-divider {
585
+ flex: 0 0 var(--seam);
586
+ /* Positioned *and* raised: `.target-wrap` next door is positioned too and
587
+ comes later in the DOM, so without a `z-index` the target pane's canvas
588
+ paints over the half of the grab area that overhangs it and swallows the
589
+ press. */
590
+ position: relative;
591
+ z-index: 1;
592
+ background: var(--line);
593
+ cursor: col-resize;
594
+ /* Or the drag scrolls the pane instead of moving the seam. */
595
+ touch-action: none;
596
+ }
597
+ .pane-divider::before {
598
+ content: '';
599
+ position: absolute;
600
+ inset: 0 -2.5px;
601
+ }
602
+ .pane-divider:hover, .pane-divider:focus-visible { background: var(--text-1); }
603
+ /* The hairline is the affordance; a ring around a 1px element would be a box
604
+ floating on the seam. Same reasoning as `.target-canvas:focus`. */
605
+ .pane-divider:focus-visible { outline: none; }
345
606
  /* Square corners, no shadow, no blur: a rounded or blurred pane edge would
346
607
  destroy the very corner pixels the user opened this app to inspect. */
347
608
  .pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
@@ -353,7 +614,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
353
614
  on the very pixels under inspection. Keyboard focus in the target pane is
354
615
  shown on the pane's own hairlines instead, which touch no page pixel. */
355
616
  .target-canvas:focus { outline: none; }
356
- .target-pane:has(.target-canvas:focus-visible) { border-left-color: var(--text-1); }
617
+ /* The seam moved out of `.target-pane`'s border and into its own element, so
618
+ the focus signal reaches it from the row both panes share — `:has` on the
619
+ ancestor, since the divider is the canvas's *earlier* sibling's sibling and
620
+ no combinator walks backwards. The footer rule is unchanged, and carries
621
+ the whole signal on its own in solo target, where there is no seam. */
622
+ .panes:has(.target-canvas:focus-visible) .pane-divider { background: var(--text-1); }
357
623
  .target-pane:has(.target-canvas:focus-visible) .pane-footer { border-top-color: var(--text-1); }
358
624
 
359
625
  /* Signature: each pane always states what it is showing, so a screenshot of
@@ -406,9 +672,6 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
406
672
  .surround-control button[aria-pressed='true'] { box-shadow: inset 0 0 0 2px var(--text-0); }
407
673
  .surround-swatch { display: block; width: 12px; height: 12px; margin: 0 auto; }
408
674
 
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
675
  /* Numbers never jitter as a slider moves. */
413
676
  .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
414
677
 
@@ -431,7 +694,11 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
431
694
  .body { flex: 1 1 auto; display: flex; min-height: 0; }
432
695
  .panes { min-width: 0; }
433
696
  .drawer {
434
- flex: 0 0 280px;
697
+ /* Border-box against `--drawer-w`: 309 - 28 padding - 1 border = the 280px
698
+ of content this has always had, now stated once for both this and the
699
+ URL-history clamp. */
700
+ box-sizing: border-box;
701
+ flex: 0 0 var(--drawer-w);
435
702
  overflow: auto;
436
703
  padding: 12px 14px;
437
704
  background: var(--chrome-1);
@@ -664,7 +931,8 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
664
931
  cursor: pointer;
665
932
  font: inherit;
666
933
  }
667
- .check-now {
934
+ .check-now,
935
+ .clear-history {
668
936
  width: 100%;
669
937
  height: 24px;
670
938
  margin-top: 8px;
@@ -674,3 +942,5 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
674
942
  border-radius: 4px;
675
943
  cursor: pointer;
676
944
  }
945
+ /* Nothing to erase: the button says so rather than pretending to act. */
946
+ .clear-history:disabled { color: var(--text-1); cursor: default; }