getobsrv 0.14.1 → 0.16.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/main/index.js CHANGED
@@ -146,6 +146,35 @@ function parseScrollReport(raw) {
146
146
  const warnings = Array.isArray(raw.warnings) ? raw.warnings.filter((w) => typeof w === "string").slice(0, MAX_SCROLL_WARNINGS) : [];
147
147
  return { id, x, y, scroller, warnings };
148
148
  }
149
+ const MAX_MENU_OPTIONS = 200;
150
+ const MAX_MENU_LABEL = 120;
151
+ function parseMenuRequest(raw) {
152
+ if (!isRecord$3(raw)) return null;
153
+ const { groups, value, ariaLabel, anchor } = raw;
154
+ if (typeof value !== "string" || value.length > MAX_MENU_LABEL) return null;
155
+ if (typeof ariaLabel !== "string" || ariaLabel.length > MAX_MENU_LABEL) return null;
156
+ const rect = parseRect(anchor);
157
+ if (!rect) return null;
158
+ if (!Array.isArray(groups)) return null;
159
+ const out = [];
160
+ let rows = 0;
161
+ for (const g of groups) {
162
+ if (!isRecord$3(g)) return null;
163
+ if (g.label !== void 0 && (typeof g.label !== "string" || g.label.length > MAX_MENU_LABEL)) return null;
164
+ if (!Array.isArray(g.options)) return null;
165
+ const options = [];
166
+ for (const o of g.options) {
167
+ if (!isRecord$3(o)) return null;
168
+ if (typeof o.value !== "string" || o.value.length > MAX_MENU_LABEL) return null;
169
+ if (typeof o.label !== "string" || o.label.length > MAX_MENU_LABEL) return null;
170
+ if (++rows > MAX_MENU_OPTIONS) return null;
171
+ options.push({ value: o.value, label: o.label });
172
+ }
173
+ out.push(g.label === void 0 ? { options } : { label: g.label, options });
174
+ }
175
+ if (rows === 0) return null;
176
+ return { groups: out, value, ariaLabel, anchor: rect };
177
+ }
149
178
  const CONTROL_FILE_NAME = "control.json";
150
179
  const CONTROL_TOKEN_BYTES = 32;
