getobsrv 0.11.0 → 0.13.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.
@@ -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
- /* Two strips. Browsing above; the controls that describe the simulated
174
- screen below, on a darker ground so the rows read as separate registers.
175
- 44 + 38 = 82 must equal TOOLBAR_H in src/main/ipc.ts. Pinned heights also
176
- 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. */
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. */
@@ -536,6 +639,41 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
536
639
  }
537
640
  .pane-footer .role { color: var(--text-0); letter-spacing: 0.08em; }
538
641
 
642
+ /* The rotate control. Same 30px border-box and the same `--chrome-3` pressed
643
+ fill as `.segmented`, so the screen row keeps one hit-target rhythm and one
644
+ way of marking a choice. Two outlines of the screen you get, rather than the
645
+ words: "Portrait"/"Landscape" would take 150px in a row that already holds
646
+ two selects and three groups, and the shape reads faster than either word.
647
+ The buttons are 28px like `.surround-control`'s, for the same reason — the
648
+ glyph is the affordance and the button is only the target.
649
+
650
+ No hue anywhere, pressed included: this control sits a few pixels from the
651
+ pane whose greys the user came to judge. */
652
+ .orientation-control {
653
+ box-sizing: border-box;
654
+ flex: 0 0 auto;
655
+ display: inline-flex;
656
+ height: 30px;
657
+ border: 1px solid var(--line);
658
+ border-radius: 5px;
659
+ overflow: hidden;
660
+ }
661
+ .orientation-control button {
662
+ width: 28px;
663
+ height: 100%;
664
+ display: inline-flex;
665
+ align-items: center;
666
+ justify-content: center;
667
+ border: 0;
668
+ border-right: 1px solid var(--line);
669
+ border-radius: 0;
670
+ background: var(--chrome-2);
671
+ color: var(--text-1);
672
+ cursor: pointer;
673
+ }
674
+ .orientation-control button:last-child { border-right: 0; }
675
+ .orientation-control button[aria-pressed='true'] { background: var(--chrome-3); color: var(--text-0); }
676
+
539
677
  /* Segmented surround control: the same 30px border-box as `.segmented` and
540
678
  `.select-shell`, so the screen row has one hit-target rhythm. `box-sizing`
541
679
  for the same reason `.segmented` needs it — a `div` gets no border-box from
@@ -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-BlbBg6lm.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-B61fbgqt.css">
7
+ <script type="module" crossorigin src="./assets/index-BHt9Vm0J.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-BRkFfzcI.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1,10 +1,45 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyOrientation = applyOrientation;
4
+ exports.screenShape = screenShape;
3
5
  exports.ppi = ppi;
4
6
  exports.computeScale = computeScale;
5
7
  exports.maxCssViewport = maxCssViewport;
6
8
  exports.clampViewport = clampViewport;
7
9
  const presets_1 = require("./presets");
10
+ /**
11
+ * The screen as it is actually being held. The **one** place the axes are
12
+ * swapped: everything downstream — the viewport handed to `TargetSource`, the
13
+ * clamp, the magnification, the footer — reads the rotated screen and needs no
14
+ * orientation of its own.
15
+ *
16
+ * Rotation must not change the *magnitude* of the physical scale, and does not:
17
+ * `ppi` is `hypot(w, h) / diagonalInches` and `hypot` is symmetric, so the
18
+ * diagonal, the pixel count and the density are all orientation-independent.
19
+ * A phone does not get physically larger by being turned sideways. The unit
20
+ * test asserts it, because it is exactly the kind of thing that drifts.
21
+ *
22
+ * `'portrait'` is the preset as the table stores it; `'landscape'` is that
23
+ * rotated a quarter turn. Every mobile preset — the case this feature exists
24
+ * for — is stored portrait, so for those the names are literal. A monitor
25
+ * preset is stored landscape-natural, so there the pair reads as
26
+ * unrotated/rotated instead; the UI never repeats the flag back at the user,
27
+ * it names the shape the dimensions actually have (`screenShape`), so nothing
28
+ * on screen can contradict the pixels beside it.
29
+ */
30
+ function applyOrientation(screen, orientation) {
31
+ if (orientation !== 'landscape')
32
+ return screen;
33
+ return { ...screen, width: screen.height, height: screen.width };
34
+ }
35
+ /**
36
+ * The shape a pair of dimensions actually has, for anything the user reads. A
37
+ * square screen counts as portrait — it has no landscape reading, and one of
38
+ * the two words has to win.
39
+ */
40
+ function screenShape(width, height) {
41
+ return width > height ? 'landscape' : 'portrait';
42
+ }
8
43
  function ppi(width, height, diagonalInches) {
9
44
  if (!(diagonalInches > 0))
10
45
  throw new RangeError('diagonalInches must be > 0');
@@ -9,6 +9,7 @@ exports.defaultControlFilePath = defaultControlFilePath;
9
9
  exports.presetApplyError = presetApplyError;
10
10
  exports.profileApplyError = profileApplyError;
11
11
  exports.viewModeApplyError = viewModeApplyError;
12
+ exports.orientationApplyError = orientationApplyError;
12
13
  exports.panesApplyError = panesApplyError;
13
14
  exports.pixelExactApplyError = pixelExactApplyError;
14
15
  exports.parseClick = parseClick;
@@ -17,6 +18,7 @@ exports.parseControlStatus = parseControlStatus;
17
18
  const node_crypto_1 = require("node:crypto");
18
19
  const node_path_1 = require("node:path");
19
20
  const ipcPayloads_1 = require("./ipcPayloads");
21
+ const calibration_1 = require("./calibration");
20
22
  const presets_1 = require("./presets");
21
23
  /**
22
24
  * The agent-control protocol shared by the main-process control server
@@ -50,6 +52,7 @@ exports.CONTROL_COMMANDS = [
50
52
  'setProfile',
51
53
  'setViewMode',
52
54
  'setPanes',
55
+ 'setOrientation',
53
56
  'captureVisible',
54
57
  // v0.5 drive controls (spec §14 "Drive controls").
55
58
  'scroll',
@@ -153,6 +156,13 @@ function profileApplyError(id) {
153
156
  function viewModeApplyError(v) {
154
157
  return v === '1:1' || v === 'fit' ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
155
158
  }
159
+ /**
160
+ * Validates a `setOrientation` payload. Unlike a preset id there is no table to
161
+ * miss, so the message just names the two words rather than listing anything.
162
+ */
163
+ function orientationApplyError(v) {
164
+ return (0, presets_1.isOrientation)(v) ? null : `setOrientation payload must be { orientation: 'portrait' | 'landscape' }`;
165
+ }
156
166
  function panesApplyError(v) {
157
167
  return v === 'both' || v === 'target' ? null : `setPanes payload must be { panes: 'both' | 'target' }`;
158
168
  }
@@ -214,6 +224,19 @@ function parseHighlight(raw) {
214
224
  durationMs: Math.min(Math.max(Math.round(d), exports.HIGHLIGHT_DURATION_MIN_MS), exports.HIGHLIGHT_DURATION_MAX_MS),
215
225
  };
216
226
  }
