getobsrv 0.12.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.
package/out/cli/args.js CHANGED
@@ -30,6 +30,18 @@ Shared flags:
30
30
  ${presets}
31
31
  --width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
32
32
  Custom CSS viewport instead of --preset (dsf defaults to 1).
33
+ --orientation <o> portrait | landscape (default ${presets_1.DEFAULT_ORIENTATION}). This names the
34
+ preset's *stored* orientation, not the shape you get:
35
+ portrait = the preset exactly as the table above lists it
36
+ landscape = that rotated a quarter turn (width and height swap)
37
+ Every mobile preset is stored portrait, so for those the two
38
+ readings agree. The laptop and desktop presets are stored
39
+ landscape-natural, so --orientation landscape turns them into a
40
+ portrait screen — which is how you render a 1080p monitor stood on
41
+ end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
42
+ dims too. The diagonal, raster density and physical size never
43
+ change: it is the same panel turned sideways. Each render's JSON
44
+ and log line name the resulting shape.
33
45
  --profile <id> Panel profile: ${profiles} (default reference).
34
46
  --wait <ms> Extra settle time after load (default 0).
35
47
  --timeout <ms> Per-render budget for load + paint quiescence (default ${exports.DEFAULT_TIMEOUT_MS}).
@@ -55,7 +67,7 @@ warning naming what was missing. Only a render that painted nothing errors.`;
55
67
  /** Flags that take no value. */
56
68
  const BOOLEAN_FLAGS = new Set(['full-page', 'json']);
57
69
  /** Flags that consume the next token. */
58
- const VALUE_FLAGS = new Set(['preset', 'profile', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
70
+ const VALUE_FLAGS = new Set(['preset', 'profile', 'orientation', 'out', 'out-dir', 'wait', 'timeout', 'matrix', 'width', 'height', 'dsf', 'diagonal']);
59
71
  const SNAP_ONLY = new Set(['out', 'full-page', 'matrix']);
60
72
  const DIFF_ONLY = new Set(['out-dir', 'json']);
61
73
  function collect(command, argv) {
@@ -131,9 +143,30 @@ function presetSpec(id) {
131
143
  cssHeight: preset.height,
132
144
  deviceScaleFactor: preset.deviceScaleFactor,
133
145
  diagonalInches: preset.diagonalInches,
146
+ orientation: presets_1.DEFAULT_ORIENTATION,
134
147
  };
135
148
  }
149
+ function resolveOrientation(flags) {
150
+ const raw = flags.get('orientation');
151
+ if (raw === undefined)
152
+ return presets_1.DEFAULT_ORIENTATION;
153
+ if (!(0, presets_1.isOrientation)(raw))
154
+ throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
155
+ return raw;
156
+ }
157
+ /**
158
+ * Rotation swaps the CSS axes and nothing else — the diagonal and the raster
159
+ * density are orientation-independent, so the render is the same screen turned
160
+ * sideways rather than a different one. Applied here, before the diff bounds
161
+ * are checked, so those check the viewport that will actually be rendered.
162
+ */
163
+ function orientSpec(spec, orientation) {
164
+ if (orientation !== 'landscape')
165
+ return { ...spec, orientation };
166
+ return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
167
+ }
136
168
  function resolveSpecs(flags) {
169
+ const orientation = resolveOrientation(flags);
137
170
  const custom = ['width', 'height', 'dsf', 'diagonal'].some(f => flags.has(f));
138
171
  if (custom && flags.has('preset'))
139
172
  throw new ArgError('--preset and --width/--height are mutually exclusive');
@@ -153,17 +186,25 @@ function resolveSpecs(flags) {
153
186
  throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
154
187
  }
155
188
  const diagonal = flags.has('diagonal') ? float(flags, 'diagonal', 0, 0.1) : null;
156
- return { specs: [{ presetId: 'custom', cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
189
+ const spec = {
190
+ presetId: 'custom',
191
+ cssWidth,
192
+ cssHeight,
193
+ deviceScaleFactor,
194
+ diagonalInches: diagonal,
195
+ orientation: presets_1.DEFAULT_ORIENTATION,
196
+ };
197
+ return { specs: [orientSpec(spec, orientation)], matrix: false };
157
198
  }
158
199
  const matrixRaw = flags.get('matrix');
159
200
  if (typeof matrixRaw === 'string') {
160
201
  const ids = matrixRaw.split(',').map(s => s.trim()).filter(s => s.length > 0);
161
202
  if (ids.length === 0)
162
203
  throw new ArgError('--matrix: expected a comma-separated list of preset ids');
163
- return { specs: ids.map(presetSpec), matrix: true };
204
+ return { specs: ids.map(id => orientSpec(presetSpec(id), orientation)), matrix: true };
164
205
  }
165
206
  const id = typeof flags.get('preset') === 'string' ? flags.get('preset') : exports.DEFAULT_PRESET;
166
- return { specs: [presetSpec(id)], matrix: false };
207
+ return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
167
208
  }
168
209
  function resolveProfile(flags) {
169
210
  const raw = flags.get('profile');
@@ -201,8 +242,14 @@ function parseArgs(argv) {
201
242
  }
202
243
  const referenceMax = (0, calibration_1.maxCssViewport)(2);
203
244
  if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
245
+ // Named as rendered, not as stored: the dims here are post-rotation, and
246
+ // attributing them to the bare preset id would print "1440p-27 is
247
+ // 1440×2560" — a shape that id never has. The bound itself is per-axis
248
+ // symmetric, so rotation can never sneak a too-large viewport past it;
249
+ // this is the message telling the truth about which one it measured.
250
+ const as = spec.orientation === 'landscape' ? ' rotated a quarter turn' : '';
204
251
  throw new ArgError(`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis ` +
205
- `(4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. ` +
252
+ `(4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. ` +
206
253
  `Use \`obsrv snap\` for this preset instead.`);
207
254
  }
208
255
  const outDir = typeof flags.get('out-dir') === 'string' ? flags.get('out-dir') : null;
package/out/main/cli.js CHANGED
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_fs = require("node:fs");
4
4
  const node_os = require("node:os");
5
5
  const node_path = require("node:path");