151
180
  const CONTROL_COMMANDS = [
@@ -308,6 +337,12 @@ const IPC = {
308
337
  setViewport: "obsrv:set-viewport",
309
338
  setNativeBounds: "obsrv:set-native-bounds",
310
339
  setNativeVisible: "obsrv:set-native-visible",
340
+ /** Chrome -> main: open a menu in the overlay view. */
341
+ menuOpen: "obsrv:menu-open",
342
+ /** Main -> overlay: draw this menu. */
343
+ menuShow: "obsrv:menu-show",
344
+ /** Overlay -> main: a row was chosen, or the menu was dismissed. */
345
+ menuPick: "obsrv:menu-pick",
311
346
  setMode: "obsrv:set-mode",
312
347
  sendInput: "obsrv:send-input",
313
348
  getHostInfo: "obsrv:get-host-info",
@@ -754,7 +789,7 @@ function hostInfo(win) {
754
789
  }
755
790
  }
756
791
  function registerIpc(ctx) {
757
- const { win, bus, tabs } = ctx;
792
+ const { win, bus, tabs, overlay } = ctx;
758
793
  const tab = () => tabs.active();
759
794
  const settingsFile = node_path.join(electron.app.getPath("userData"), "settings.json");
760
795
  let settings = loadSettings(settingsFile);
@@ -838,6 +873,28 @@ function registerIpc(ctx) {
838
873
  applyNativeVisibility();
839
874
  bus.setEnabled(tab().modeIsLive);
840
875
  });
876
+ let pendingMenu = null;
877
+ const settleMenu = (value) => {
878
+ const resolve = pendingMenu;
879
+ pendingMenu = null;
880
+ overlay.hide();
881
+ resolve?.(value);
882
+ };
883
+ handle(IPC.menuOpen, (e, raw) => {
884
+ if (!fromRenderer(e)) return null;
885
+ const request = parseMenuRequest(raw);
886
+ if (!request) return null;
887
+ settleMenu(null);
888
+ overlay.show(request);
889
+ return new Promise((resolve) => {
890
+ pendingMenu = resolve;
891
+ });
892
+ });
893
+ on(IPC.menuPick, (e, raw) => {
894
+ if (e.sender !== overlay.webContents) return;
895
+ if (raw !== null && typeof raw !== "string") return;
896
+ settleMenu(raw);
897
+ });
841
898
  on(IPC.setNativeVisible, (e, raw) => {
842
899
  if (!fromRenderer(e)) return;
843
900
  if (typeof raw !== "boolean") return;
@@ -1433,6 +1490,76 @@ function installMenu({ win, tabs }) {
1433
1490
  ];
1434
1491
  electron.Menu.setApplicationMenu(electron.Menu.buildFromTemplate(template));
1435
1492
  }
1493
+ class Overlay {
1494
+ constructor(win) {
1495
+ this.win = win;
1496
+ this.view = new electron.WebContentsView({
1497
+ webPreferences: {
1498
+ preload: node_path.join(__dirname, "../preload/app.js"),
1499
+ contextIsolation: true,
1500
+ sandbox: true,
1501
+ // Transparency has to be asked for at construction; setting the
1502
+ // background colour afterwards is not enough to keep the page's own
1503
+ // white from painting over the panes.
1504
+ transparent: true
1505
+ }
1506
+ });
1507
+ this.view.setBackgroundColor("#00000000");
1508
+ this.win.contentView.addChildView(this.view);
1509
+ this.view.setVisible(false);
1510
+ this.layout();
1511
+ if (!electron.app.isPackaged && process.env.ELECTRON_RENDERER_URL)
1512
+ void this.view.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}?overlay=1`);
1513
+ else
1514
+ void this.view.webContents.loadFile(node_path.join(__dirname, "../renderer/index.html"), {
1515
+ query: { overlay: "1" }
1516
+ });
1517
+ this.win.on("resize", () => this.layout());
1518
+ }
1519
+ view;
1520
+ open = false;
1521
+ destroyed = false;
1522
+ get webContents() {
1523
+ return this.view.webContents;
1524
+ }
1525
+ /** Covers the whole content area: the menu positions itself within it, and
1526
+ * the rest of the surface is what catches a click meant to dismiss. */
1527
+ layout() {
1528
+ const [width = 0, height = 0] = this.win.getContentSize();
1529
+ this.view.setBounds({ x: 0, y: 0, width, height });
1530
+ }
1531
+ show(request) {
1532
+ this.win.contentView.addChildView(this.view);
1533
+ this.layout();
1534
+ this.view.setVisible(true);
1535
+ this.open = true;
1536
+ this.view.webContents.focus();
1537
+ this.view.webContents.send(IPC.menuShow, request);
1538
+ }
1539
+ hide() {
1540
+ if (!this.open) return;
1541
+ this.open = false;
1542
+ this.view.setVisible(false);
1543
+ this.win.webContents.focus();
1544
+ }
1545
+ get isOpen() {
1546
+ return this.open;
1547
+ }
1548
+ /**
1549
+ * Idempotent, because the window's `close` handler is not guaranteed to run
1550
+ * once: quit.spec.ts emits `close` by hand to drive the teardown while the
1551
+ * window is still alive, and the real close then runs it again. A throw on
1552
+ * the second pass would take the rest of that handler with it — including the
1553
+ * destroy of the offscreen windows, without which `window-all-closed` never
1554
+ * fires and the app hangs instead of quitting.
1555
+ */
1556
+ destroy() {
1557
+ if (this.destroyed) return;
1558
+ this.destroyed = true;
1559
+ this.win.contentView.removeChildView(this.view);
1560
+ if (!this.view.webContents.isDestroyed()) this.view.webContents.close();
1561
+ }
1562
+ }
1436
1563
  function closeTab(tabs, closeId, activeId) {
1437
1564
  const index = tabs.findIndex((t) => t.id === closeId);
1438
1565
  if (index === -1) return { tabs, activeId };
@@ -1990,12 +2117,14 @@ function createMainWindow() {
1990
2117
  function boot() {
1991
2118
  const win = createMainWindow();
1992
2119
  const tabs = new TabManager(win);
1993
- const ctx = { win, tabs, bus: tabs.bus, toolbarH: TOOLBAR_H };
2120
+ const overlay = new Overlay(win);
2121
+ const ctx = { win, tabs, bus: tabs.bus, toolbarH: TOOLBAR_H, overlay };
1994
2122
  const stopIpc = registerIpc(ctx);
1995
2123
  installMenu(ctx);
1996
2124
  win.on("close", () => {
1997
2125
  stopIpc();
1998
2126
  tabs.destroy();
2127
+ overlay.destroy();
1999
2128
  });
2000
2129
  exposeForTests(ctx);
2001
2130
  }
@@ -8,6 +8,12 @@ const IPC = {
8
8
  setViewport: "obsrv:set-viewport",
9
9
  setNativeBounds: "obsrv:set-native-bounds",
10
10
  setNativeVisible: "obsrv:set-native-visible",
11
+ /** Chrome -> main: open a menu in the overlay view. */
12
+ menuOpen: "obsrv:menu-open",
13
+ /** Main -> overlay: draw this menu. */
14
+ menuShow: "obsrv:menu-show",
15
+ /** Overlay -> main: a row was chosen, or the menu was dismissed. */
16
+ menuPick: "obsrv:menu-pick",
11
17
  setMode: "obsrv:set-mode",
12
18
  sendInput: "obsrv:send-input",
13
19
  getHostInfo: "obsrv:get-host-info",
@@ -69,6 +75,13 @@ const api = {
69
75
  setViewport: (width, height, deviceScaleFactor) => electron.ipcRenderer.invoke(IPC.setViewport, width, height, deviceScaleFactor),
70
76
  setNativeBounds: (rect) => electron.ipcRenderer.send(IPC.setNativeBounds, rect),
71
77
  setNativeVisible: (visible) => electron.ipcRenderer.send(IPC.setNativeVisible, visible),
78
+ openMenu: (request) => electron.ipcRenderer.invoke(IPC.menuOpen, request),
79
+ onMenuShow: (fn) => {
80
+ const h = (_e, request) => fn(request);
81
+ electron.ipcRenderer.on(IPC.menuShow, h);
82
+ return () => electron.ipcRenderer.off(IPC.menuShow, h);
83
+ },
84
+ pickMenu: (value) => electron.ipcRenderer.send(IPC.menuPick, value),
72
85
  setMode: (mode) => electron.ipcRenderer.send(IPC.setMode, mode),
73
86
  sendInput: (ev) => electron.ipcRenderer.send(IPC.sendInput, ev),
74
87
  getHostInfo: () => electron.ipcRenderer.invoke(IPC.getHostInfo),
@@ -13554,6 +13554,11 @@ function NumberField({ className, label, unit, value, min, step, onCommit, onInv
13554
13554
  )
13555
13555
  ] });
13556
13556
  }
13557
+ const SURROUNDS = [
13558
+ { id: "black", label: "Black surround", swatch: "#000000" },
13559
+ { id: "graphite", label: "Graphite surround", swatch: "#2a2a2a" },
13560
+ { id: "grey50", label: "Neutral 50% surround", swatch: "#808080" }
13561
+ ];
13557
13562
  function SettingsPanel() {
13558
13563
  const host = useStore(useShallow((s) => s.host));
13559
13564
  const settings = useStore(useShallow((s) => s.settings));
@@ -13566,6 +13571,8 @@ function SettingsPanel() {
13566
13571
  const fallback = useStore(selectScaleIsFallback);
13567
13572
  const setSettings = useStore((s) => s.setSettings);
13568
13573
  const setCustom = useStore((s) => s.setCustom);
13574
+ const surround = useStore((s) => s.surround);
13575
+ const setSurround = useStore((s) => s.setSurround);
13569
13576
  const [hostError, setHostError] = reactExports.useState(null);
13570
13577
  const [customError, setCustomError] = reactExports.useState(null);
13571
13578
  const queue = reactExports.useRef(Promise.resolve());
@@ -13688,6 +13695,20 @@ function SettingsPanel() {
13688
13695
  "."
13689
13696
  ] }),
13690
13697
  /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "readout", children: custom.diagonalInches > 0 ? `${ppi(custom.width, custom.height, custom.diagonalInches).toFixed(0)} PPI` : "Enter a diagonal to compute PPI" }),
13698
+ /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Pane surround" }),
13699
+ /* @__PURE__ */ jsxRuntimeExports.jsx("p", { className: "hint", children: "What the panes are filled with around a render. It changes nothing in the render itself, but the same pixels read lighter or darker against a different ground — which is why it is a choice and not a constant. Black for judging a dark UI, 50% grey for a neutral reference." }),
13700
+ /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "surround-control", role: "group", "aria-label": "Pane surround", children: SURROUNDS.map((s) => /* @__PURE__ */ jsxRuntimeExports.jsx(
13701
+ "button",
13702
+ {
13703
+ type: "button",
13704
+ title: s.label,
13705
+ "aria-label": s.label,
13706
+ "aria-pressed": surround === s.id,
13707
+ onClick: () => setSurround(s.id),
13708
+ children: /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "surround-swatch", style: { background: s.swatch } })
13709
+ },
13710
+ s.id
13711
+ )) }),
13691
13712
  /* @__PURE__ */ jsxRuntimeExports.jsx("h2", { children: "Updates" }),
13692
13713
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-block", children: [
13693
13714
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "version-row", children: [
@@ -14829,22 +14850,30 @@ const createLucideIcon = (iconName, iconNode) => {
14829
14850
  * This source code is licensed under the ISC license.
14830
14851
  * See the LICENSE file in the root directory of this source tree.
14831
14852
  */
14832
- const __iconNode$a = [
14853
+ const __iconNode$b = [
14833
14854
  ["path", { d: "m12 19-7-7 7-7", key: "1l729n" }],
14834
14855
  ["path", { d: "M19 12H5", key: "x3x0zl" }]
14835
14856
  ];
14836
- const ArrowLeft = createLucideIcon("arrow-left", __iconNode$a);
14857
+ const ArrowLeft = createLucideIcon("arrow-left", __iconNode$b);
14837
14858
  /**
14838
14859
  * @license lucide-react v1.34.0 - ISC
14839
14860
  *
14840
14861
  * This source code is licensed under the ISC license.
14841
14862
  * See the LICENSE file in the root directory of this source tree.
14842
14863
  */
14843
- const __iconNode$9 = [
14864
+ const __iconNode$a = [
14844
14865
  ["path", { d: "M5 12h14", key: "1ays0h" }],
14845
14866
  ["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
14846
14867
  ];
14847
- const ArrowRight = createLucideIcon("arrow-right", __iconNode$9);
14868
+ const ArrowRight = createLucideIcon("arrow-right", __iconNode$a);
14869
+ /**
14870
+ * @license lucide-react v1.34.0 - ISC
14871
+ *
14872
+ * This source code is licensed under the ISC license.
14873
+ * See the LICENSE file in the root directory of this source tree.
14874
+ */
14875
+ const __iconNode$9 = [["path", { d: "M20 6 9 17l-5-5", key: "1gmf2c" }]];
14876
+ const Check = createLucideIcon("check", __iconNode$9);
14848
14877
  /**
14849
14878
  * @license lucide-react v1.34.0 - ISC
14850
14879
  *
@@ -14962,6 +14991,8 @@ const ICONS = {
14962
14991
  sliders: SlidersHorizontal,
14963
14992
  gear: Settings,
14964
14993
  chevron: ChevronDown,
14994
+ // The listbox's tick. Marks the chosen row the way the native menu did.
14995
+ check: Check,
14965
14996
  plus: Plus,
14966
14997
  // The rotate control's two shapes. A plain outline of the screen you get is
14967
14998
  // the whole affordance — no arrow, no device silhouette: the target may be a
@@ -15109,27 +15140,54 @@ Agent control is driving this tab` : label,
15109
15140
  )
15110
15141
  ] });
