prism-mcp-server 20.21.18 → 20.21.19

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/README.md CHANGED
@@ -158,6 +158,14 @@ or by re-enabling after each run.
158
158
  <details>
159
159
  <summary>Release history (optional)</summary>
160
160
 
161
+ ## What's New in v20.21.19
162
+
163
+ - Prism Browser emulates phones and tablets. `--device iphone-17`, or one of
164
+ five other measured iPhones and iPads, turns on touch CSS, the pixel ratio,
165
+ and the iPhone safe area, so a layout that only breaks on the phone can be
166
+ caught in a test. `--touch`, `--device-scale-factor`, and `--safe-area` set
167
+ each part on its own.
168
+
161
169
  ## What's New in v20.21.18
162
170
 
163
171
  - Fixed: when a save was refused with `context_not_loaded`, the instructions
@@ -80,6 +80,39 @@ state carries between runs and makes them order dependent. For test runs use
80
80
  `--trace PATH`, `--video DIR`, and `--har PATH` record Playwright artifacts for
81
81
  a failing run.
82
82
 
83
+ ## Phones and tablets
84
+
85
+ A desktop run never matches `(pointer: coarse)` and reads every
86
+ `env(safe-area-inset-*)` as 0, so CSS written for touchscreens, and layouts
87
+ that pad the iPhone status area and home indicator, cannot show up at a phone
88
+ size alone. Emulate the device instead:
89
+
90
+ - `--device NAME` — a measured iPhone or iPad in portrait: viewport, pixel
91
+ ratio, touch, and safe area. `iphone-se`, `iphone-17`, `iphone-air`,
92
+ `iphone-17-pro-max`, `ipad-a16`, `ipad-pro-13`. The values were read from a
93
+ full-screen web view on iOS 26.5 simulators.
94
+ - `--touch` — a touchscreen: `(pointer: coarse)` and `(hover: none)` match,
95
+ touch events exist, and the page's meta viewport is honoured.
96
+ - `--device-scale-factor N` — the device pixel ratio, for example `3`.
97
+ - `--safe-area T,R,B,L` — `env(safe-area-inset-*)` in CSS pixels, for example
98
+ `62,0,34,0`. On iOS a page reads the insets only if its meta viewport sets
99
+ `viewport-fit=cover`; without it an iPhone 17 reports 0. Chromium applies
100
+ them either way, so check that your page sets it.
101
+
102
+ Flags given explicitly override the preset, so a phone turned sideways is
103
+ `--device iphone-17 --viewport 874x402 --safe-area 0,62,21,62`. Landscape
104
+ insets are not part of the presets. The engine stays Chromium, and the user
105
+ agent stays the desktop one, so pages that branch on the user-agent string
106
+ still take their desktop path.
107
+
108
+ ```bash
109
+ printf '%s\n' \
110
+ 'open http://127.0.0.1:3000/app' \
111
+ 'assert-eval matchMedia("(pointer: coarse)").matches' \
112
+ 'screenshot phone.png' \
113
+ | prism browser --headless --fast --ephemeral-profile --local-only --device iphone-17 pipe
114
+ ```
115
+
83
116
  ## Multiple pages
84
117
 
85
118
  A popup (OAuth, payment, print preview) opens a new page. `pages` lists them,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.18",
3
+ "version": "20.21.19",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",
@@ -68,6 +68,19 @@ AUDIT_LOG_PATH = BROWSER_DATA_DIR / "audit.log"
68
68
  DEFAULT_PROFILE = "default"
69
69
  DEFAULT_TIMEOUT = 30000
70
70
  DEFAULT_VIEWPORT = (1440, 900)
71
+ # iOS devices in portrait as a full-screen WKWebView page sees them: CSS
72
+ # viewport, devicePixelRatio, and env(safe-area-inset-*) as (top, right,
73
+ # bottom, left). Measured 2026-10-03 on iOS 26.5 simulators, each running a
74
+ # full-screen WKWebView app whose page reported these values. Landscape was not
75
+ # measured: pass --viewport and --safe-area for it.
76
+ DEVICE_PRESETS: dict[str, dict] = {
77
+ 'iphone-se': {'viewport': (375, 667), 'scale': 2.0, 'safe_area': (20, 0, 0, 0)},
78
+ 'iphone-17': {'viewport': (402, 874), 'scale': 3.0, 'safe_area': (62, 0, 34, 0)},
79
+ 'iphone-air': {'viewport': (420, 912), 'scale': 3.0, 'safe_area': (68, 0, 34, 0)},
80
+ 'iphone-17-pro-max': {'viewport': (440, 956), 'scale': 3.0, 'safe_area': (62, 0, 34, 0)},
81
+ 'ipad-a16': {'viewport': (820, 1180), 'scale': 2.0, 'safe_area': (32, 0, 20, 0)},
82
+ 'ipad-pro-13': {'viewport': (1032, 1376), 'scale': 2.0, 'safe_area': (32, 0, 20, 0)},
83
+ }
71
84
  REPL_IDLE_TIMEOUT = 600 # 10 minutes — auto-close to prevent zombie Chromium