6
- const targetSource = require("./targetSource-DlsHc-Ri.js");
6
+ const targetSource = require("./targetSource-DuRYHUo8.js");
7
7
  function boxDownsample(src, factor) {
8
8
  if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
9
9
  const width = Math.floor(src.width / factor);
@@ -61,6 +61,18 @@ Shared flags:
61
61
  ${presets}
62
62
  --width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
63
63
  Custom CSS viewport instead of --preset (dsf defaults to 1).
64
+ --orientation <o> portrait | landscape (default ${targetSource.DEFAULT_ORIENTATION}). This names the
65
+ preset's *stored* orientation, not the shape you get:
66
+ portrait = the preset exactly as the table above lists it
67
+ landscape = that rotated a quarter turn (width and height swap)
68
+ Every mobile preset is stored portrait, so for those the two
69
+ readings agree. The laptop and desktop presets are stored
70
+ landscape-natural, so --orientation landscape turns them into a
71
+ portrait screen — which is how you render a 1080p monitor stood on
72
+ end (1080p-24 becomes 1080x1920). Applies to custom --width/--height
73
+ dims too. The diagonal, raster density and physical size never
74
+ change: it is the same panel turned sideways. Each render's JSON
75
+ and log line name the resulting shape.
64
76
  --profile <id> Panel profile: ${profiles} (default reference).
65
77
  --wait <ms> Extra settle time after load (default 0).
66
78
  --timeout <ms> Per-render budget for load + paint quiescence (default ${DEFAULT_TIMEOUT_MS}).
@@ -84,7 +96,7 @@ painted. False is a rescued capture, not a failure: a page that kept animating
84
96
  warning naming what was missing. Only a render that painted nothing errors.`;
85
97
  }
86
98
  const BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["full-page", "json"]);
87
- const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
99
+ const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "orientation", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
88
100
  const SNAP_ONLY = /* @__PURE__ */ new Set(["out", "full-page", "matrix"]);
89
101
  const DIFF_ONLY = /* @__PURE__ */ new Set(["out-dir", "json"]);
90
102
  function collect(command, argv) {
@@ -148,10 +160,22 @@ function presetSpec(id) {
148
160
  cssWidth: preset.width,
149
161
  cssHeight: preset.height,
150
162
  deviceScaleFactor: preset.deviceScaleFactor,
151
- diagonalInches: preset.diagonalInches
163
+ diagonalInches: preset.diagonalInches,
164
+ orientation: targetSource.DEFAULT_ORIENTATION
152
165
  };
153
166
  }
167
+ function resolveOrientation(flags) {
168
+ const raw = flags.get("orientation");
169
+ if (raw === void 0) return targetSource.DEFAULT_ORIENTATION;
170
+ if (!targetSource.isOrientation(raw)) throw new ArgError(`--orientation: expected portrait or landscape, got "${String(raw)}"`);
171
+ return raw;
172
+ }
173
+ function orientSpec(spec, orientation) {
174
+ if (orientation !== "landscape") return { ...spec, orientation };
175
+ return { ...spec, orientation, cssWidth: spec.cssHeight, cssHeight: spec.cssWidth };
176
+ }
154
177
  function resolveSpecs(flags) {
178
+ const orientation = resolveOrientation(flags);
155
179
  const custom = ["width", "height", "dsf", "diagonal"].some((f) => flags.has(f));
156
180
  if (custom && flags.has("preset")) throw new ArgError("--preset and --width/--height are mutually exclusive");
157
181
  if (custom && flags.has("matrix")) throw new ArgError("--matrix lists presets; it cannot be combined with custom --width/--height dims");
@@ -168,16 +192,24 @@ function resolveSpecs(flags) {
168
192
  throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
169
193
  }
170
194
  const diagonal = flags.has("diagonal") ? float(flags, "diagonal", 0, 0.1) : null;
171
- return { specs: [{ presetId: "custom", cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
195
+ const spec = {
196
+ presetId: "custom",
197
+ cssWidth,
198
+ cssHeight,
199
+ deviceScaleFactor,
200
+ diagonalInches: diagonal,
201
+ orientation: targetSource.DEFAULT_ORIENTATION
202
+ };
203
+ return { specs: [orientSpec(spec, orientation)], matrix: false };
172
204
  }
173
205
  const matrixRaw = flags.get("matrix");
174
206
  if (typeof matrixRaw === "string") {
175
207
  const ids = matrixRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
176
208
  if (ids.length === 0) throw new ArgError("--matrix: expected a comma-separated list of preset ids");
177
- return { specs: ids.map(presetSpec), matrix: true };
209
+ return { specs: ids.map((id2) => orientSpec(presetSpec(id2), orientation)), matrix: true };
178
210
  }
179
211
  const id = typeof flags.get("preset") === "string" ? flags.get("preset") : DEFAULT_PRESET;
180
- return { specs: [presetSpec(id)], matrix: false };
212
+ return { specs: [orientSpec(presetSpec(id), orientation)], matrix: false };
181
213
  }
182
214
  function resolveProfile(flags) {
183
215
  const raw = flags.get("profile");
@@ -215,8 +247,9 @@ ${usage()}`);
215
247
  }
216
248
  const referenceMax = targetSource.maxCssViewport(2);
217
249
  if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
250
+ const as = spec.orientation === "landscape" ? " rotated a quarter turn" : "";
218
251
  throw new ArgError(
219
- `diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
252
+ `diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}"${as} is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
220
253
  );
221
254
  }
222
255
  const outDir = typeof flags.get("out-dir") === "string" ? flags.get("out-dir") : null;
@@ -559,8 +592,9 @@ async function runSnap(cmd) {
559
592
  const img = applyPanelProfile(bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height), profile);
560
593
  node_fs.mkdirSync(node_path.dirname(out), { recursive: true });
561
594
  node_fs.writeFileSync(out, encodePng(img));
595
+ const shape = targetSource.screenShape(r.cssWidth, r.cssHeight);
562
596
  human(
563
- `snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, preset ${spec.presetId}, profile ${profile.id})`
597
+ `snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, ${r.cssWidth}×${r.cssHeight} CSS ${shape}, preset ${spec.presetId}, profile ${profile.id})`
564
598
  );