15111
15142
  }
15112
- function Select({ className, value, label, ariaLabel, onChange, children }) {
15113
- return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "select-shell", children: [
15114
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label", "aria-hidden": "true", children: label }),
15115
- /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-chevron", children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "chevron", size: 14 }) }),
15116
- /* @__PURE__ */ jsxRuntimeExports.jsx(
15117
- "select",
15118
- {
15119
- className,
15120
- value,
15121
- "aria-label": ariaLabel,
15122
- onChange: (e) => onChange(e.target.value),
15123
- children
15124
- }
15125
- )
15126
- ] });
15143
+ function Select({ className, value, label, ariaLabel, groups, onChange }) {
15144
+ const triggerRef = reactExports.useRef(null);
15145
+ const [open, setOpen] = reactExports.useState(false);
15146
+ const openMenu = async () => {
15147
+ const el = triggerRef.current;
15148
+ if (!el || open) return;
15149
+ const r = el.getBoundingClientRect();
15150
+ setOpen(true);
15151
+ const picked = await window.obsrv.openMenu({
15152
+ groups,
15153
+ value,
15154
+ ariaLabel,
15155
+ // Window coordinates: the overlay spans the content area, so the menu
15156
+ // needs no conversion to place itself under this control.
15157
+ anchor: { x: r.left, y: r.top, width: r.width, height: r.height }
15158
+ });
15159
+ setOpen(false);
15160
+ el.focus();
15161
+ if (picked !== null) onChange(picked);
15162
+ };
15163
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "select-shell", children: /* @__PURE__ */ jsxRuntimeExports.jsxs(
15164
+ "button",
15165
+ {
15166
+ type: "button",
15167
+ ref: triggerRef,
15168
+ className: `select-trigger ${className}`,
15169
+ role: "combobox",
15170
+ "aria-haspopup": "listbox",
15171
+ "aria-expanded": open,
15172
+ "aria-label": ariaLabel,
15173
+ "data-value": value,
15174
+ onClick: () => void openMenu(),
15175
+ onKeyDown: (e) => {
15176
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
15177
+ e.preventDefault();
15178
+ void openMenu();
15179
+ }
15180
+ },
15181
+ children: [
15182
+ /* @__PURE__ */ jsxRuntimeExports.jsxs("span", { className: "select-label", children: [
15183
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label-text", children: label }),
15184
+ groups.flatMap((g) => g.options).map((o) => /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-label-sizer", "aria-hidden": "true", children: o.label }, o.value))
15185
+ ] }),
15186
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-chevron", children: /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "chevron", size: 14 }) })
15187
+ ]
15188
+ }
15189
+ ) });
15127
15190
  }
