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 +52 -5
- package/out/main/cli.js +43 -9
- package/out/main/index.js +58 -8
- package/out/main/{targetSource-DlsHc-Ri.js → targetSource-DuRYHUo8.js} +10 -0
- package/out/mcp/lib.js +14 -1
- package/out/mcp/server.js +67 -8
- package/out/renderer/assets/{index-gYDbIgqJ.js → index-BHt9Vm0J.js} +104 -16
- package/out/renderer/assets/{index-DvwDBeho.css → index-BRkFfzcI.css} +35 -0
- package/out/renderer/index.html +2 -2
- package/out/shared/calibration.js +35 -0
- package/out/shared/control.js +59 -1
- package/out/shared/ipcPayloads.js +15 -0
- package/out/shared/presets.js +11 -1
- package/package.json +1 -1
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
|
-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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) => ({
|
|
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
|
-
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
|
142
|
-
|
|
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,
|
|
639
|
-
`profile, the target pane's 1:1/fit view or pixel-exact
|
|
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
|
|
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
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
14724
|
-
const ChevronDown = createLucideIcon("chevron-down", __iconNode$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
package/out/renderer/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
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');
|
package/out/shared/control.js
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/out/shared/presets.js
CHANGED
|
@@ -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' },
|