72
85
  PIPE_IDLE_TIMEOUT = 600 # stdin held open with no commands — same protection
73
86
  MAX_INIT_SCRIPT_BYTES = 256 * 1024
@@ -796,7 +809,8 @@ class StealthBrowserSession:
796
809
  allow_degraded_stealth=False, ephemeral_profile=False,
797
810
  storage_state_path=None, trace_path=None, video_dir=None,
798
811
  har_path=None, grant_permissions=None, geolocation=None,
799
- allow_http_error=False):
812
+ allow_http_error=False, touch=False, device_scale_factor=None,
813
+ safe_area=None):
800
814
  self.profile = validate_profile_name(profile)
801
815
  self.headless = headless
802
816
  self.timeout = timeout
@@ -814,6 +828,9 @@ class StealthBrowserSession:
814
828
  self.grant_permissions = list(grant_permissions or [])
815
829
  self.geolocation = geolocation
816
830
  self.allow_http_error = allow_http_error
831
+ self.touch = touch
832
+ self.device_scale_factor = device_scale_factor
833
+ self.safe_area = safe_area # (top, right, bottom, left) CSS px, or None
817
834
  self.stealth_degraded: list[str] = []
818
835
  self.fingerprint: dict = {}
819
836
  self._fingerprint_reverified = False
@@ -895,6 +912,13 @@ class StealthBrowserSession:
895
912
  'latitude': self.geolocation[0],
896
913
  'longitude': self.geolocation[1],
897
914
  }
915
+ if self.touch:
916
+ # A phone or tablet: (pointer: coarse) and (hover: none) match, touch
917
+ # events exist, and the page's meta viewport is honoured.
918
+ launch_kwargs['has_touch'] = True
919
+ launch_kwargs['is_mobile'] = True
920
+ if self.device_scale_factor:
921
+ launch_kwargs['device_scale_factor'] = self.device_scale_factor
898
922
  if self.video_dir:
899
923
  launch_kwargs['record_video_dir'] = str(self.video_dir)
900
924
  if self.har_path:
@@ -950,7 +974,8 @@ class StealthBrowserSession:
950
974
  audit_log(
951
975
  "session_start", f"profile={self.profile}",
952
976
  f"stealth={self.stealth_level},headless={self.headless},"
953
- f"local_only={self.local_only},degraded={len(self.stealth_degraded)}",
977
+ f"local_only={self.local_only},degraded={len(self.stealth_degraded)},"
978
+ f"touch={self.touch},scale={self.device_scale_factor},safe_area={self.safe_area}",
954
979
  )
955
980
 
956
981
  def _on_new_page(self, page):
@@ -990,6 +1015,29 @@ class StealthBrowserSession:
990
1015
  self.diagnostics.attach(page)
991
1016
  if self.stealth_level != "none":
992
1017
  self._apply_cdp_user_agent(page)
1018
+ # After the user-agent override, never before: Emulation.setUserAgentOverride
1019
+ # resets an earlier safe-area override on the same page, and env() then
1020
+ # reads 0 under --stealth full.
1021
+ if self.safe_area:
1022
+ self._apply_safe_area(page)
1023
+
1024
+ def _apply_safe_area(self, page):
1025
+ """
1026
+ Set env(safe-area-inset-*), which Playwright cannot emulate. Chromium
1027
+ applies it whether or not the page sets viewport-fit=cover; iOS reports
1028
+ 0 without it. A runtime without the override fails the run: a silent 0
1029
+ would test the wrong layout.
1030
+ """
1031
+ top, right, bottom, left = self.safe_area
1032
+ try:
1033
+ self._context.new_cdp_session(page).send('Emulation.setSafeAreaInsetsOverride', {
1034
+ 'insets': {'top': top, 'right': right, 'bottom': bottom, 'left': left},
1035
+ })
1036
+ except Exception as exc:
1037
+ raise RuntimeError(
1038
+ f"--safe-area could not be applied ({type(exc).__name__}: {exc}). "
1039
+ "It needs a Chromium with Emulation.setSafeAreaInsetsOverride."
1040
+ ) from exc
993
1041
 
994
1042
  def _apply_cdp_user_agent(self, page):