15128
- const SURROUNDS = [
15129
- { id: "black", label: "Black surround", swatch: "#000000" },
15130
- { id: "graphite", label: "Graphite surround", swatch: "#2a2a2a" },
15131
- { id: "grey50", label: "Neutral 50% surround", swatch: "#808080" }
15132
- ];
15133
15191
  const VIEWS = [
15134
15192
  { id: "1:1", label: "1:1", title: "Actual size" },
15135
15193
  { id: "fit", label: "Fit", title: "Fit the pane — not pixel-exact" }
@@ -15138,6 +15196,17 @@ const PANES = [
15138
15196
  { id: "both", label: "Both", title: "Native and target side by side" },
15139
15197
  { id: "target", label: "Target", title: "The target render alone, full width" }
15140
15198
  ];
15199
+ const inGroup = (group) => SCREEN_PRESETS.filter((p) => p.group === group).map((p) => ({ value: p.id, label: p.label }));
15200
+ const PRESET_GROUPS = [
15201
+ { label: "Laptops", options: inGroup("laptop") },
15202
+ { label: "Desktops", options: inGroup("desktop") },
15203
+ { label: "Mobile", options: inGroup("mobile") },
15204
+ // Ungrouped, as it was: "Custom" is not one more screen among the presets.
15205
+ { options: [{ value: CUSTOM_PRESET_ID, label: "Custom" }] }
15206
+ ];
15207
+ const PROFILE_GROUPS = [
15208
+ { options: PANEL_PROFILES.map((p) => ({ value: p.id, label: p.label })) }
15209
+ ];
15141
15210
  function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15142
15211
  const mode = useStore((s) => selectTab(s).mode);
15143
15212
  const presetId = useStore((s) => selectTab(s).presetId);
@@ -15156,9 +15225,7 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15156
15225
  const setProfile = useStore((s) => s.setProfile);
15157
15226
  const setPixelExact = useStore((s) => s.setPixelExact);
15158
15227
  const setError = useStore((s) => s.setError);
15159
- const surround = useStore((s) => s.surround);
15160
15228
  const update = useStore((s) => s.update);
15161
- const setSurround = useStore((s) => s.setSurround);
15162
15229
  const viewMode = useStore((s) => selectTab(s).viewMode);
15163
15230
  const setViewMode = useStore((s) => s.setViewMode);
15164
15231
  const panes = useStore((s) => s.panes);
@@ -15409,20 +15476,15 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15409
15476
  ] }) })