227
+ /**
228
+ * The shape to report when the app did not name one. Dimensions settle it
229
+ * outright when they are there. Without them the app predates rotation, which
230
+ * means it is showing the preset unrotated — so the preset's own natural shape
231
+ * is exact rather than a guess. Only a custom screen on such an app falls all
232
+ * the way through to the flag.
233
+ */
234
+ function inferScreenShape(cssWidth, cssHeight, presetId, orientation) {
235
+ if (cssWidth > 0 && cssHeight > 0)
236
+ return (0, calibration_1.screenShape)(cssWidth, cssHeight);
237
+ const preset = presets_1.SCREEN_PRESETS.find(p => p.id === presetId);
238
+ return preset ? (0, calibration_1.screenShape)(preset.width, preset.height) : orientation;
239
+ }
217
240
  /** Validates a control server `status` response on the client side. */
218
241
  function parseControlStatus(raw) {
219
242
  if (!isRecord(raw))
@@ -233,5 +256,52 @@ function parseControlStatus(raw) {
233
256
  const panes = raw.panes ?? 'both';
234
257
  if (panes !== 'both' && panes !== 'target')
235
258
  return null;
236
- return { version, url, presetId, profileId, viewMode, panes, mode };
259
+ // The same skew, one release later. An app that predates tabs has exactly
260
+ // one session — it is the tab at index 0, and it has no id to name — so the
261
+ // defaults describe that app truthfully rather than papering over it. An
262
+ // agent polling one sees an unchanging `tabId`, which is correct: there is
263
+ // no other tab for the user to switch to. Returning null instead would take
264
+ // out drive and live snap wholesale against every app older than tabs.
265
+ const tabId = raw.tabId ?? '';
266
+ if (typeof tabId !== 'string')
267
+ return null;
268
+ const tabIndex = raw.tabIndex ?? 0;
269
+ if (typeof tabIndex !== 'number' || !Number.isInteger(tabIndex) || tabIndex < 0)
270
+ return null;
271
+ // And the same skew again, one release later still. The npm MCP server
272
+ // routinely talks to a DMG app older than itself, and an app that predates
273
+ // rotation shows every screen unrotated — which is exactly what the default
274
+ // says. Returning null instead would take out drive and live snap wholesale
275
+ // against every app older than this feature, which is the bug this repo has
276
+ // already been bitten by twice.
277
+ const orientation = raw.orientation ?? presets_1.DEFAULT_ORIENTATION;
278
+ if (!(0, presets_1.isOrientation)(orientation))
279
+ return null;
280
+ // And once more for the dimensions and the derived shape. `0` means "the app
281
+ // did not say", which is the truthful answer for one that predates them —
282
+ // inventing a size would be worse than admitting the gap.
283
+ const cssWidth = raw.cssWidth ?? 0;
284
+ if (typeof cssWidth !== 'number' || !Number.isFinite(cssWidth) || cssWidth < 0)
285
+ return null;
286
+ const cssHeight = raw.cssHeight ?? 0;
287
+ if (typeof cssHeight !== 'number' || !Number.isFinite(cssHeight) || cssHeight < 0)
288
+ return null;
289
+ const reported = raw.screenShape;
290
+ if (reported !== undefined && !(0, presets_1.isOrientation)(reported))
291
+ return null;
292
+ return {
293
+ version,
294
+ url,
295
+ presetId,
296
+ profileId,
297
+ viewMode,
298
+ panes,
299
+ orientation,
300
+ mode,
301
+ tabId,
302
+ tabIndex,
303
+ cssWidth,
304
+ cssHeight,
305
+ screenShape: reported ?? inferScreenShape(cssWidth, cssHeight, presetId, orientation),
306
+ };
237
307
  }
@@ -5,7 +5,9 @@ exports.parseRect = parseRect;
5
5
  exports.parseInputEvent = parseInputEvent;
6
6
  exports.parseDeviceScaleFactor = parseDeviceScaleFactor;
7
7
  exports.parseSettings = parseSettings;
8
+ exports.parseOrientation = parseOrientation;
8
9
  exports.parseMode = parseMode;
10
+ exports.parseTabId = parseTabId;
9
11
  exports.parseUiState = parseUiState;
10
12
  exports.parseScrollPos = parseScrollPos;
11
13
  exports.parseScrollRequest = parseScrollRequest;
@@ -132,13 +134,42 @@ function parseSettings(raw) {
132
134
  const split = raw.split ?? presets_1.DEFAULT_SETTINGS.split;
133
135
  if (!isFiniteNumber(split) || split < presets_1.SPLIT_MIN || split > presets_1.SPLIT_MAX)
134
136
  return null;
135
- return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split };
137
+ // Same shape for the tab cap: absent is the pre-tabs wire shape and means
138
+ // the default, and an out-of-band or fractional count is refused rather
139
+ // than clamped — the Settings input is a bounded integer field, so one
140
+ // arriving here is a bug rather than a hand-edited file.
141
+ const maxTabs = raw.maxTabs ?? presets_1.DEFAULT_SETTINGS.maxTabs;
142
+ if (!isFiniteNumber(maxTabs) || !Number.isInteger(maxTabs) || maxTabs < presets_1.MAX_TABS_MIN || maxTabs > presets_1.MAX_TABS_MAX)
143
+ return null;
144
+ return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split, maxTabs };
145
+ }
146
+ /**
147
+ * An orientation off the wire. Refused rather than defaulted, unlike the
148
+ * absent-field handling inside `parseUiState`: a caller that names an
149
+ * orientation and gets a different one back is worse served than one told its
150
+ * value was not a word this app knows.
151
+ */
152
+ function parseOrientation(raw) {
153
+ return (0, presets_1.isOrientation)(raw) ? raw : null;
136
154
  }
