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.
- package/README.md +34 -0
- package/out/main/cli.js +1 -1
- package/out/main/index.js +842 -192
- package/out/main/{targetSource-CbJwfugi.js → targetSource-DlsHc-Ri.js} +50 -1
- package/out/mcp/server.js +21 -1
- package/out/preload/app.js +25 -16
- package/out/renderer/assets/{index-CZUlXhEb.css → index-DvwDBeho.css} +284 -14
- package/out/renderer/assets/{index-CQVE5hF3.js → index-gYDbIgqJ.js} +655 -157
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +13 -1
- package/out/shared/history.js +90 -0
- package/out/shared/ipcPayloads.js +41 -2
- package/out/shared/presets.js +12 -1
- package/out/shared/tabList.js +46 -0
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +7 -0
|
@@ -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,
|
package/out/preload/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|