15410
15477
  ] }),
15411
15478
  /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "chrome-row chrome-screen", children: [
15412
- /* @__PURE__ */ jsxRuntimeExports.jsxs(
15479
+ /* @__PURE__ */ jsxRuntimeExports.jsx(
15413
15480
  Select,
15414
15481
  {
15415
15482
  className: "preset-select",
15416
15483
  value: presetId,
15417
15484
  label: presetLabel,
15418
15485
  ariaLabel: "Target screen",
15419
- onChange: setPreset,
15420
- children: [
15421
- /* @__PURE__ */ jsxRuntimeExports.jsx("optgroup", { label: "Laptops", children: SCREEN_PRESETS.filter((p) => p.group === "laptop").map((p) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: p.id, children: p.label }, p.id)) }),
15422
- /* @__PURE__ */ jsxRuntimeExports.jsx("optgroup", { label: "Desktops", children: SCREEN_PRESETS.filter((p) => p.group === "desktop").map((p) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: p.id, children: p.label }, p.id)) }),
15423
- /* @__PURE__ */ jsxRuntimeExports.jsx("optgroup", { label: "Mobile", children: SCREEN_PRESETS.filter((p) => p.group === "mobile").map((p) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: p.id, children: p.label }, p.id)) }),
15424
- /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: CUSTOM_PRESET_ID, children: "Custom" })
15425
- ]
15486
+ groups: PRESET_GROUPS,
15487
+ onChange: setPreset
15426
15488
  }
15427
15489
  ),
15428
15490
  /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "orientation-control", role: "group", "aria-label": "Screen orientation", children: ORIENTATIONS.map((value, i) => {
@@ -15469,22 +15531,10 @@ function Toolbar({ drawer, onTogglePanel, onToggleSettings }) {
15469
15531
  value: profileId,
15470
15532
  label: profileLabel,
15471
15533
  ariaLabel: "Panel profile",
15472
- onChange: setProfile,
15473
- children: PANEL_PROFILES.map((p) => /* @__PURE__ */ jsxRuntimeExports.jsx("option", { value: p.id, children: p.label }, p.id))
15534
+ groups: PROFILE_GROUPS,
15535
+ onChange: setProfile
15474
15536
  }
15475
15537
  ),
15476
- /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "surround-control", role: "group", "aria-label": "Pane surround", children: SURROUNDS.map((s) => /* @__PURE__ */ jsxRuntimeExports.jsx(
15477
- "button",
15478
- {
15479
- type: "button",
15480
- title: s.label,
15481
- "aria-label": s.label,
15482
- "aria-pressed": surround === s.id,
15483
- onClick: () => setSurround(s.id),
15484
- children: /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "surround-swatch", style: { background: s.swatch } })
15485
- },
15486
- s.id
15487
- )) }),
15488
15538
  agentControl && /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: `agent-activity${agentActive ? " active" : ""}`, children: "AGENT" })
15489
15539
  ] })
15490
15540
  ] });
@@ -15733,6 +15783,134 @@ function App() {
15733
15783
  )
15734
15784
  );
15735
15785
  }