137
155
  function parseMode(raw) {
138
156
  return raw === 'url' || raw === 'image' ? raw : null;
139
157
  }
140
158
  /** Longest preset/profile id the UI-state mirror will store. */
141
159
  const MAX_UI_ID = 64;
160
+ /**
161
+ * Longest tab id main will act on. Ids are minted main-side (`tab-N`), so a
162
+ * renderer message naming one is only ever echoing what it was told; the bound
163
+ * is there because the renderer is still the one sending it. An id no session
164
+ * carries resolves to nothing in the manager and the command is dropped, so
165
+ * shape is all this has to check.
166
+ */
167
+ const MAX_TAB_ID = 64;
168
+ function parseTabId(raw) {
169
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_TAB_ID)
170
+ return null;
171
+ return raw;
172
+ }
142
173
  /**
143
174
  * The renderer's UI-state report (`IPC.uiState`), mirrored main-side so the
144
175
  * agent-control server can answer `status` without a renderer round-trip.
@@ -156,6 +187,12 @@ function parseUiState(raw) {
156
187
  if (!isRecord(raw))
157
188
  return null;
158
189
  const { presetId, profileId, viewMode, mode } = raw;
190
+ // Required, unlike `panes` below: there is no sane default for "which tab
191
+ // this describes", and guessing would reintroduce exactly the misattribution
192
+ // the field exists to stop.
193
+ const tabId = parseTabId(raw.tabId);
194
+ if (tabId === null)
195
+ return null;
159
196
  if (typeof presetId !== 'string' || presetId.length === 0 || presetId.length > MAX_UI_ID)
160
197
  return null;
161
198
  if (typeof profileId !== 'string' || profileId.length === 0 || profileId.length > MAX_UI_ID)
@@ -170,11 +207,17 @@ function parseUiState(raw) {
170
207
  const panes = raw.panes ?? 'both';
171
208
  if (panes !== 'both' && panes !== 'target')
172
209
  return null;
210
+ // Same shape again for the orientation, and for the same reason.
211
+ const orientation = raw.orientation ?? presets_1.DEFAULT_ORIENTATION;
212
+ if (!(0, presets_1.isOrientation)(orientation))
213
+ return null;
173
214
  return {
215
+ tabId,
174
216
  presetId,
175
217
  profileId,
176
218
  viewMode,
177
219
  panes,
220
+ orientation,
178
221
  mode,
179
222
  targetBounds: parseRect(raw.targetBounds),
180
223
  canvasBounds: parseRect(raw.canvasBounds),
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_SETTINGS = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
3
+ exports.PANEL_PROFILES = exports.SCREEN_PRESETS = exports.DEFAULT_ORIENTATION = exports.DEFAULT_SETTINGS = exports.MAX_TABS_MAX = exports.MAX_TABS_MIN = exports.SPLIT_MAX = exports.SPLIT_MIN = exports.MAX_VIEWPORT = void 0;
4
+ exports.isOrientation = isOrientation;
4
5
  exports.findPreset = findPreset;
5
6
  exports.findProfile = findProfile;
6
7
  exports.MAX_VIEWPORT = 4096;
@@ -8,6 +9,10 @@ exports.MAX_VIEWPORT = 4096;
8
9
  * drag's 240px-a-side floor, which only the renderer knows the pixels for. */