565
599
  results.push({
566
600
  out,
@@ -607,7 +641,7 @@ async function runDiff(cmd) {
607
641
  }
608
642
  const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
609
643
  human(
610
- `diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
644
+ `diff ${cmd.url} @ ${cmd.spec.presetId} (${cmd.spec.cssWidth}×${cmd.spec.cssHeight} CSS ${targetSource.screenShape(cmd.spec.cssWidth, cmd.spec.cssHeight)}, profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)${settled ? "" : " — UNSETTLED, deltas are not rendering evidence"}`
611
645
  );
612
646
  await machine({
613
647
  url: cmd.url,
package/out/main/index.js CHANGED
@@ -4,7 +4,7 @@ const node_fs = require("node:fs");
4
4
  const promises = require("node:fs/promises");
5
5
  const node_path = require("node:path");
6
6
  const node_crypto = require("node:crypto");
7
- const targetSource = require("./targetSource-DlsHc-Ri.js");
7
+ const targetSource = require("./targetSource-DuRYHUo8.js");
8
8
  const node_http = require("node:http");
9
9
  const node_url = require("node:url");
10
10
  const MAX_SCROLL_SELECTOR = 512;
@@ -106,12 +106,15 @@ function parseUiState(raw) {
106
106
  if (mode !== "url" && mode !== "image") return null;
107
107
  const panes = raw.panes ?? "both";
108
108
  if (panes !== "both" && panes !== "target") return null;
109
+ const orientation = raw.orientation ?? targetSource.DEFAULT_ORIENTATION;
110
+ if (!targetSource.isOrientation(orientation)) return null;
109
111
  return {
110
112
  tabId,
111
113
  presetId,
112
114
  profileId,
113
115
  viewMode,
114
116
  panes,
117
+ orientation,
115
118
  mode,
116
119
  targetBounds: parseRect(raw.targetBounds),
117
120
  canvasBounds: parseRect(raw.canvasBounds)
@@ -152,6 +155,7 @@ const CONTROL_COMMANDS = [
152
155
  "setProfile",
153
156
  "setViewMode",
154
157
  "setPanes",
158
+ "setOrientation",
155
159
  "captureVisible",
156
160
  // v0.5 drive controls (spec §14 "Drive controls").
157
161
  "scroll",
@@ -196,6 +200,9 @@ function profileApplyError(id) {
196
200
  function viewModeApplyError(v) {
197
201
  return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
198
202
  }
203
+ function orientationApplyError(v) {
204
+ return targetSource.isOrientation(v) ? null : `setOrientation payload must be { orientation: 'portrait' | 'landscape' }`;
205
+ }
199
206
  function panesApplyError(v) {
200
207
  return v === "both" || v === "target" ? null : `setPanes payload must be { panes: 'both' | 'target' }`;
201
208
  }
@@ -400,7 +407,8 @@ function loadTabs(file, max = Infinity) {
400
407
  tabs.push({
401
408
  url: entry.url,
402
409
  presetId: targetSource.SCREEN_PRESETS.some((p) => p.id === entry.presetId) ? entry.presetId : DEFAULT_PRESET,
403
- profileId: targetSource.PANEL_PROFILES.some((p) => p.id === entry.profileId) ? entry.profileId : DEFAULT_PROFILE
410
+ profileId: targetSource.PANEL_PROFILES.some((p) => p.id === entry.profileId) ? entry.profileId : DEFAULT_PROFILE,
411
+ orientation: targetSource.isOrientation(entry.orientation) ? entry.orientation : targetSource.DEFAULT_ORIENTATION
404
412
  });
405
413
  }
406
414
  const kept = tabs.slice(0, Math.max(0, max));
@@ -587,6 +595,12 @@ class ControlServer {
587
595
  const panes = payload.panes;
588
596
  return this.applyAndConfirm({ panes }, (s) => s.panes === panes);
589
597
  }
598
+ case "setOrientation": {
599
+ const err = orientationApplyError(payload.orientation);
600
+ if (err) return reply(400, { error: err });
601
+ const orientation = payload.orientation;
602
+ return this.applyAndConfirm({ orientation }, (s) => s.orientation === orientation);
603
+ }
590
604
  case "captureVisible": {
591
605
  const capture = await this.deps.captureVisible();
592
606
  return reply(200, { ok: true, ...capture });
@@ -934,6 +948,12 @@ function registerIpc(ctx) {
934
948
  set viewMode(v) {
935
949
  tab().viewMode = v;
936
950
  },
951
+ get orientation() {
952
+ return tab().orientation;
953
+ },
954
+ set orientation(v) {
955
+ tab().orientation = v;
956
+ },
937
957
  get mode() {
938
958
  return tab().reportedMode;
939
959
  },
@@ -1115,7 +1135,12 @@ function registerIpc(ctx) {
1115
1135
  const persistTabs = () => {
1116
1136
  if (!persistReady) return;
1117
1137
  const stored = {
1118
- tabs: tabs.tabs.map((t) => ({ url: t.url, presetId: t.presetId, profileId: t.profileId })),
1138
+ tabs: tabs.tabs.map((t) => ({
1139
+ url: t.url,
1140
+ presetId: t.presetId,
1141
+ profileId: t.profileId,
1142
+ orientation: t.orientation
1143
+ })),
1119
1144
  activeIndex: tabs.activeIndex
1120
1145
  };
1121
1146
  const json = JSON.stringify(stored);
@@ -1137,6 +1162,7 @@ function registerIpc(ctx) {
1137
1162
  if (!s) break;
1138
1163
  s.presetId = entry.presetId;
1139
1164
  s.profileId = entry.profileId;
1165
+ s.orientation = entry.orientation;
1140
1166
  sessions.push(s);
1141
1167
  }
1142
1168
  tabs.activate(sessions[Math.min(stored.activeIndex, sessions.length - 1)].id);
@@ -1176,14 +1202,30 @@ function registerIpc(ctx) {
1176
1202
  url = tab().target.webContents.getURL();
1177
1203
  } catch {
1178
1204
  }
1179
- return { version: appVersion, url, tabId: tabs.activeId, tabIndex: tabs.activeIndex, ...uiState };
1205
+ let css = { width: 0, height: 0 };
1206
+ try {
1207
+ css = tab().target.getViewport();
1208
+ } catch {
1209
+ }
1210
+ return {
1211
+ version: appVersion,
1212
+ url,
1213
+ tabId: tabs.activeId,
1214
+ tabIndex: tabs.activeIndex,
1215
+ cssWidth: css.width,
1216
+ cssHeight: css.height,
1217
+ screenShape: targetSource.screenShape(css.width, css.height),
1218
+ ...uiState
1219
+ };
1180
1220
  },
1181
1221
  navigate: navigateBoth,
1182
1222
  apply: (patch) => {
1183
1223
  if (win.isDestroyed()) return;
1184
- if (patch.presetId !== void 0) {
1185
- tab().viewportPending = true;
1186
- tab().viewportArrived = false;
1224
+ const s = tab();
1225
+ const resizes = patch.presetId !== void 0 && patch.presetId !== s.presetId || patch.orientation !== void 0 && patch.orientation !== s.orientation;
1226
+ if (resizes) {
1227
+ s.viewportPending = true;
1228
+ s.viewportArrived = false;
1187
1229
  }
1188
1230
  if (!rendererReported) {
1189
1231
  if (pendingApplies.length >= MAX_PENDING_APPLIES) {
@@ -1605,6 +1647,13 @@ class TabSession {
1605
1647
  viewportArrived = false;
1606
1648
  presetId = "1080p-24";
1607
1649
  profileId = "reference";
1650
+ /**
1651
+ * Which way round this tab's screen is held. Per tab like the preset it
1652
+ * rotates, and mirrored here from the renderer's `uiState` for the same
1653
+ * reason: a restored tab has to come back the way it was left, and no
1654
+ * renderer existed to report that when the list came off disk.
1655
+ */
1656
+ orientation = targetSource.DEFAULT_ORIENTATION;
1608
1657
  viewMode = "fit";
1609
1658
  /**
1610
1659
  * Resolves once both panes have settled on their initial `about:blank`.
@@ -1824,7 +1873,8 @@ class TabManager {
1824
1873
  url: t.url,
1825
1874
  title: t.title,
1826
1875
  presetId: t.presetId,
1827
- profileId: t.profileId
1876
+ profileId: t.profileId,
1877
+ orientation: t.orientation
1828
1878
  })),
1829
1879
  activeId: this.id
1830
1880
  };
@@ -17,6 +17,10 @@ const DEFAULT_SETTINGS = {
17
17
  split: 0.5,
18
18
  maxTabs: 12
19
19
  };
20
+ const DEFAULT_ORIENTATION = "portrait";
21
+ function isOrientation(v) {
22
+ return v === "portrait" || v === "landscape";
23
+ }
20
24
  const SCREEN_PRESETS = [
21
25
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
22
26
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -55,6 +59,9 @@ function findProfile(id) {
55
59
  if (!p) throw new Error(`unknown profile: ${id}`);
56
60
  return p;
57
61
  }
62
+ function screenShape(width, height) {
63
+ return width > height ? "landscape" : "portrait";
64
+ }
58
65
  function maxCssViewport(deviceScaleFactor) {
59
66
  const dsf = Number.isFinite(deviceScaleFactor) && deviceScaleFactor > 1 ? deviceScaleFactor : 1;
60
67
  return Math.max(1, Math.floor(MAX_VIEWPORT / dsf));
@@ -410,6 +417,7 @@ class TargetSource extends node_events.EventEmitter {
410
417
  }
411
418
  }
412
419
  exports.ALLOWED_URL_SCHEMES = ALLOWED_URL_SCHEMES;
420
+ exports.DEFAULT_ORIENTATION = DEFAULT_ORIENTATION;
413
421
  exports.DEFAULT_SETTINGS = DEFAULT_SETTINGS;
414
422
  exports.IMAGE_EXTENSIONS = IMAGE_EXTENSIONS;
415
423
  exports.MAX_TABS_MAX = MAX_TABS_MAX;
@@ -422,6 +430,8 @@ exports.TargetSource = TargetSource;
422
430
  exports.classifyFileNavigation = classifyFileNavigation;
423
431
  exports.findPreset = findPreset;
424
432
  exports.findProfile = findProfile;
433
+ exports.isOrientation = isOrientation;
425
434
  exports.maxCssViewport = maxCssViewport;
426
435
  exports.normalizeUrl = normalizeUrl;
436
+ exports.screenShape = screenShape;
427
437
  exports.urlSchemeError = urlSchemeError;
package/out/mcp/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PANE_CAPTURE_HEADLESS_NOTE = exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
3
+ exports.ORIENTATION_NOTE = exports.PANE_CAPTURE_HEADLESS_NOTE = exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
4
4
  exports.buildSnapArgs = buildSnapArgs;
5
5
  exports.buildDiffArgs = buildDiffArgs;
6
6
  exports.shouldInlineImage = shouldInlineImage;
@@ -46,6 +46,8 @@ function buildSnapArgs(input, outPath) {
46
46
  const args = ['snap', input.url];
47
47
  if (input.preset !== undefined)
48
48
  args.push('--preset', input.preset);
49
+ if (input.orientation !== undefined)
50
+ args.push('--orientation', input.orientation);
49
51
  if (custom) {
50
52
  args.push('--width', String(input.width), '--height', String(input.height));
51
53
  if (input.deviceScaleFactor !== undefined)
@@ -170,9 +172,20 @@ function extractTrailingJson(stdout) {
170
172
  }
171
173
  return null;
172
174
  }
175
+ /**
176
+ * What `obsrv_presets` says about rotation. Stated once here rather than
177
+ * repeated per entry: it is true of every preset in the table, and a field
178
+ * saying "rotatable: true" fourteen times would carry less than one sentence.
179
+ */
180
+ exports.ORIENTATION_NOTE = 'cssWidth/cssHeight are each preset\'s natural orientation — portrait for every mobile preset, ' +
181
+ 'landscape for the monitor and laptop ones. Every preset rotates: pass orientation: "landscape" ' +
182
+ 'to obsrv_snap or obsrv_drive to swap the two axes a quarter turn. Rotation changes nothing else — ' +
183
+ 'the diagonal, deviceScaleFactor, ppi and physical size are all orientation-independent, so a ' +
184
+ 'rotated screen is the same panel turned sideways rather than a different one.';
173
185
  /** The `obsrv_presets` payload, straight from src/shared/presets.ts — no spawn. */
174
186
  function listCatalog() {
175
187
  return {
188
+ orientation: exports.ORIENTATION_NOTE,
176
189
  presets: presets_1.SCREEN_PRESETS.map(p => ({
177
190
  id: p.id,
178
191
  label: p.label,
package/out/mcp/server.js CHANGED
@@ -97,12 +97,21 @@ const profileField = zod_1.z
97
97
  .enum(PROFILE_IDS)
98
98
  .optional()
99
99
  .describe('Panel simulation (contrast floor, gamut, bit depth, brightness). Default: reference (off).');
100
+ const orientationField = zod_1.z
101
+ .enum(['portrait', 'landscape'])
102
+ .optional()
103
+ .describe('Rotate the screen a quarter turn (default portrait). Presets store their natural orientation — ' +
104
+ 'portrait for every mobile preset, landscape for the monitors and laptops — and this swaps the CSS ' +
105
+ "viewport's two axes on top of that. Nothing else changes: the diagonal, raster density and physical " +
106
+ 'size are orientation-independent, so it is the same panel turned sideways. Use it to check a ' +
107
+ 'landscape phone layout, or a monitor stood on end.');
100
108
  const snapInputShape = {
101
109
  url: urlField,
102
110
  preset: zod_1.z
103
111
  .enum(PRESET_IDS)
104
112
  .optional()
105
113
  .describe('Screen preset id (list them with obsrv_presets). Mutually exclusive with width/height. Default: 1080p-24.'),
114
+ orientation: orientationField,
106
115
  width: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport width in px. Needs height; mutually exclusive with preset.'),
107
116
  height: zod_1.z.number().int().min(1).optional().describe('Custom CSS viewport height in px. Needs width.'),
108
117
  deviceScaleFactor: zod_1.z
@@ -138,8 +147,24 @@ const snapOutputShape = {
138
147
  .describe('How the snap was produced: a headless render, or a capture of the visible Obsrv app window (live drive).'),
139
148
  out: zod_1.z.string().optional().describe('Headless only: PNG path the CLI wrote (same file as pngPath).'),
140
149
  preset: zod_1.z.string().optional().describe('Headless only: preset id, or "custom" for width/height runs.'),
141
- cssWidth: zod_1.z.number().optional().describe('Headless only: applied CSS viewport width.'),
142
- cssHeight: zod_1.z.number().optional().describe('Headless only: applied CSS viewport height (grown under fullPage).'),
150
+ cssWidth: zod_1.z
151
+ .number()
152
+ .optional()
153
+ .describe('Applied CSS viewport width, already rotated. Headless: grown under fullPage. Live: what the app is rendering.'),
154
+ cssHeight: zod_1.z
155
+ .number()
156
+ .optional()
157
+ .describe('Applied CSS viewport height, already rotated. Headless: grown under fullPage. Live: what the app is rendering.'),
158
+ orientation: zod_1.z
159
+ .string()
160
+ .optional()
161
+ .describe("Live only: the app's rotation flag — 'portrait' (the preset as its table stores it) or 'landscape' " +
162
+ '(rotated a quarter turn). See `screenShape` for the shape that produced. Headless runs report the ' +
163
+ 'applied `cssWidth`/`cssHeight` instead, which say the same thing exactly.'),
164
+ screenShape: zod_1.z.string().optional().describe('Live only. ' + "The shape the screen actually has: 'portrait' or 'landscape'. Derived from the CSS dimensions, not from " +
165
+ "the `orientation` flag beside it — the flag means 'the preset as its table stores it' vs 'rotated a " +
166
+ "quarter turn', so for a landscape-natural monitor preset the two diverge (a fresh 1080p-24 tab is " +
167
+ "orientation 'portrait' on a 1920x1080 landscape screen). Report this word to the user, not the flag."),
143
168
  deviceScaleFactor: zod_1.z.number().optional().describe('Headless only.'),
144
169
  profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
145
170
  settled: zod_1.z
@@ -218,6 +243,9 @@ const diffOutputShape = {
218
243
  findings: zod_1.z.array(zod_1.z.string()).describe('Humanised per-band findings. Informational — thresholds are the caller\'s job.'),
219
244
  };
220
245
  const presetsOutputShape = {
246
+ orientation: zod_1.z
247
+ .string()
248
+ .describe('How the cssWidth/cssHeight below relate to rotation, and how to ask for the other orientation.'),
221
249
  presets: zod_1.z.array(zod_1.z.object({
222
250
  id: zod_1.z.string(),
223
251
  label: zod_1.z.string(),
@@ -246,6 +274,7 @@ const driveInputShape = {
246
274
  .optional()
247
275
  .describe('Navigate the app (both panes) to this http://, https:// or file:// URL (bare hosts also work).'),
248
276
  preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
277
+ orientation: orientationField,
249
278
  profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
250
279
  viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
251
280
  panes: zod_1.z
@@ -309,6 +338,19 @@ const driveOutputShape = {
309
338
  url: zod_1.z.string().describe('The URL the target pane reports showing.'),
310
339
  presetId: zod_1.z.string(),
311
340
  profileId: zod_1.z.string(),
341
+ orientation: zod_1.z
342
+ .string()
343
+ .describe("The rotation flag: 'portrait' (the preset as its table stores it) or 'landscape' (rotated a quarter " +
344
+ "turn). This is what to pass back to change it — for the shape the screen actually has, read " +
345
+ '`screenShape`. Reported as \'portrait\' by an app older than rotation, which is what such an app shows.'),
346
+ screenShape: zod_1.z.string().describe("The shape the screen actually has: 'portrait' or 'landscape'. Derived from the CSS dimensions, not from " +
347
+ "the `orientation` flag beside it — the flag means 'the preset as its table stores it' vs 'rotated a " +
348
+ "quarter turn', so for a landscape-natural monitor preset the two diverge (a fresh 1080p-24 tab is " +
349
+ "orientation 'portrait' on a 1920x1080 landscape screen). Report this word to the user, not the flag."),
350
+ cssWidth: zod_1.z
351
+ .number()
352
+ .describe('The CSS viewport the target is rendering at, already rotated. 0 from an app that predates the field.'),
353
+ cssHeight: zod_1.z.number().describe('The CSS viewport height, already rotated. 0 from an app that predates the field.'),
312
354
  viewMode: zod_1.z.string(),
313
355
  panes: zod_1.z.string(),
314
356
  mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
@@ -442,6 +484,9 @@ async function liveSnap(app, input, notes) {
442
484
  }
443
485
  if (input.preset !== undefined)
444
486
  await (0, control_2.controlCall)(info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
487
+ if (input.orientation !== undefined) {
488
+ await (0, control_2.controlCall)(info, 'setOrientation', { orientation: input.orientation }, LIVE_APPLY_TIMEOUT_MS);
489
+ }
445
490
  if (input.profile !== undefined)
446
491
  await (0, control_2.controlCall)(info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
447
492
  }
@@ -488,6 +533,10 @@ async function liveSnap(app, input, notes) {
488
533
  url: status.url,
489
534
  presetId: status.presetId,
490
535
  profileId: status.profileId,
536
+ orientation: status.orientation,
537
+ screenShape: status.screenShape,
538
+ cssWidth: status.cssWidth,
539
+ cssHeight: status.cssHeight,
491
540
  viewMode: status.viewMode,
492
541
  panes: status.panes,
493
542
  tabId: status.tabId,
@@ -515,7 +564,8 @@ server.registerTool('obsrv_snap', {
515
564
  `presets, the device's 2x/3x DPR plus mobile UA and viewport semantics for phone presets — optionally ` +
516
565
  `through a cheap-panel simulation, and return the PNG. Use it to judge how a page actually looks on the ` +
517
566
  `screens users own (1366×768 laptops, 1080p desktops, budget Androids) before declaring frontend work done.\n\n` +
518
- `Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both. ` +
567
+ `Pass either \`preset\` (list ids with obsrv_presets) or custom \`width\` + \`height\`, never both; either can be ` +
568
+ `rotated with \`orientation: "landscape"\`, which is how you check a phone's landscape layout. ` +
519
569
  `Returns structured metadata (applied viewport, profile, \`settled\`, warnings, and \`pngPath\` — the PNG ` +
520
570
  `kept in a per-call temp dir) plus the PNG as an inline image when it is within the 1.5 MiB cap; larger ` +
521
571
  `captures (typically fullPage) stay on disk with a note.\n\n` +
@@ -635,15 +685,16 @@ server.registerTool('obsrv_diff', {
635
685
  });
636
686
  server.registerTool('obsrv_drive', {
637
687
  title: 'Drive the visible Obsrv app',
638
- description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
639
- `profile, the target pane's 1:1/fit view or pixel-exact toggle — each exactly as clicking the toolbar would ` +
688
+ description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, rotate that ` +
689
+ `screen to landscape or portrait, apply a panel profile, the target pane's 1:1/fit view or pixel-exact ` +
690
+ `toggle — each exactly as clicking the toolbar would ` +
640
691
  `— and steer the session like a guided demo: focus the window, step history (back/forward/reload), scroll ` +
641
692
  `both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
642
693
  `neutral marker, all while the user watches.\n\n` +
643
694
  `Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
644
- `preset → profile → viewMode → panes → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
695
+ `preset → orientation → profile → viewMode → panes → pixelExact → reload → back → forward → scroll → panTo → click → highlight → ` +
645
696
  `capture. ` +
646
- `The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
697
+ `The result is the final status: app version, the URL showing, and the selected preset/orientation/profile/view. A ` +
647
698
  `click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
648
699
  `scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
649
700
  `\`scrolled\` with what you asked for rather than trusting the call's success, and use \`scroll.scrollSelector\` ` +
@@ -686,6 +737,12 @@ server.registerTool('obsrv_drive', {
686
737
  }
687
738
  if (input.preset !== undefined)
688
739
  await (0, control_2.controlCall)(live.info, 'setPreset', { id: input.preset }, LIVE_APPLY_TIMEOUT_MS);
740
+ // After the preset, before everything else: rotation is applied on top of
741
+ // whichever screen is in force, so a call carrying both has to land in
742
+ // that order or the rotation would be spent on the outgoing preset.
743
+ if (input.orientation !== undefined) {
744
+ await (0, control_2.controlCall)(live.info, 'setOrientation', { orientation: input.orientation }, LIVE_APPLY_TIMEOUT_MS);
745
+ }
689
746
  if (input.profile !== undefined)
690
747
  await (0, control_2.controlCall)(live.info, 'setProfile', { id: input.profile }, LIVE_APPLY_TIMEOUT_MS);
691
748
  if (input.viewMode !== undefined) {
@@ -777,7 +834,9 @@ server.registerTool('obsrv_presets', {
777
834
  title: 'List screen presets and panel profiles',
778
835
  description: `List every screen preset (id, label, group, CSS dims, deviceScaleFactor, panel diagonal, derived physical ` +
779
836
  `ppi) and panel profile (id, label, simulation params) accepted by obsrv_snap and obsrv_diff. Read straight ` +
780
- `from the app's preset table — nothing is rendered.`,
837
+ `from the app's preset table — nothing is rendered. The dimensions are each preset's natural orientation ` +
838
+ `(portrait for the mobile ones, landscape for the monitors); every preset also rotates — see the ` +
839
+ `\`orientation\` note in the result.`,
781
840
  inputSchema: {},
782
841
  outputSchema: presetsOutputShape,
783
842
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -12702,6 +12702,7 @@ const DEFAULT_SETTINGS = {
12702
12702
  split: 0.5,
12703
12703
  maxTabs: 12
12704
12704
  };
12705
+ const DEFAULT_ORIENTATION = "portrait";
12705
12706
  const SCREEN_PRESETS = [
12706
12707
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
12707
12708
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -12735,6 +12736,13 @@ function findProfile(id) {
12735
12736
  if (!p) throw new Error(`unknown profile: ${id}`);
12736
12737
  return p;
12737
12738
  }
12739
+ function applyOrientation(screen, orientation) {
12740
+ if (orientation !== "landscape") return screen;
12741
+ return { ...screen, width: screen.height, height: screen.width };
12742
+ }
12743
+ function screenShape(width, height) {
12744
+ return width > height ? "landscape" : "portrait";
12745
+ }
12738
12746
  function ppi(width, height, diagonalInches) {
12739
12747
  if (!(diagonalInches > 0)) throw new RangeError("diagonalInches must be > 0");
12740
12748
  return Math.hypot(width, height) / diagonalInches;
@@ -12799,6 +12807,7 @@ function blankTab() {
12799
12807
  title: "",
12800
12808
  lastUrl: "",
12801
12809
  presetId: "1080p-24",
12810
+ orientation: DEFAULT_ORIENTATION,
12802
12811
  custom: { width: 1920, height: 1080, diagonalInches: 24 },
12803
12812
  pixelExact: false,
12804
12813
  profileId: PANEL_PROFILES[0].id,
@@ -12860,6 +12869,11 @@ const useStore = create()((set, get) => ({
12860
12869
  // A screen change re-rasters the target, so a highlight's target-pixel rect
12861
12870
  // no longer marks what it marked; the same for the custom fields below.
12862
12871
  setPreset: (presetId) => set(patchActive({ presetId, agentHighlight: null })),
12872
+ // A rotation re-rasters the target exactly as a preset change does, so a
12873
+ // highlight's target-pixel rect no longer marks what it marked. Setting the
12874
+ // orientation already in force writes nothing, so a re-report from an agent
12875
+ // (or a click on the pressed half of the control) costs no re-render.
12876
+ setOrientation: (orientation) => set(patchActiveWith((t) => t.orientation === orientation ? null : { orientation, agentHighlight: null })),
12863
12877
  setCustom: (c) => set(patchActiveWith((t) => ({ custom: { ...t.custom, ...c }, presetId: CUSTOM_PRESET_ID, agentHighlight: null }))),
12864
12878
  setPixelExact: (pixelExact) => set(patchActive({ pixelExact })),
12865
12879
  // Picking a profile drops any hand-tuned slider values.
@@ -12914,7 +12928,8 @@ const useStore = create()((set, get) => ({
12914
12928
  url: info.url,
12915
12929
  title: info.title,
12916
12930
  presetId: info.presetId,
12917
- profileId: info.profileId
12931
+ profileId: info.profileId,
12932
+ orientation: info.orientation
12918
12933
  }
12919
12934
  );
12920
12935
  }
@@ -12952,7 +12967,7 @@ const useStore = create()((set, get) => ({
12952
12967
  function selectTab(s) {
12953
12968
  return s.tabs[s.activeId];
12954
12969
  }
12955
- function selectScreen(s) {
12970
+ function naturalScreen(s) {
12956
12971
  const tab = selectTab(s);
12957
12972
  const preset = SCREEN_PRESETS.find((p) => p.id === tab.presetId);
12958
12973
  return preset ? {
@@ -12962,6 +12977,21 @@ function selectScreen(s) {
12962
12977
  deviceScaleFactor: preset.deviceScaleFactor
12963
12978
  } : tab.custom;
12964
12979
  }
12980
+ function selectScreen(s) {
12981
+ return applyOrientation(naturalScreen(s), selectTab(s).orientation);
12982
+ }
12983
+ function selectScreenShape(s) {
12984
+ const screen = selectScreen(s);
12985
+ return screenShape(screen.width, screen.height);
12986
+ }
12987
+ const ORIENTATIONS = ["portrait", "landscape"];
12988
+ function selectOrientationShapes(s) {
12989
+ const natural = naturalScreen(s);
12990
+ return ORIENTATIONS.map((value) => {
12991
+ const r = applyOrientation(natural, value);
12992
+ return screenShape(r.width, r.height);
12993
+ });
12994
+ }
12965
12995
  function selectDeviceScaleFactor(s) {
12966
12996
  return selectScreen(s).deviceScaleFactor ?? 1;
12967
12997
  }
@@ -13183,7 +13213,8 @@ function TargetFooter() {
13183
13213
  const viewMode = useStore((s) => selectTab(s).viewMode);
13184
13214
  const fitScale2 = useStore((s) => selectTab(s).fitScale);
13185
13215
  const dsf = useStore(selectDeviceScaleFactor);
13186
- const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""}`;
13216
+ const shape = useStore(selectScreenShape);
13217
+ const size = mode === "image" && image ? `${image.width}×${image.height}` : `${viewport.width}×${viewport.height}${dsf > 1 ? ` @${dsf}x` : ""} ${shape}`;
13187
13218
  const depth = params.levels <= 63 ? "6-bit" : "8-bit";
13188
13219
  const magnification = viewMode === "fit" && fitScale2 !== null ? [`fit ×${fitScale2.toFixed(2)}`, "not pixel-exact"] : [`×${scale.toFixed(2)}`];
13189
13220
  return /* @__PURE__ */ jsxRuntimeExports.jsx(
@@ -13526,6 +13557,7 @@ function SettingsPanel() {
13526
13557
  const update = useStore((s) => s.update);
13527
13558
  const history = useStore((s) => s.history);
13528
13559
  const custom = useStore(useShallow((s) => selectTab(s).custom));
13560
+ const orientation = useStore((s) => selectTab(s).orientation);
13529
13561
  const viewport = useStore(useShallow(selectViewport));
13530
13562
  const scale = useStore(selectScale);
13531
13563
  const fallback = useStore(selectScaleIsFallback);
@@ -13598,6 +13630,15 @@ function SettingsPanel() {
13598
13630
  ] }),
13599
13631
  /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Custom screen" }),
13600
13632
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "muted", children: "Editing these selects the Custom preset." }),
13633
+ orientation === "landscape" && /* @__PURE__ */ jsxRuntimeExports.jsxs("p", { className: "muted custom-rotated", children: [
13634
+ "Rotated, so these render transposed:",
13635
+ " ",
13636
+ (() => {
13637
+ const r = applyOrientation({ ...custom }, orientation);
13638
+ return `${r.width}×${r.height}`;
13639
+ })(),
13640
+ "."
13641
+ ] }),
13601
13642
  /* @__PURE__ */ jsxRuntimeExports.jsx(
13602
13643
  NumberField,
13603
13644
  {
@@ -14698,53 +14739,73 @@ const createLucideIcon = (iconName, iconNode) => {
14698
14739
  * This source code is licensed under the ISC license.
14699
14740
  * See the LICENSE file in the root directory of this source tree.
14700
14741
  */
14701
- const __iconNode$8 = [
14742
+ const __iconNode$a = [
14702
14743
  ["path", { d: "m12 19-7-7 7-7", key: "1l729n" }],
14703
14744
  ["path", { d: "M19 12H5", key: "x3x0zl" }]
14704
14745
  ];
14705
- const ArrowLeft = createLucideIcon("arrow-left", __iconNode$8);
14746
+ const ArrowLeft = createLucideIcon("arrow-left", __iconNode$a);
14706
14747
  /**
14707
14748
  * @license lucide-react v1.34.0 - ISC
14708
14749
  *
14709
14750
  * This source code is licensed under the ISC license.
14710
14751
  * See the LICENSE file in the root directory of this source tree.
14711
14752
  */
14712
- const __iconNode$7 = [
14753
+ const __iconNode$9 = [
14713
14754
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14714
14755
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
14715
14756
  ];
14716
- const ArrowRight = createLucideIcon("arrow-right", __iconNode$7);
14757
+ const ArrowRight = createLucideIcon("arrow-right", __iconNode$9);
14717
14758
  /**
14718
14759
  * @license lucide-react v1.34.0 - ISC
14719
14760
  *
14720
14761
  * This source code is licensed under the ISC license.
14721
14762
  * See the LICENSE file in the root directory of this source tree.
14722
14763
  */
14723
- const __iconNode$6 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14724
- const ChevronDown = createLucideIcon("chevron-down", __iconNode$6);
14764
+ const __iconNode$8 = [["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }]];
14765
+ const ChevronDown = createLucideIcon("chevron-down", __iconNode$8);
14725
14766
  /**
14726
14767
  * @license lucide-react v1.34.0 - ISC
14727
14768
  *
14728
14769
  * This source code is licensed under the ISC license.
14729
14770
  * See the LICENSE file in the root directory of this source tree.
14730
14771
  */
14731
- const __iconNode$5 = [
14772
+ const __iconNode$7 = [
14732
14773
  ["circle", { cx: "12", cy: "12", r: "1", key: "41hilf" }],
14733
14774
  ["circle", { cx: "12", cy: "5", r: "1", key: "gxeob9" }],
14734
14775
  ["circle", { cx: "12", cy: "19", r: "1", key: "lyex9k" }]
14735
14776
  ];
14736
- const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$5);
14777
+ const EllipsisVertical = createLucideIcon("ellipsis-vertical", __iconNode$7);
14737
14778
  /**
14738
14779
  * @license lucide-react v1.34.0 - ISC
14739
14780
  *
14740
14781
  * This source code is licensed under the ISC license.
14741
14782
  * See the LICENSE file in the root directory of this source tree.
14742
14783
  */
14743
- const __iconNode$4 = [
14784
+ const __iconNode$6 = [
14744
14785
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14745
14786
  ["path", { d: "M12 5v14", key: "s699le" }]
14746
14787
  ];
14747
- const Plus = createLucideIcon("plus", __iconNode$4);
14788
+ const Plus = createLucideIcon("plus", __iconNode$6);
14789
+ /**
14790
+ * @license lucide-react v1.34.0 - ISC
14791
+ *
14792
+ * This source code is licensed under the ISC license.
14793
+ * See the LICENSE file in the root directory of this source tree.
14794
+ */
14795
+ const __iconNode$5 = [
14796
+ ["rect", { width: "20", height: "12", x: "2", y: "6", rx: "2", key: "9lu3g6" }]
14797
+ ];
14798
+ const RectangleHorizontal = createLucideIcon("rectangle-horizontal", __iconNode$5);
14799
+ /**
14800
+ * @license lucide-react v1.34.0 - ISC
14801
+ *
14802
+ * This source code is licensed under the ISC license.
14803
+ * See the LICENSE file in the root directory of this source tree.
14804
+ */
14805
+ const __iconNode$4 = [
14806
+ ["rect", { width: "12", height: "20", x: "6", y: "2", rx: "2", key: "1oxtiu" }]
14807
+ ];
14808
+ const RectangleVertical = createLucideIcon("rectangle-vertical", __iconNode$4);
14748
14809
  /**
14749
14810
  * @license lucide-react v1.34.0 - ISC
14750
14811
  *
@@ -14811,7 +14872,12 @@ const ICONS = {
14811
14872
  sliders: SlidersHorizontal,
14812
14873
  gear: Settings,
14813
14874
  chevron: ChevronDown,
14814
- plus: Plus
14875
+ plus: Plus,
14876
+ // The rotate control's two shapes. A plain outline of the screen you get is
14877
+ // the whole affordance — no arrow, no device silhouette: the target may be a
14878
+ // monitor as readily as a phone.
14879
+ portrait: RectangleVertical,
14880
+ landscape: RectangleHorizontal
14815
14881
  };
14816
14882
  function Icon({ name, size = 16 }) {
14817
14883
  const Glyph = ICONS[name];
@@ -14994,6 +15060,9 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
14994
15060
  const setUrl = useStore((s) => s.setUrl);
14995
15061
  const setMode = useStore((s) => s.setMode);
14996
15062
  const setPreset = useStore((s) => s.setPreset);
15063
+ const orientation = useStore((s) => selectTab(s).orientation);
15064
+ const orientationShapes = useStore(useShallow(selectOrientationShapes));
15065
+ const setOrientation = useStore((s) => s.setOrientation);
14997
15066
  const setProfile = useStore((s) => s.setProfile);
14998
15067
  const setPixelExact = useStore((s) => s.setPixelExact);
14999
15068
  const setError = useStore((s) => s.setError);
@@ -15266,6 +15335,23 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15266
15335
  ]
15267
15336
  }
15268
15337
  ),
15338
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "orientation-control", role: "group", "aria-label": "Screen orientation", children: ORIENTATIONS.map((value, i) => {
15339
+ const shape = orientationShapes[i] ?? value;
15340
+ const name = shape === "landscape" ? "Landscape" : "Portrait";
15341
+ return /* @__PURE__ */ jsxRuntimeExports.jsx(
15342
+ "button",
15343
+ {
15344
+ type: "button",
15345
+ className: `orient-${shape}`,
15346
+ title: `${name} — ${value === orientation ? "showing" : "rotate the screen"}`,
15347
+ "aria-label": name,
15348
+ "aria-pressed": orientation === value,
15349
+ onClick: () => setOrientation(value),
15350
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: shape })
15351
+ },
15352
+ value
15353
+ );
15354
+ }) }),
15269
15355
  /* @__PURE__ */ jsxRuntimeExports.jsx(
15270
15356
  Segmented,
15271
15357
  {
@@ -15342,6 +15428,7 @@ function App() {
15342
15428
  const deviceScaleFactor = useStore(selectDeviceScaleFactor);
15343
15429
  const presetId = useStore((s) => selectTab(s).presetId);
15344
15430
  const profileId = useStore((s) => selectTab(s).profileId);
15431
+ const orientation = useStore((s) => selectTab(s).orientation);
15345
15432
  const viewMode = useStore((s) => selectTab(s).viewMode);
15346
15433
  const activeId = useStore((s) => s.activeId);
15347
15434
  const tabOrder = useStore((s) => s.tabOrder);
@@ -15422,13 +15509,14 @@ function App() {
15422
15509
  }, []);
15423
15510
  reactExports.useEffect(() => {
15424
15511
  if (!tabsKnown) return;
15425
- window.obsrv.reportUiState({ tabId: activeId, presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds });
15426
- }, [tabsKnown, activeId, presetId, profileId, viewMode, panes, mode, targetBounds, canvasBounds]);
15512
+ window.obsrv.reportUiState({ tabId: activeId, presetId, profileId, orientation, viewMode, panes, mode, targetBounds, canvasBounds });
15513
+ }, [tabsKnown, activeId, presetId, profileId, orientation, viewMode, panes, mode, targetBounds, canvasBounds]);
15427
15514
  reactExports.useEffect(() => {
15428
15515
  return window.obsrv.onAgentApply((patch) => {
15429
15516
  const s = useStore.getState();
15430
15517
  if (patch.presetId !== void 0) s.setPreset(patch.presetId);
15431
15518
  if (patch.profileId !== void 0) s.setProfile(patch.profileId);
15519
+ if (patch.orientation !== void 0) s.setOrientation(patch.orientation);
15432
15520
  if (patch.viewMode !== void 0) s.setViewMode(patch.viewMode);
15433
15521
  if (patch.panes !== void 0) s.setPanes(patch.panes);
15434
15522
  if (patch.pixelExact !== void 0) s.setPixelExact(patch.pixelExact);
@@ -639,6 +639,41 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
639
639
  }
640
640
  .pane-footer .role { color: var(--text-0); letter-spacing: 0.08em; }
641
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
+
642
677
  /* Segmented surround control: the same 30px border-box as `.segmented` and
643
678
  `.select-shell`, so the screen row has one hit-target rhythm. `box-sizing`
644
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-gYDbIgqJ.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DvwDBeho.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))
@@ -245,5 +268,40 @@ function parseControlStatus(raw) {
245
268
  const tabIndex = raw.tabIndex ?? 0;
246
269
  if (typeof tabIndex !== 'number' || !Number.isInteger(tabIndex) || tabIndex < 0)
247
270
  return null;
248
- return { version, url, presetId, profileId, viewMode, panes, mode, tabId, tabIndex };
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
+ };
249
307
  }
@@ -5,6 +5,7 @@ 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;
9
10
  exports.parseTabId = parseTabId;
10
11
  exports.parseUiState = parseUiState;
@@ -142,6 +143,15 @@ function parseSettings(raw) {
142
143
  return null;
143
144
  return { hostDiagonalInches, hostNits, agentControl, updateCheck, lastUpdateCheck, recordHistory, split, maxTabs };
144
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;
154
+ }
145
155
  function parseMode(raw) {
146
156
  return raw === 'url' || raw === 'image' ? raw : null;
147
157
  }
@@ -197,12 +207,17 @@ function parseUiState(raw) {
197
207
  const panes = raw.panes ?? 'both';
198
208
  if (panes !== 'both' && panes !== 'target')
199
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;
200
214
  return {
201
215
  tabId,
202
216
  presetId,
203
217
  profileId,
204
218
  viewMode,
205
219
  panes,
220
+ orientation,
206
221
  mode,
207
222
  targetBounds: parseRect(raw.targetBounds),
208
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.MAX_TABS_MAX = exports.MAX_TABS_MIN = 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;
@@ -22,6 +23,15 @@ exports.DEFAULT_SETTINGS = {
22
23
  split: 0.5,
23
24
  maxTabs: 12,
24
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
+ }
25
35
  exports.SCREEN_PRESETS = [
26
36
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
27
37
  { id: 'laptop-768', label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: 'laptop' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.12.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": {