15786
+ const MARGIN = 8;
15787
+ const TYPEAHEAD_MS = 700;
15788
+ function MenuLayer() {
15789
+ const [request, setRequest] = reactExports.useState(null);
15790
+ const [active, setActive] = reactExports.useState(0);
15791
+ const [pos, setPos] = reactExports.useState(null);
15792
+ const listRef = reactExports.useRef(null);
15793
+ const typed = reactExports.useRef({ text: "", at: 0 });
15794
+ const flat = request?.groups.flatMap((g) => g.options) ?? [];
15795
+ reactExports.useEffect(() => window.obsrv.onMenuShow(setRequest), []);
15796
+ reactExports.useEffect(() => {
15797
+ if (!request) return;
15798
+ setActive(Math.max(0, flat.findIndex((o) => o.value === request.value)));
15799
+ }, [request]);
15800
+ const finish = (value) => {
15801
+ window.obsrv.pickMenu(value);
15802
+ setRequest(null);
15803
+ setPos(null);
15804
+ };
15805
+ reactExports.useLayoutEffect(() => {
15806
+ if (!request) return;
15807
+ const list = listRef.current;
15808
+ if (!list) return;
15809
+ const a = request.anchor;
15810
+ const below = window.innerHeight - (a.y + a.height) - MARGIN;
15811
+ const above = a.y - MARGIN;
15812
+ const wanted = list.scrollHeight;
15813
+ const dropDown = below >= wanted || below >= above;
15814
+ const maxHeight = Math.max(80, dropDown ? below : above);
15815
+ const height = Math.min(wanted, maxHeight);
15816
+ const top = dropDown ? a.y + a.height : a.y - height;
15817
+ const left = Math.max(MARGIN, Math.min(a.x, window.innerWidth - list.offsetWidth - MARGIN));
15818
+ setPos({ left, top, maxHeight });
15819
+ }, [request]);
15820
+ reactExports.useEffect(() => {
15821
+ if (!request) return;
15822
+ const move = (to) => {
15823
+ const next = Math.max(0, Math.min(flat.length - 1, to));
15824
+ setActive(next);
15825
+ listRef.current?.querySelector(`[data-index="${next}"]`)?.scrollIntoView({ block: "nearest" });
15826
+ };
15827
+ const onKey = (e) => {
15828
+ switch (e.key) {
15829
+ case "Escape":
15830
+ e.preventDefault();
15831
+ return finish(null);
15832
+ case "ArrowDown":
15833
+ e.preventDefault();
15834
+ return move(active + 1);
15835
+ case "ArrowUp":
15836
+ e.preventDefault();
15837
+ return move(active - 1);
15838
+ case "Home":
15839
+ e.preventDefault();
15840
+ return move(0);
15841
+ case "End":
15842
+ e.preventDefault();
15843
+ return move(flat.length - 1);
15844
+ case "Tab":
15845
+ e.preventDefault();
15846
+ return finish(null);
15847
+ case "Enter":
15848
+ case " ":
15849
+ e.preventDefault();
15850
+ return finish(flat[active]?.value ?? null);
15851
+ }
15852
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
15853
+ const now = Date.now();
15854
+ typed.current.text = now - typed.current.at > TYPEAHEAD_MS ? e.key : typed.current.text + e.key;
15855
+ typed.current.at = now;
15856
+ const q = typed.current.text.toLowerCase();
15857
+ const hit = flat.findIndex((o) => o.label.toLowerCase().startsWith(q));
15858
+ if (hit >= 0) move(hit);
15859
+ }
15860
+ };
15861
+ window.addEventListener("keydown", onKey);
15862
+ return () => window.removeEventListener("keydown", onKey);
15863
+ }, [request, active, flat.length]);
15864
+ reactExports.useEffect(() => {
15865
+ if (!request || !pos) return;
15866
+ listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
15867
+ }, [pos]);
15868
+ if (!request) return null;
15869
+ let index = -1;
15870
+ return /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "menu-backdrop", onMouseDown: () => finish(null), children: /* @__PURE__ */ jsxRuntimeExports.jsx(
15871
+ "div",
15872
+ {
15873
+ ref: listRef,
15874
+ className: "select-menu",
15875
+ role: "listbox",
15876
+ "aria-label": request.ariaLabel,
15877
+ style: pos ? { left: pos.left, top: pos.top, maxHeight: pos.maxHeight } : (
15878
+ // Hidden until measured: the first pass needs the real height, and
15879
+ // a menu that flashes in the wrong place is worse than one that
15880
+ // appears a frame later.
15881
+ { visibility: "hidden", top: 0, left: 0 }
15882
+ ),
15883
+ onMouseDown: (e) => e.stopPropagation(),
15884
+ children: request.groups.map((g, gi) => /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "select-group", role: "group", "aria-label": g.label, children: [
15885
+ g.label && /* @__PURE__ */ jsxRuntimeExports.jsx("div", { className: "select-group-label", "aria-hidden": "true", children: g.label }),
15886
+ g.options.map((o) => {
15887
+ index += 1;
15888
+ const i = index;
15889
+ return /* @__PURE__ */ jsxRuntimeExports.jsxs(
15890
+ "div",
15891
+ {
15892
+ role: "option",
15893
+ "data-index": i,
15894
+ "data-value": o.value,
15895
+ className: `select-option${i === active ? " active" : ""}`,
15896
+ "aria-selected": o.value === request.value,
15897
+ onMouseDown: (e) => {
15898
+ e.stopPropagation();
15899
+ finish(o.value);
15900
+ },
15901
+ onMouseEnter: () => setActive(i),
15902
+ children: [
15903
+ /* @__PURE__ */ jsxRuntimeExports.jsx("span", { className: "select-tick", "aria-hidden": "true", children: o.value === request.value ? /* @__PURE__ */ jsxRuntimeExports.jsx(Icon, { name: "check", size: 12 }) : null }),
15904
+ o.label
15905
+ ]
15906
+ },
15907
+ o.value
15908
+ );
15909
+ })
15910
+ ] }, g.label ?? gi))
15911
+ }
15912
+ ) });
15913
+ }
15736
15914
  function BrowserNotice() {
15737
15915
  return /* @__PURE__ */ jsxRuntimeExports.jsxs("div", { className: "browser-notice", role: "note", children: [
15738
15916
  /* @__PURE__ */ jsxRuntimeExports.jsx("strong", { children: "This is only Obsrv's renderer shell." }),
@@ -15751,4 +15929,8 @@ function BrowserNotice() {
15751
15929
  ] })
15752
15930
  ] });
