getobsrv 0.12.0 → 0.14.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/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 },
@@ -190,7 +190,14 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
190
190
  starts at the window's left edge (the row's only border is at the bottom),
191
191
  which is the frame `--native-right` is expressed in. */
192
192
  .chrome-browse { position: relative; }
193
- .chrome-screen { height: 38px; background: var(--chrome-0); }
193
+ /* The screen controls sit centred: they describe the render, and the render is
194
+ what the two panes flank, so the middle is the shortest reach from either
195
+ side. The AGENT chip is taken out of flow rather than balanced against a
196
+ spacer — otherwise the group would shift sideways the moment agent control
197
+ was switched on, and a control that moves when an unrelated setting changes
198
+ is worse than one slightly off-centre. */
199
+ .chrome-screen { height: 38px; background: var(--chrome-0); justify-content: center; position: relative; }
200
+ .chrome-screen .agent-activity { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); }
194
201
  .chrome-spacer { flex: 1 1 auto; }
195
202
 
196
203
  /* The tab strip. Ground is the darkest chrome step so an inactive tab (one
@@ -239,18 +246,19 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
239
246
  .tab > .tab-label[aria-selected='true'] { color: var(--text-0); font-weight: 600; }
240
247
 
241
248
  /* The driven tab, while agent control is on. The 2px inset rule `.menu-row`
242
- and `.surround-control` already use to mark a choice the same idiom, in
243
- the same direction, so it reads as "this one" and not as a new language.
244
- It sits on the leading edge rather than surrounding the tab, so it never
245
- competes with the selected fill underneath it (the driven tab is always the
246
- selected one, and two full outlines on one control would be noise).
247
-
248
- Neutral, and only neutral: --text-1 standing, --text-0 for ~3s after each
249
- command, exactly the step the AGENT chip makes. No hue and no glow — a blur
250
- here would be the first shadow in the app, and the rule against it is what
251
- stops the chrome biasing the render below. */
252
- .tab.driven { box-shadow: inset 2px 0 0 var(--text-1); }
253
- .tab.driven.busy { box-shadow: inset 2px 0 0 var(--text-0); }
249
+ and `.surround-control` already use to mark a choice, on the leading edge so
250
+ it never competes with the selected fill underneath it.
251
+
252
+ It wears the warn hue for as long as an agent holds the tab, selected or
253
+ not, so a background tab being worked on is visible without switching to it.
254
+ Hue is tolerable here because the tab strip is the row furthest from the
255
+ panes and nowhere nearer them. No blur: a glow beside a render would spill
256
+ light onto the pixels being measured. */
257
+ .tab.driven { box-shadow: inset 2px 0 0 var(--warn); background: color-mix(in srgb, var(--warn) 14%, var(--chrome-1)); }
258
+ .tab.driven:has(> .tab-label[aria-selected='true']) { background: color-mix(in srgb, var(--warn) 22%, var(--chrome-3)); }
259
+ .tab.driven > .tab-label { color: var(--text-0); }
260
+ /* An arriving command: the rule brightens to the full hue for ~3s. */
261
+ .tab.driven.busy { box-shadow: inset 3px 0 0 var(--warn); }
254
262
 
255
263
  .tab-close {
256
264
  flex: 0 0 auto;
@@ -600,13 +608,32 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
600
608
  inset: 0 -2.5px;
601
609
  }
602
610
  .pane-divider:hover, .pane-divider:focus-visible { background: var(--text-1); }
611
+ /* Under the pointer the seam lights up its full height rather than growing a
612
+ handle. Every split-pane tool worth copying does it this way — VS Code,
613
+ Framer, Retool, Rive all leave the divider a hairline and let the cursor and
614
+ the widened hit area carry the affordance. Nothing floats over the pane
615
+ pixels this app exists to show. Painted as an overlay, so no layout moves
616
+ and the target pane is never re-rendered for a mouse-over. */
617
+ .pane-divider::after {
618
+ content: '';
619
+ position: absolute;
620
+ inset: 0 -1px;
621
+ background: var(--text-1);
622
+ opacity: 0;
623
+ }
624
+ .pane-divider:hover::after, .pane-divider:focus-visible::after { opacity: 1; }
603
625
  /* The hairline is the affordance; a ring around a 1px element would be a box
604
626
  floating on the seam. Same reasoning as `.target-canvas:focus`. */