995
1043
  """
@@ -2309,7 +2357,15 @@ def build_parser():
2309
2357
  help='Delete screenshots captured in this run on exit (see --help notes)')
2310
2358
  p.add_argument('--sanitize', action='store_true', help='Mask PHI patterns in text output')
2311
2359
  p.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT, help='Timeout (ms)')
2312
- p.add_argument('--viewport', default='1440x900', help='Viewport WxH')
2360
+ p.add_argument('--viewport', default=None, help='Viewport WxH (default 1440x900, or the --device size)')
2361
+ p.add_argument('--device', choices=sorted(DEVICE_PRESETS),
2362
+ help='Emulate a measured iPhone or iPad in portrait: size, pixel ratio, touch and safe area')
2363
+ p.add_argument('--touch', action='store_true',
2364
+ help='Emulate a touchscreen: (pointer: coarse), (hover: none), touch events, meta viewport')
2365
+ p.add_argument('--device-scale-factor', metavar='N', help='Device pixel ratio, for example 3')
2366
+ p.add_argument('--safe-area', metavar='T,R,B,L',
2367
+ help='env(safe-area-inset-*) in CSS pixels, for example 62,0,34,0; unlike iOS, '
2368
+ 'applied even when the page omits viewport-fit=cover')
2313
2369
  p.add_argument('--stealth', choices=['full', 'light', 'none'], default='full',
2314
2370
  help='Fingerprint level: full (stealth lib + CDP + JS), light (CDP + JS), none')
2315
2371
  p.add_argument('--allow-degraded-stealth', action='store_true',
@@ -2422,6 +2478,54 @@ def parse_viewport(value: str) -> tuple[int, int]:
2422
2478
  return int(match.group(1)), int(match.group(2))
2423
2479
 
2424
2480
 
2481
+ def parse_safe_area(value: str) -> tuple[int, int, int, int]:
2482
+ """Parse T,R,B,L insets in CSS pixels, failing loudly like --viewport."""
2483
+ match = re.fullmatch(r'\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*', value or '')
2484
+ if not match:
2485
+ raise ValueError(
2486
+ f"Invalid --safe-area {value!r}. Expected T,R,B,L in CSS pixels, for example 62,0,34,0."
2487
+ )
2488
+ return tuple(int(group) for group in match.groups())
2489
+
2490
+
2491
+ def parse_device_scale_factor(value: str) -> float:
2492
+ """Parse a device pixel ratio, failing loudly on anything outside 0.5 to 5."""
2493
+ try:
2494
+ scale = float(value)
2495
+ except (TypeError, ValueError):
2496
+ scale = 0.0
2497
+ if not 0.5 <= scale <= 5:
2498
+ raise ValueError(f"Invalid --device-scale-factor {value!r}. Expected a number from 0.5 to 5, for example 3.")
2499
+ return scale
2500
+
2501
+
2502
+ def resolve_device_options(device, viewport, touch, scale, safe_area):
2503
+ """
2504
+ Combine --device with the individual flags. The preset supplies defaults;
2505
+ any flag given explicitly wins, so --device iphone-17 --viewport 874x402
2506
+ is the same phone turned sideways (pass its landscape --safe-area too).
2507
+ """
2508
+ # "Given" means not None: an empty value (an unset shell variable) must fail
2509
+ # loudly, not quietly fall back to a desktop default.
2510
+ preset = DEVICE_PRESETS.get(device) if device is not None else None
2511
+ if device is not None and preset is None:
2512
+ raise ValueError(f"Unknown --device {device!r}. Known: {', '.join(sorted(DEVICE_PRESETS))}.")
2513
+ if viewport is not None:
2514
+ size = parse_viewport(viewport)
2515
+ elif preset:
2516
+ size = tuple(preset['viewport'])
2517
+ else:
2518
+ size = DEFAULT_VIEWPORT
2519
+ return {
2520
+ 'viewport': size,
2521
+ 'touch': bool(touch or preset),
2522
+ 'device_scale_factor': (parse_device_scale_factor(scale) if scale is not None
2523
+ else (preset['scale'] if preset else None)),
2524
+ 'safe_area': (parse_safe_area(safe_area) if safe_area is not None
2525
+ else (tuple(preset['safe_area']) if preset else None)),
2526
+ }
2527
+
2528
+
2425
2529
  def parse_geolocation(value: str) -> tuple[float, float]:
2426
2530
  parts = (value or '').split(',')
2427
2531
  if len(parts) != 2:
@@ -2447,9 +2551,12 @@ def main():
2447
2551
  return 1
2448
2552
 
2449
2553
  try:
2450
- viewport = parse_viewport(args.viewport)
2554
+ device = resolve_device_options(
2555
+ args.device, args.viewport, args.touch, args.device_scale_factor, args.safe_area,
2556
+ )
2451
2557
  except ValueError as exc:
2452
2558
  parser.error(str(exc))
2559
+ viewport = device['viewport']
2453
2560
 
2454
2561
  geolocation = None
2455
2562
  if args.geolocation:
@@ -2488,6 +2595,9 @@ def main():
2488
2595
  grant_permissions=args.grant,
2489
2596
  geolocation=geolocation,
2490
2597
  allow_http_error=args.allow_http_error,
2598
+ touch=device['touch'],
2599
+ device_scale_factor=device['device_scale_factor'],
2600
+ safe_area=device['safe_area'],
2491
2601
  )
2492
2602
  except (ValueError, StealthConfigurationError) as exc:
2493
2603
  print(json.dumps({"status": "error", "message": str(exc)}), file=sys.stderr)