15753
15931
  }
15754
- clientExports.createRoot(document.getElementById("root")).render("obsrv" in window ? /* @__PURE__ */ jsxRuntimeExports.jsx(App, {}) : /* @__PURE__ */ jsxRuntimeExports.jsx(BrowserNotice, {}));
15932
+ const overlay = new URLSearchParams(location.search).has("overlay");
15933
+ if (overlay) document.documentElement.dataset.overlay = "";
15934
+ clientExports.createRoot(document.getElementById("root")).render(
15935
+ !("obsrv" in window) ? /* @__PURE__ */ jsxRuntimeExports.jsx(BrowserNotice, {}) : overlay ? /* @__PURE__ */ jsxRuntimeExports.jsx(MenuLayer, {}) : /* @__PURE__ */ jsxRuntimeExports.jsx(App, {})
15936
+ );
@@ -421,7 +421,10 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
421
421
  /* Our surface, the platform's popup: the real select sits transparent on
422
422
  top, keeping the native menu, keyboard handling and Playwright's
423
423
  `selectOption` working unchanged. */
424
- .select-shell {
424
+ .select-shell { position: relative; flex: 0 0 auto; display: inline-flex; }
425
+ /* The trigger carries the surface the shell used to: it is a real button now,
426
+ not a transparent native select sitting on top of a painted div. */
427
+ .select-trigger {
425
428
  position: relative;
426
429
  flex: 0 0 auto;
427
430
  display: inline-flex;
@@ -429,22 +432,23 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
429
432
  height: 30px;
430
433
  box-sizing: border-box;
431
434
  padding: 0 26px 0 9px;
435
+ font: inherit;
432
436
  font-size: 11.5px;
433
437
  white-space: nowrap;
434
438
  background: var(--chrome-2);
435
439
  color: var(--text-0);
436
440
  border: 1px solid var(--line);
437
441
  border-radius: 5px;
438
- }
439
- .select-shell select {
440
- position: absolute;
441
- inset: 0;
442
- width: 100%;
443
- height: 100%;
444
- opacity: 0;
445
- appearance: none;
446
442
  cursor: pointer;
447
443
  }
444
+ .select-trigger:hover { background: var(--chrome-3); }
445
+ /* The stack that fixes the width: one cell, every label in it, only the chosen
446
+ one visible. `visibility` rather than `display: none` because a hidden box
447
+ still has to take its width for this to work at all. */
448
+ .select-label { display: grid; align-items: center; }
449
+ .select-label > * { grid-area: 1 / 1; white-space: nowrap; text-align: left; }
450
+ .select-label-sizer { visibility: hidden; }
451
+ .select-trigger:focus-visible { outline: none; border-color: var(--text-1); }
448
452
  .select-shell .select-chevron {
449
453
  position: absolute;
450
454
  right: 7px;
@@ -453,6 +457,56 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
453
457
  pointer-events: none;
454
458
  }
455
459
 
460
+ /* The menu. Fixed, so it is positioned against the window rather than against
461
+ whatever the toolbar's overflow happens to be, and clamped in JS to the
462
+ window's box — the whole point of replacing the native popup, which drew
463
+ outside the app entirely. */
464
+ .select-menu {
465
+ position: fixed;
466
+ z-index: 60;
467
+ /* Or `max-height` would bound the *content* box and the padding and border
468
+ would sit outside it — the menu overflowed the window's bottom edge by
469
+ exactly those 10px, which is the one thing this control exists to prevent. */
470
+ box-sizing: border-box;
471
+ min-width: 220px;
472
+ padding: 4px;
473
+ overflow-y: auto;
474
+ overscroll-behavior: contain;
475
+ font-size: 11.5px;
476
+ background: var(--chrome-1);
477
+ color: var(--text-0);
478
+ border: 1px solid var(--line);
479
+ border-radius: 6px;
480
+ /* The one shadow in the chrome. A menu is the only surface that genuinely
481
+ floats above the rest, and without it the panel reads as part of the page
482
+ under test rather than as app chrome. */
483
+ box-shadow: 0 8px 24px rgb(0 0 0 / 55%);
484
+ }
485
+ .select-group + .select-group { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 4px; }
486
+ .select-group-label {
487
+ padding: 5px 9px 3px;
488
+ color: var(--text-1);
489
+ font-size: 10.5px;
490
+ letter-spacing: 0.06em;
491
+ text-transform: uppercase;
492
+ }
493
+ .select-option {
494
+ display: flex;
495
+ align-items: center;
496
+ gap: 6px;
497
+ padding: 5px 9px 5px 4px;
498
+ border-radius: 4px;
499
+ cursor: pointer;
500
+ white-space: nowrap;
501
+ }
502
+ /* Hover and keyboard share one highlight: two different treatments for "the
503
+ row you are about to choose" is one too many. */
504
+ .select-option.active { background: var(--chrome-3); }
505
+ /* The tick's column is always reserved, so labels do not shift by 12px when
506
+ the selection moves. */
507
+ .select-tick { display: flex; width: 14px; justify-content: center; color: var(--text-0); }
508
+
509
+
456
510
  /* Segmented groups. Pressed is a fill step and weight, never hue. */
457
511
  .segmented {
458
512
  flex: 0 0 auto;
@@ -1074,3 +1128,16 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
1074
1128
  being simulated, which is the one fact that survives having no page. Stacking
1075
1129
  rather than insetting the overlay, so nothing depends on the footer's height. */
1076
1130
  .pane-footer { position: relative; z-index: 3; }
1131
+
1132
+ /* --- overlay view --------------------------------------------------------- */
1133
+ /* The menu view is a transparent sheet over the whole window, so its page must
1134
+ paint nothing of its own — a background here would hide both panes. */
1135
+ :root[data-overlay] html,
1136
+ :root[data-overlay],
1137
+ :root[data-overlay] body,
1138
+ :root[data-overlay] #root {
1139
+ background: transparent;
1140
+ }
1141
+ /* Spans the overlay so a press anywhere outside the menu dismisses it, which is
1142
+ what a native menu's invisible tracking window does. */
1143
+ .menu-backdrop { position: fixed; inset: 0; }
@@ -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-WM0cwf7d.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-BpvyvKfD.css">
7
+ <script type="module" crossorigin src="./assets/index-BJmd2r3W.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-DUmCxwNL.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -12,6 +12,7 @@ exports.parseUiState = parseUiState;
12
12
  exports.parseScrollPos = parseScrollPos;
13
13
  exports.parseScrollRequest = parseScrollRequest;
14
14
  exports.parseScrollReport = parseScrollReport;
15
+ exports.parseMenuRequest = parseMenuRequest;
15
16
  const presets_1 = require("./presets");
16
17
  const types_1 = require("./types");
17
18
  /**
@@ -281,3 +282,52 @@ function parseScrollReport(raw) {
281
282
  : [];
282
283
  return { id, x, y, scroller, warnings };
283
284
  }
285
+ /** Rows in one menu, and characters in a label. A renderer bug should not be
286
+ * able to ask main to hold an unbounded payload. */
287
+ const MAX_MENU_OPTIONS = 200;
288
+ const MAX_MENU_LABEL = 120;
289
+ /**
290
+ * The menu the chrome is asking the overlay to draw. Validated like anything
291
+ * else crossing into main: the two sides are separate web contents, and main is
292
+ * the only thing standing between them.
293
+ */
294
+ function parseMenuRequest(raw) {
295
+ if (!isRecord(raw))
296
+ return null;
297
+ const { groups, value, ariaLabel, anchor } = raw;
298
+ if (typeof value !== 'string' || value.length > MAX_MENU_LABEL)
299
+ return null;
300
+ if (typeof ariaLabel !== 'string' || ariaLabel.length > MAX_MENU_LABEL)
301
+ return null;
302
+ const rect = parseRect(anchor);
303
+ if (!rect)
304
+ return null;
305
+ if (!Array.isArray(groups))
306
+ return null;
307
+ const out = [];
308
+ let rows = 0;
309
+ for (const g of groups) {
310
+ if (!isRecord(g))
311
+ return null;
312
+ if (g.label !== undefined && (typeof g.label !== 'string' || g.label.length > MAX_MENU_LABEL))
313
+ return null;
314
+ if (!Array.isArray(g.options))
315
+ return null;
316
+ const options = [];
317
+ for (const o of g.options) {
318
+ if (!isRecord(o))
319
+ return null;
320
+ if (typeof o.value !== 'string' || o.value.length > MAX_MENU_LABEL)
321
+ return null;
322
+ if (typeof o.label !== 'string' || o.label.length > MAX_MENU_LABEL)
323
+ return null;
324
+ if (++rows > MAX_MENU_OPTIONS)
325
+ return null;
326
+ options.push({ value: o.value, label: o.label });
327
+ }
328
+ out.push(g.label === undefined ? { options } : { label: g.label, options });
329
+ }
330
+ if (rows === 0)
331
+ return null;
332
+ return { groups: out, value, ariaLabel, anchor: rect };
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getobsrv",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "See your site the way 1x screens see it",
5
5
  "main": "./out/main/index.js",
6
6
  "bin": {