605
627
  .pane-divider:focus-visible { outline: none; }
606
628
  /* Square corners, no shadow, no blur: a rounded or blurred pane edge would
607
629
  destroy the very corner pixels the user opened this app to inspect. */
608
630
  .pane-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
609
- .native-slot { width: 100%; height: 100%; }
631
+ /* One pixel short of the seam. The native view is an OS-composited layer sized
632
+ to exactly this rect, so anything the renderer paints inside it is hidden
633
+ underneath — without the inset, the divider's hover band would appear on the
634
+ target side only, since that side is the renderer's own canvas. The view
635
+ still sits exactly over the slot; the slot is simply 1px narrower. */
636
+ .native-slot { width: calc(100% - 1px); height: 100%; }
610
637
  /* The backing store is already at device resolution; this only guards against
611
638
  a fractional CSS size introducing a resample. */
612
639
  .target-canvas { display: block; image-rendering: pixelated; }
@@ -639,6 +666,41 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
639
666
  }
640
667
  .pane-footer .role { color: var(--text-0); letter-spacing: 0.08em; }
641
668
 
669
+ /* The rotate control. Same 30px border-box and the same `--chrome-3` pressed
670
+ fill as `.segmented`, so the screen row keeps one hit-target rhythm and one
671
+ way of marking a choice. Two outlines of the screen you get, rather than the
672
+ words: "Portrait"/"Landscape" would take 150px in a row that already holds
673
+ two selects and three groups, and the shape reads faster than either word.
674
+ The buttons are 28px like `.surround-control`'s, for the same reason — the
675
+ glyph is the affordance and the button is only the target.
676
+
677
+ No hue anywhere, pressed included: this control sits a few pixels from the
678
+ pane whose greys the user came to judge. */
679
+ .orientation-control {
680
+ box-sizing: border-box;
681
+ flex: 0 0 auto;
682
+ display: inline-flex;
683
+ height: 30px;
684
+ border: 1px solid var(--line);
685
+ border-radius: 5px;
686
+ overflow: hidden;
687
+ }
688
+ .orientation-control button {
689
+ width: 28px;
690
+ height: 100%;
691
+ display: inline-flex;
692
+ align-items: center;
693
+ justify-content: center;
694
+ border: 0;
695
+ border-right: 1px solid var(--line);
696
+ border-radius: 0;
697
+ background: var(--chrome-2);
698
+ color: var(--text-1);
699
+ cursor: pointer;
700
+ }
701
+ .orientation-control button:last-child { border-right: 0; }
702
+ .orientation-control button[aria-pressed='true'] { background: var(--chrome-3); color: var(--text-0); }
703
+
642
704
  /* Segmented surround control: the same 30px border-box as `.segmented` and
643
705
  `.select-shell`, so the screen row has one hit-target rhythm. `box-sizing`
644
706
  for the same reason `.segmented` needs it — a `div` gets no border-box from
@@ -676,18 +738,23 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
676
738
  .num { font-family: var(--mono); font-variant-numeric: tabular-nums; }
677
739
 
678
740
  /* Agent control opens a loopback server, so the chip persists while it is
679
- enabled; recent activity is a brighter text weight, not a colour. */
741
+ enabled. Something else driving the app is exactly the "attention, never
742
+ decoration" case the palette reserves --warn for: the user must be able to
743
+ tell at a glance that the window is not only theirs. Standing state is the
744
+ warn hue outlined; an arriving command fills it. No blur — a glow beside the
745
+ panes would spill light onto the pixels being measured, which is the one
746
+ thing this chrome may never do. */
680
747
  .agent-activity {
681
748
  flex: 0 0 auto;
682
- color: var(--text-1);
683
- border: 1px solid var(--line);
749
+ color: var(--warn);
750
+ border: 1px solid var(--warn);
684
751
  border-radius: 4px;
685
752
  padding: 2px 7px;
686
753
  font-size: 10px;
687
754
  letter-spacing: 0.08em;
688
755
  white-space: nowrap;
689
756
  }
