getobsrv 0.11.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 +741 -201
- package/out/main/{targetSource-nbfc-Fe6.js → targetSource-DlsHc-Ri.js} +43 -1
- package/out/mcp/server.js +21 -1
- package/out/preload/app.js +19 -16
- package/out/renderer/assets/{index-B61fbgqt.css → index-DvwDBeho.css} +107 -4
- package/out/renderer/assets/{index-BlbBg6lm.js → index-gYDbIgqJ.js} +380 -118
- package/out/renderer/index.html +2 -2
- package/out/shared/control.js +13 -1
- package/out/shared/ipcPayloads.js +29 -1
- package/out/shared/presets.js +6 -1
- package/out/shared/tabList.js +46 -0
- package/package.json +1 -1
- package/skills/obsrv-screens/SKILL.md +7 -0
|
@@ -5,6 +5,8 @@ const node_path = require("node:path");
|
|
|
5
5
|
const MAX_VIEWPORT = 4096;
|
|
6
6
|
const SPLIT_MIN = 0.1;
|
|
7
7
|
const SPLIT_MAX = 0.9;
|
|
8
|
+
const MAX_TABS_MIN = 2;
|
|
9
|
+
const MAX_TABS_MAX = 32;
|
|
8
10
|
const DEFAULT_SETTINGS = {
|
|
9
11
|
hostDiagonalInches: 27,
|
|
10
12
|
hostNits: 500,
|
|
@@ -12,7 +14,8 @@ const DEFAULT_SETTINGS = {
|
|
|
12
14
|
updateCheck: true,
|
|
13
15
|
lastUpdateCheck: 0,
|
|
14
16
|
recordHistory: true,
|
|
15
|
-
split: 0.5
|
|
17
|
+
split: 0.5,
|
|
18
|
+
maxTabs: 12
|
|
16
19
|
};
|
|
17
20
|
const SCREEN_PRESETS = [
|
|
18
21
|
// Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
|
|
@@ -144,6 +147,12 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
144
147
|
*/
|
|
145
148
|
internal = false;
|
|
146
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;
|
|
147
156
|
constructor(fps = DEFAULT_FPS, options = {}) {
|
|
148
157
|
super();
|
|
149
158
|
this.fps = fps;
|
|
@@ -171,6 +180,7 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
171
180
|
this.firstNavDone = false;
|
|
172
181
|
const wc = win.webContents;
|
|
173
182
|
wc.setFrameRate(this.fps);
|
|
183
|
+
if (!this.paintingWanted) wc.stopPainting();
|
|
174
184
|
wc.setAudioMuted(true);
|
|
175
185
|
this.defaultUserAgent ??= wc.getUserAgent();
|
|
176
186
|
wc.setUserAgent(this.dsf > 1 && this.mobileEmulation ? MOBILE_USER_AGENT : this.defaultUserAgent);
|
|
@@ -245,6 +255,16 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
245
255
|
if (this.win === win) this.firstNavDone = true;
|
|
246
256
|
});
|
|
247
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
|
+
}
|
|
248
268
|
/**
|
|
249
269
|
* Mobile viewport semantics for dsf > 1 (see class doc). Post-commit only:
|
|
250
270
|
* enabling emulation before a window's first navigation commits segfaults
|
|
@@ -344,6 +364,26 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
344
364
|
getDeviceScaleFactor() {
|
|
345
365
|
return this.dsf;
|
|
346
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
|
+
}
|
|
347
387
|
/** Forces a full-frame repaint, e.g. after the renderer loses its texture. */
|
|
348
388
|
invalidate() {
|
|
349
389
|
if (!this.win.isDestroyed()) this.win.webContents.invalidate();
|
|
@@ -372,6 +412,8 @@ class TargetSource extends node_events.EventEmitter {
|
|
|
372
412
|
exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
|
|
373
413
|
exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
|
|
374
414
|
exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
|
|
415
|
+
exports.MAX_TABS_MAX = MAX_TABS_MAX;
|
|
416
|
+
exports.MAX_TABS_MIN = MAX_TABS_MIN;
|
|
375
417
|
exports.PANEL_PROFILES = PANEL_PROFILES;
|
|
376
418
|
exports.SCREEN_PRESETS = SCREEN_PRESETS;
|
|
377
419
|
exports.SPLIT_MAX = SPLIT_MAX;
|
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",
|
|
@@ -34,7 +35,12 @@ const IPC = {
|
|
|
34
35
|
updateStatus: "obsrv:update-status",
|
|
35
36
|
getHistory: "obsrv:get-history",
|
|
36
37
|
clearHistory: "obsrv:clear-history",
|
|
37
|
-
historyChanged: "obsrv:history-changed"
|
|
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"
|
|
38
44
|
};
|
|
39
45
|
function subscribe(channel, cb) {
|
|
40
46
|
const listener = (_e, v) => cb(v);
|
|
@@ -69,24 +75,16 @@ const api = {
|
|
|
69
75
|
getSettings: () => electron.ipcRenderer.invoke(IPC.getSettings),
|
|
70
76
|
setSettings: (s) => electron.ipcRenderer.invoke(IPC.setSettings, s),
|
|
71
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.
|
|
72
81
|
onUrlChanged: (cb) => subscribe(IPC.urlChanged, cb),
|
|
82
|
+
onTitleChanged: (cb) => subscribe(IPC.titleChanged, cb),
|
|
73
83
|
onLoadError: (cb) => subscribe(IPC.loadError, cb),
|
|
74
84
|
onHostChanged: (cb) => subscribe(IPC.hostChanged, cb),
|
|
75
85
|
onTargetLoading: (cb) => subscribe(IPC.targetLoading, cb),
|
|
76
|
-
onNativeFocused: (cb) =>
|
|
77
|
-
|
|
78
|
-
electron.ipcRenderer.on(IPC.nativeFocused, listener);
|
|
79
|
-
return () => {
|
|
80
|
-
electron.ipcRenderer.removeListener(IPC.nativeFocused, listener);
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
onTargetNavigating: (cb) => {
|
|
84
|
-
const listener = () => cb();
|
|
85
|
-
electron.ipcRenderer.on(IPC.targetNavigating, listener);
|
|
86
|
-
return () => {
|
|
87
|
-
electron.ipcRenderer.removeListener(IPC.targetNavigating, listener);
|
|
88
|
-
};
|
|
89
|
-
},
|
|
86
|
+
onNativeFocused: (cb) => subscribe(IPC.nativeFocused, cb),
|
|
87
|
+
onTargetNavigating: (cb) => subscribe(IPC.targetNavigating, cb),
|
|
90
88
|
onOpenImage: (cb) => {
|
|
91
89
|
const listener = () => cb();
|
|
92
90
|
electron.ipcRenderer.on(IPC.openImage, listener);
|
|
@@ -118,6 +116,11 @@ const api = {
|
|
|
118
116
|
onUpdateStatus: (cb) => subscribe(IPC.updateStatus, cb),
|
|
119
117
|
getHistory: () => electron.ipcRenderer.invoke(IPC.getHistory),
|
|
120
118
|
clearHistory: () => electron.ipcRenderer.invoke(IPC.clearHistory),
|
|
121
|
-
onHistoryChanged: (cb) => subscribe(IPC.historyChanged, cb)
|
|
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)
|
|
122
125
|
};
|
|
123
126
|
electron.contextBridge.exposeInMainWorld("obsrv", api);
|
|
@@ -170,10 +170,12 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
170
170
|
.warn { color: var(--warn); }
|
|
171
171
|
.chrome .warn { white-space: nowrap; }
|
|
172
172
|
|
|
173
|
-
/*
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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. */
|
|
177
179
|
.chrome { flex: 0 0 auto; }
|
|
178
180
|
.chrome-row {
|
|
179
181
|
box-sizing: border-box;
|
|
@@ -191,6 +193,107 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
191
193
|
.chrome-screen { height: 38px; background: var(--chrome-0); }
|
|
192
194
|
.chrome-spacer { flex: 1 1 auto; }
|
|
193
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
|
+
|
|
194
297
|
/* No blanket `button` rule here, ever. The old `.toolbar button { width:
|
|
195
298
|
26px }` was specificity (0,1,1) and silently clipped the update button to
|
|
196
299
|
"v0."; every control now sizes itself. */
|