9
10
  exports.SPLIT_MIN = 0.1;
10
11
  exports.SPLIT_MAX = 0.9;
12
+ /** The band `Settings.maxTabs` is held to. Below two there is nothing to tab
13
+ * between; above thirty-two the process count is a problem on any machine. */
14
+ exports.MAX_TABS_MIN = 2;
15
+ exports.MAX_TABS_MAX = 32;
11
16
  exports.DEFAULT_SETTINGS = {
12
17
  hostDiagonalInches: 27,
13
18
  hostNits: 500,
@@ -16,7 +21,17 @@ exports.DEFAULT_SETTINGS = {
16
21
  lastUpdateCheck: 0,
17
22
  recordHistory: true,
18
23
  split: 0.5,
24
+ maxTabs: 12,
19
25
  };
26
+ /**
27
+ * Every tab opens unrotated. `'portrait'` means "the preset as stored", so this
28
+ * is a no-op against the table below rather than a shape anyone has to honour.
29
+ */
30
+ exports.DEFAULT_ORIENTATION = 'portrait';
31
+ /** Type guard for anything off the wire, off disk, or off an agent's payload. */
32
+ function isOrientation(v) {
33
+ return v === 'portrait' || v === 'landscape';
34
+ }
20
35
  exports.SCREEN_PRESETS = [
21
36
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
22
37
  { id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.closeTab = closeTab;
4
+ exports.canAddTab = canAddTab;
5
+ exports.tabTitle = tabTitle;
6
+ /**
7
+ * Removes a tab and says which one should take focus. Closing the active tab
8
+ * moves right, because the tab that took its screen position is the one the
9
+ * eye is already on; at the end of the strip there is nothing to the right, so
10
+ * it moves left.
11
+ */
12
+ function closeTab(tabs, closeId, activeId) {
13
+ const index = tabs.findIndex(t => t.id === closeId);
14
+ if (index === -1)
15
+ return { tabs, activeId };
16
+ const next = tabs.filter(t => t.id !== closeId);
17
+ if (next.length === 0)
18
+ return { tabs: next, activeId: null };
19
+ if (closeId !== activeId)
20
+ return { tabs: next, activeId };
21
+ const neighbour = next[Math.min(index, next.length - 1)];
22
+ return { tabs: next, activeId: neighbour.id };
23
+ }
24
+ function canAddTab(count, max) {
25
+ return count < max;
26
+ }
27
+ /**
28
+ * What the strip shows. The port is kept deliberately: two local dev servers
29
+ * differ only by it, and a host-only label would render them identically.
30
+ */
31
+ function tabTitle(url, pageTitle) {
32
+ if (pageTitle.trim() !== '')
33
+ return pageTitle;
34
+ // `about:blank` is where every session starts and what an unused tab still
35
+ // holds, so it is the empty tab spelled in Chromium's words rather than an
36
+ // address anyone typed. It has no host, and falling through to the raw
37
+ // string labels a fresh tab `about:blank` — which the screenshot caught.
38
+ if (url.trim() === '' || url.trim() === 'about:blank')
39
+ return 'New tab';
40
+ try {
41
+ return new URL(url).host || url;
42
+ }
43
+ catch {
44
+ return url;
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {
@@ -61,6 +61,13 @@ when the PNG is taken. Reaching for `obsrv_snap` after a scroll works only
61
61
  when the app is already on that exact URL (it answers `navigated: false`);
62
62
  snapping a different URL is a fresh load and lands back at the top.
63
63
 
64
+ The app can hold several sessions open as tabs, and both tools act on the one
65
+ in **front** — resolved per command, so the user can move it under you. Each
66
+ result names the tab (`tabId`, `tabIndex`); if you drive over several calls and
67
+ the state has to hold, check that `tabId` did not change rather than assuming
68
+ it. You cannot name another tab, and you cannot open, close or switch tabs —
69
+ ask the user. An empty `tabId` is an app older than tabs, which has only one.
70
+
64
71
  ## The loop that catches real regressions
65
72
 
66
73
  1. Snap the dev URL across `--matrix laptop-768,android-65,1080p-24`, plus a