690
- .agent-activity.active { color: var(--text-0); border-color: var(--text-0); }
757
+ .agent-activity.active { color: var(--chrome-0); background: var(--warn); border-color: var(--warn); }
691
758
 
692
759
  /* Drawers sit beside the panes, never over them: the native WebContentsView is
693
760
  an OS-level overlay and would cover anything painted on top of it. */
@@ -944,3 +1011,66 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
944
1011
  }
945
1012
  /* Nothing to erase: the button says so rather than pretending to act. */
946
1013
  .clear-history:disabled { color: var(--text-1); cursor: default; }
1014
+
1015
+ /* --- empty state ---------------------------------------------------------- */
1016
+ /* Shown when a tab has no page. It covers the target canvas rather than
1017
+ replacing it: the canvas keeps its GL context and frame subscription, so the
1018
+ first navigation does not pay for a context restore, and the native slot
1019
+ keeps reporting its bounds. `--surround` is the pane field, so the state sits
1020
+ on the same ground the render will. */
1021
+ .empty-state {
1022
+ position: absolute;
1023
+ inset: 0;
1024
+ display: flex;
1025
+ flex-direction: column;
1026
+ align-items: center;
1027
+ justify-content: center;
1028
+ gap: 20px;
1029
+ padding: 24px;
1030
+ background: var(--surround);
1031
+ /* Above `.pane-divider`, which is itself raised to 1: the seam would
1032
+ otherwise draw its hairline straight through the illustration. Covering it
1033
+ must not disarm it, though — the split is still adjustable on a blank tab —
1034
+ so everything here is transparent to the pointer except the form. */
1035
+ z-index: 2;
1036
+ pointer-events: none;
1037
+ }
1038
+ .empty-form { pointer-events: auto; }
1039
+ .panes { position: relative; }
1040
+ .empty-state svg { width: 244px; max-width: 100%; height: auto; }
1041
+ .empty-lede {
1042
+ margin: 0;
1043
+ color: var(--text-1);
1044
+ text-align: center;
1045
+ max-width: 34ch;
1046
+ }
1047
+ .empty-form { display: flex; gap: 8px; width: 100%; max-width: 420px; }
1048
+ .empty-input {
1049
+ flex: 1 1 auto;
1050
+ min-width: 0;
1051
+ height: 30px;
1052
+ padding: 0 10px;
1053
+ color: var(--text-0);
1054
+ background: var(--field);
1055
+ border: 1px solid var(--line);
1056
+ border-radius: 4px;
1057
+ font: inherit;
1058
+ font-family: var(--mono);
1059
+ }
1060
+ .empty-input:focus-visible { outline: none; border-color: var(--text-1); }
1061
+ .empty-go {
1062
+ height: 30px;
1063
+ padding: 0 14px;
1064
+ color: var(--text-0);
1065
+ background: var(--chrome-2);
1066
+ border: 1px solid var(--line);
1067
+ border-radius: 4px;
1068
+ font: inherit;
1069
+ }
1070
+ .empty-go:hover:not(:disabled) { background: var(--chrome-3); }
1071
+ .empty-go:disabled { color: var(--text-1); opacity: 0.6; }
1072
+ .empty-error { margin: 0; color: var(--error); }
1073
+ /* The readouts stay legible through the empty state: they still name the screen
1074
+ being simulated, which is the one fact that survives having no page. Stacking
1075
+ rather than insetting the overlay, so nothing depends on the footer's height. */
1076
+ .pane-footer { position: relative; z-index: 3; }