tapline-host 1.0.0 → 1.2.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.
Files changed (53) hide show
  1. package/dist/actionlog.d.ts +43 -0
  2. package/dist/actionlog.js +62 -0
  3. package/dist/actionlog.js.map +1 -0
  4. package/dist/audit.d.ts +44 -0
  5. package/dist/audit.js +60 -0
  6. package/dist/audit.js.map +1 -0
  7. package/dist/cli.js +82 -17
  8. package/dist/cli.js.map +1 -1
  9. package/dist/confirm.d.ts +69 -0
  10. package/dist/confirm.js +120 -0
  11. package/dist/confirm.js.map +1 -0
  12. package/dist/format.d.ts +8 -1
  13. package/dist/format.js +53 -11
  14. package/dist/format.js.map +1 -1
  15. package/dist/images.d.ts +7 -0
  16. package/dist/images.js +10 -0
  17. package/dist/images.js.map +1 -1
  18. package/dist/init.d.ts +59 -0
  19. package/dist/init.js +475 -0
  20. package/dist/init.js.map +1 -0
  21. package/dist/inspect.d.ts +75 -0
  22. package/dist/inspect.js +127 -0
  23. package/dist/inspect.js.map +1 -0
  24. package/dist/mcp.d.ts +4 -1
  25. package/dist/mcp.js +56 -2
  26. package/dist/mcp.js.map +1 -1
  27. package/dist/policy.d.ts +42 -0
  28. package/dist/policy.js +74 -0
  29. package/dist/policy.js.map +1 -0
  30. package/dist/run.d.ts +53 -0
  31. package/dist/run.js +97 -0
  32. package/dist/run.js.map +1 -0
  33. package/dist/script.d.ts +76 -0
  34. package/dist/script.js +169 -0
  35. package/dist/script.js.map +1 -0
  36. package/dist/server.d.ts +39 -0
  37. package/dist/server.js +120 -0
  38. package/dist/server.js.map +1 -1
  39. package/dist/tool-text-data.d.ts +25 -8
  40. package/dist/tool-text-data.js +25 -8
  41. package/dist/tool-text-data.js.map +1 -1
  42. package/dist/tool-text.d.ts +1 -1
  43. package/dist/tools.d.ts +221 -7
  44. package/dist/tools.js +1451 -168
  45. package/dist/tools.js.map +1 -1
  46. package/dist/viewer-page.js +1 -1
  47. package/dist/viewer-page.js.map +1 -1
  48. package/dist/viewer.d.ts +3 -0
  49. package/dist/viewer.js +21 -4
  50. package/dist/viewer.js.map +1 -1
  51. package/package.json +2 -2
  52. package/skills/tapline/SKILL.md +42 -12
  53. package/skills/tapline/references/setup.md +24 -2
@@ -6,5 +6,5 @@
6
6
  * references). The host serves it at GET /viewer and GET /, on the same origin as /stream and /ws;
7
7
  * it is a client of docs/VIEWER-PROTOCOL.md.
8
8
  */
9
- export const VIEWER_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<title>Live view</title>\n<link rel=\"icon\" href=\"data:,\">\n<!-- The shared live view page: a client of docs/VIEWER-PROTOCOL.md. One file, inline CSS and\n script, no external references. It never names the tool that serves it: the tool's name comes\n in the status message (tool). The text between the video-block markers is shared byte for\n byte with other copies of this page. Change it only by agreement. -->\n<style>\n:root {\n color-scheme: light dark;\n --bg: #e9ecf1;\n --stage-hi: #ffffff;\n --stage-lo: #d9dee6;\n --panel: rgba(255, 255, 255, 0.72);\n --panel-solid: #ffffff;\n --line: rgba(17, 24, 39, 0.12);\n --text: #111827;\n --muted: #4b5563;\n --accent: #0a66d1;\n --btn: rgba(255, 255, 255, 0.9);\n --btn-line-hover: rgba(17, 24, 39, 0.32);\n --pressed: #dcebff;\n --dot-idle: #6b7480;\n --dot-live: #1f883d;\n --dot-wait: #9a6700;\n --dot-bad: #cf222e;\n --notice-bg: #fdecec;\n --notice-line: #c62828;\n --notice-text: #7a1212;\n /* The phone's body: a brushed-metal edge around a black bezel. It is drawn, never a photo. */\n --metal-1: #c9ccd1;\n --metal-2: #8d9197;\n --metal-3: #eceef1;\n --bezel: #050506;\n --frame-shadow: 0 30px 60px -20px rgba(15, 23, 42, 0.45), 0 18px 36px -18px rgba(15, 23, 42, 0.35);\n /* Marks and overlay labels sit on the phone's picture, which doesn't follow the theme, so\n they keep one set of colours: dark text on a light fill, readable on any picture. */\n --agent: #4cc2ff;\n --person: #8be28b;\n --fail: #ff6b6b;\n --on-mark: #000000;\n --label-bg: #ffd60a;\n --label-text: #000000;\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --bg: #0b0d11;\n --stage-hi: #2d3440;\n --stage-lo: #0b0d11;\n --panel: rgba(22, 26, 33, 0.78);\n --panel-solid: #161a21;\n --line: rgba(255, 255, 255, 0.1);\n --text: #e8ebf0;\n --muted: #9aa4b2;\n --accent: #7cc4ff;\n --btn: rgba(255, 255, 255, 0.06);\n --btn-line-hover: rgba(255, 255, 255, 0.32);\n --pressed: rgba(124, 196, 255, 0.18);\n --dot-live: #3fb950;\n --dot-wait: #d29922;\n --dot-bad: #ff6b6b;\n --notice-bg: #3a1c1c;\n --notice-line: #ff6b6b;\n --notice-text: #ffd7d7;\n --metal-1: #80858d;\n --metal-2: #3d4148;\n --metal-3: #c2c7ce;\n --frame-shadow: 0 40px 80px -24px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 60px 0 rgba(124, 196, 255, 0.06);\n }\n}\n* { box-sizing: border-box; }\n[hidden] { display: none !important; }\nhtml, body { margin: 0; height: 100%; }\nbody {\n height: 100vh;\n height: 100dvh;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n background: var(--bg) radial-gradient(120% 90% at 50% 38%, var(--stage-hi) 0%, var(--stage-lo) 70%) no-repeat;\n color: var(--text);\n font: 14px/1.4 ui-rounded, \"SF Pro Rounded\", system-ui, -apple-system, \"Segoe UI Variable Text\", \"Segoe UI\", sans-serif;\n overflow: hidden;\n}\n#bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px 12px;\n margin: max(10px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));\n padding: 6px 6px 6px 16px;\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 999px;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n}\n#heading {\n flex: none;\n margin: 0;\n font-size: 15px;\n font-weight: 650;\n letter-spacing: -0.01em;\n line-height: 1.4;\n}\n#statusline {\n flex: 1 1 200px;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 8px;\n height: 36px;\n}\n#dot {\n flex: none;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--dot-idle);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-idle) 22%, transparent);\n}\n#dot.live { background: var(--dot-live); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-live) 22%, transparent); }\n#dot.wait { background: var(--dot-wait); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-wait) 22%, transparent); }\n#dot.bad { background: var(--dot-bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-bad) 22%, transparent); }\n#status {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n color: var(--muted);\n font-variant-numeric: tabular-nums;\n}\n#announce {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n}\n#tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }\nbutton, select {\n min-height: 36px;\n padding: 0 14px;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--btn);\n color: var(--text);\n font: inherit;\n font-weight: 550;\n cursor: pointer;\n}\nselect { padding-right: 8px; }\n/* The list a select opens is drawn by the system, on its own background: the options get the theme's\n colours, or dark mode drew the page's light text on the system's white (seen by the owner, 2026-11-11). */\nselect option { background: #ffffff; color: #111827; }\n@media (prefers-color-scheme: dark) {\n select option { background: #1b2029; color: #e8ebf0; }\n}\nbutton:hover, select:hover { border-color: var(--btn-line-hover); }\nbutton[aria-pressed=\"true\"] { background: var(--pressed); border-color: var(--accent); color: var(--accent); }\n:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\nlabel.q { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); padding-left: 6px; }\n@media (pointer: coarse) {\n button, select { min-height: 44px; }\n #statusline { height: 44px; }\n}\n@media (max-width: 560px) {\n #bar { border-radius: 18px; padding: 8px 8px 8px 14px; gap: 4px 10px; }\n #heading { font-size: 14px; }\n #statusline { flex: 1 1 0; height: 28px; }\n #tools { flex: 1 0 100%; gap: 6px; }\n #tools > * { flex: none; }\n button, select { min-height: 34px; padding: 0 12px; }\n label.q { padding-left: 0; font-size: 0; gap: 0; } /* the select keeps its own label for screen readers */\n label.q select { font-size: 14px; }\n /* short visible names on a narrow screen; the buttons' aria-labels keep the full ones */\n #vol-down, #vol-up { font-size: 0; padding: 0 12px; }\n #vol-down::after { content: \"Vol \\2212\"; font-size: 14px; }\n #vol-up::after { content: \"Vol +\"; font-size: 14px; }\n}\n@media (max-width: 560px) and (pointer: coarse) {\n button, select { min-height: 40px; }\n}\n#stage {\n position: relative;\n min-height: 0;\n display: grid;\n place-items: center;\n overflow: hidden;\n}\n/* The drawn phone. #frame inside it is the picture's own rectangle: every click is mapped from it,\n so nothing here may move it or catch an event over it. The bezel and the rounded screen corners\n are painted on top by #glass, which lets events through to the picture, corners included. */\n#device {\n --b: 14px; /* from the screen's edge to the body's outer edge */\n --edge: 3px; /* the metal rim, part of --b */\n --r: 44px; /* the screen's corner radius */\n --s: 200px; /* the screen's short side */\n position: relative;\n padding: var(--b);\n border-radius: calc(var(--r) + var(--b));\n background: linear-gradient(135deg, var(--metal-3) 0%, var(--metal-1) 22%, var(--metal-2) 50%, var(--metal-1) 78%, var(--metal-3) 100%);\n box-shadow: var(--frame-shadow), inset 0 0 0 1px rgba(255, 255, 255, 0.35), inset 0 0 0 2px rgba(0, 0, 0, 0.25);\n}\n#frame {\n position: relative;\n z-index: 1;\n width: 200px;\n height: 433px;\n overflow: hidden;\n outline: none;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n transform: translateZ(0); /* its own layer: a new picture repaints only the screen */\n}\n#frame.can-point { cursor: crosshair; }\n/* The picture's layers are clipped to the rounded screen. They take no events, so a click in a clipped\n corner still lands on #frame's plain rectangle and maps as before. */\n.pic, #empty, #overlay, #marks, #trail { clip-path: inset(0 round var(--r)); }\n#empty { background: #000; }\n#glass {\n position: absolute;\n z-index: 2;\n left: calc(var(--b) + 1px);\n top: calc(var(--b) + 1px);\n right: calc(var(--b) + 1px);\n bottom: calc(var(--b) + 1px);\n border-radius: calc(var(--r) - 1px);\n /* the black bezel, spread from the screen's rounded edge: it also covers the picture's square corners,\n and its inner 1 px overlaps the picture so no colour shows along the curve */\n box-shadow: 0 0 0 calc(var(--b) - var(--edge) + 1px) var(--bezel), inset 0 0 0 1px rgba(255, 255, 255, 0.05);\n pointer-events: none;\n}\n#frame:focus-visible + #glass { box-shadow: 0 0 0 calc(var(--b) - var(--edge) + 1px) var(--bezel), inset 0 0 0 2px var(--accent); }\n#island {\n position: absolute;\n left: 50%;\n top: calc(var(--s) * 0.026);\n width: calc(var(--s) * 0.293);\n height: calc(var(--s) * 0.086);\n transform: translateX(-50%);\n border-radius: 999px;\n background: #000;\n pointer-events: none;\n}\n#device.wide #island {\n left: calc(var(--s) * 0.026);\n top: 50%;\n width: calc(var(--s) * 0.086);\n height: calc(var(--s) * 0.293);\n transform: translateY(-50%);\n}\n#device.no-island #island { display: none; }\n/* Side keys: drawn on the body's edge. They take a click only where the server accepts the key\n (status.accepts), and then only as a shortcut for the labelled buttons in the toolbar. */\n.key {\n position: absolute;\n z-index: 0;\n padding: 0;\n min-height: 0;\n border: 0;\n border-radius: 2px;\n background: var(--metal-1);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25), 0 0 0 0.5px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n cursor: default;\n}\n.key.live { pointer-events: auto; cursor: pointer; }\n.key.live:hover { filter: brightness(1.12); }\n.key.live:active { transform: scale(0.96); }\n#key-action { left: -4px; width: 5px; top: 17%; height: 4%; }\n#key-vol-up { left: -4px; width: 5px; top: 25%; height: 7.5%; }\n#key-vol-down { left: -4px; width: 5px; top: 34%; height: 7.5%; }\n#key-side { right: -4px; width: 5px; top: 27%; height: 11%; }\n/* Landscape: the phone turned with its top to the left, so the left keys are at the bottom. */\n#device.wide #key-action { left: 17%; top: auto; bottom: -4px; width: 4%; height: 5px; }\n#device.wide #key-vol-up { left: 25%; top: auto; bottom: -4px; width: 7.5%; height: 5px; }\n#device.wide #key-vol-down { left: 34%; top: auto; bottom: -4px; width: 7.5%; height: 5px; }\n#device.wide #key-side { left: 27%; right: auto; top: -4px; width: 11%; height: 5px; }\n.pic {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: fill;\n pointer-events: none;\n -webkit-user-drag: none;\n}\n#empty {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n padding: 16px;\n text-align: center;\n color: #8b93a1;\n}\n#frame.has-frame #empty { display: none; }\n#overlay, #marks, #trail { position: absolute; inset: 0; pointer-events: none; }\n#trail { width: 100%; height: 100%; }\n#overlay.stale { opacity: 0.3; }\n.box {\n position: absolute;\n outline: 1px solid rgba(255, 214, 10, 0.9);\n outline-offset: -1px;\n}\n.box > span {\n position: absolute;\n left: -1px;\n bottom: 100%; /* above the box, so it doesn't cover what the box marks */\n max-width: 100%;\n overflow: hidden;\n padding: 0 2px;\n font: 10px/12px ui-monospace, Consolas, monospace;\n color: var(--label-text);\n background: var(--label-bg);\n white-space: nowrap;\n}\n.mark {\n --c: var(--agent);\n position: absolute;\n animation: markfade 1.6s ease-out forwards;\n}\n.mark.person { --c: var(--person); }\n.mark.fail { --c: var(--fail); animation-duration: 2.6s; }\n.mark .tag {\n position: absolute;\n padding: 1px 7px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 16px;\n white-space: nowrap;\n color: var(--on-mark);\n background: var(--c);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);\n}\n.mark.ring { width: 0; height: 0; }\n.mark.ring::before {\n content: \"\";\n position: absolute;\n left: -16px;\n top: -16px;\n width: 32px;\n height: 32px;\n border: 3px solid var(--c);\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(0, 0, 0, 0.6);\n}\n.mark.ring .tag { left: 20px; top: -8px; }\n/* near the right edge the tag goes on the ring's left, so it stays on the picture */\n.mark.ring.flip .tag { left: auto; right: 20px; }\n.mark.rect {\n border: 2px solid var(--c);\n border-radius: 4px;\n background: rgba(255, 255, 255, 0.08);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(0, 0, 0, 0.6);\n}\n.mark.rect .tag { left: -2px; top: -20px; }\n.mark.pill .tag { position: absolute; left: 8px; top: 8px; }\n.mark.pending { animation: none; opacity: 0.55; }\n.mark.pending.gone { opacity: 0; transition: opacity 0.3s ease-out; }\n@keyframes markfade {\n 0% { opacity: 1; }\n 70% { opacity: 1; }\n 100% { opacity: 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n .mark { animation: none; }\n .key.live:active { transform: none; }\n}\n#notice {\n position: absolute;\n z-index: 3;\n left: 50%;\n bottom: 14px;\n max-width: calc(100% - 32px);\n transform: translateX(-50%);\n padding: 8px 14px;\n border-radius: 999px;\n background: var(--notice-bg);\n border: 1px solid var(--notice-line);\n color: var(--notice-text);\n opacity: 0;\n pointer-events: none;\n}\n#notice.on { opacity: 1; }\n#notice { bottom: auto; top: 12px; }\n/* The More menu: everything technical (Details, boxes, picture quality, the phone's keys), out of the bar. Drawn\n by the page itself, so it follows the theme; a native select's list is drawn by the system (light text on white\n in dark mode on Windows, seen by the owner). */\n.more { position: relative; }\n#more-btn[aria-expanded=\"true\"] { background: var(--pressed); border-color: var(--accent); color: var(--accent); }\n#more-menu {\n position: absolute;\n z-index: 10;\n right: 0;\n top: calc(100% + 14px);\n width: 260px;\n max-width: calc(100vw - 32px);\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 14px;\n border-radius: 18px;\n background: var(--panel-solid);\n border: 1px solid var(--line);\n box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);\n}\n.menu-group { display: flex; flex-direction: column; gap: 8px; }\n.menu-label { color: var(--text); opacity: 0.9; font-size: 12px; font-weight: 650; letter-spacing: 0.02em; }\n.menu-row { display: flex; flex-wrap: wrap; gap: 6px; }\n.seg { display: flex; padding: 3px; gap: 2px; border: 1px solid var(--line); border-radius: 999px; }\n.seg button { flex: 1; min-height: 32px; padding: 0 10px; border: 0; background: transparent; }\n.seg button[aria-pressed=\"true\"] { background: var(--accent); color: var(--panel-solid); }\n#phone-keys .menu-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }\n#phone-keys button { padding: 0 6px; }\n/* short visible names in the menu; the buttons' aria-labels keep the full ones */\n#more-menu #vol-down, #more-menu #vol-up { font-size: 0; }\n#more-menu #vol-down::after { content: \"Vol \\2212\"; font-size: 14px; }\n#more-menu #vol-up::after { content: \"Vol +\"; font-size: 14px; }\n@media (max-width: 560px) {\n #more-menu { position: fixed; left: 16px; right: 16px; width: auto; max-width: none; }\n}\n/* Presentation view (VIEWER-PROTOCOL 1.4): people see the step in plain words under the phone and a ripple at\n the touch. The element's name beside a touch, the boxes and the coordinates are the Details view's. */\nbody:not(.details) .mark .tag { display: none; }\n.mark.ring::after {\n content: \"\";\n position: absolute;\n left: -16px;\n top: -16px;\n width: 32px;\n height: 32px;\n border-radius: 50%;\n border: 2px solid var(--c);\n animation: ripple 0.7s ease-out forwards;\n}\n@keyframes ripple {\n 0% { transform: scale(0.6); opacity: 0.9; }\n 100% { transform: scale(2.4); opacity: 0; }\n}\n#caption {\n position: absolute;\n z-index: 3;\n left: 50%;\n bottom: 18px;\n max-width: calc(100% - 32px);\n transform: translate(-50%, 6px);\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 16px 8px 12px;\n border-radius: 999px;\n background: var(--panel);\n border: 1px solid var(--line);\n box-shadow: 0 6px 20px -8px rgba(0, 0, 0, 0.35);\n color: var(--text);\n font-size: 15px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.2s ease-out, transform 0.2s ease-out;\n}\n#caption.on { opacity: 1; transform: translate(-50%, 0); }\n#caption::before {\n content: \"\";\n flex: none;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--agent);\n}\n#caption.person::before { background: var(--person); }\n#caption.fail { border-color: var(--notice-line); color: var(--notice-text); background: var(--notice-bg); }\n#caption.fail::before { background: var(--fail); }\n#steps {\n position: absolute;\n z-index: 4;\n top: 12px;\n right: 16px;\n bottom: 64px;\n width: 320px;\n max-width: calc(100% - 32px);\n display: flex;\n flex-direction: column;\n border-radius: 20px;\n background: var(--panel-solid);\n border: 1px solid var(--line);\n box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);\n overflow: hidden;\n}\n.steps-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 18px;\n border-bottom: 1px solid var(--line); }\n.steps-head h2 { margin: 0; font-size: 15px; font-weight: 650; }\n#steps-close { min-height: 32px; width: 32px; padding: 0; font-size: 20px; line-height: 1; }\n#steps-list { list-style: none; margin: 0; padding: 6px 8px 10px; overflow-y: auto; flex: 1; }\n#steps-list li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 10px;\n padding: 8px 10px; border-radius: 12px; }\n#steps-list li + li { margin-top: 2px; }\n#steps-list .ico { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font: 15px/1 \"Segoe UI Symbol\", \"Apple Symbols\", system-ui, sans-serif;\n color: var(--on-mark); background: var(--agent); }\n#steps-list li.person .ico { background: var(--person); }\n#steps-list li.fail .ico { background: var(--fail); }\n#steps-list .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n#steps-list li.fail .txt { color: var(--dot-bad); }\n#steps-list .ago { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }\n#steps-empty { margin: 0; padding: 18px; color: var(--muted); }\n#steps-list:not(:empty) + #steps-empty { display: none; }\n@media (max-width: 560px) {\n #steps { top: auto; left: 12px; right: 12px; bottom: 72px; width: auto; max-width: none; height: 48%; }\n}\n@media (prefers-reduced-motion: reduce) {\n .mark.ring::after { animation: none; opacity: 0; }\n #caption { transition: none; }\n}\n</style>\n</head>\n<body>\n<header id=\"bar\">\n <h1 id=\"heading\">Live view</h1>\n <div id=\"statusline\">\n <span id=\"dot\" aria-hidden=\"true\"></span>\n <span id=\"status\">Connecting</span>\n <span id=\"announce\" role=\"status\" aria-live=\"polite\"></span>\n </div>\n <div id=\"tools\" role=\"toolbar\" aria-label=\"Viewer controls\">\n <button type=\"button\" id=\"pause\" aria-pressed=\"false\">Pause agent</button>\n <button type=\"button\" id=\"steps-btn\" aria-expanded=\"false\" aria-controls=\"steps\">Steps</button>\n <div class=\"more\">\n <button type=\"button\" id=\"more-btn\" aria-haspopup=\"true\" aria-expanded=\"false\" aria-controls=\"more-menu\">More</button>\n <div id=\"more-menu\" hidden>\n <div class=\"menu-row\">\n <button type=\"button\" id=\"details\" aria-pressed=\"false\">Details</button>\n <button type=\"button\" id=\"boxes\" aria-pressed=\"false\" hidden>Boxes</button>\n </div>\n <div class=\"menu-group\" id=\"quality-wrap\" role=\"group\" aria-labelledby=\"quality-label\" hidden>\n <span class=\"menu-label\" id=\"quality-label\">Picture</span>\n <div class=\"seg\" id=\"quality-seg\">\n <button type=\"button\" data-q=\"sharp\" aria-pressed=\"false\">Sharp</button>\n <button type=\"button\" data-q=\"balanced\" aria-pressed=\"true\">Balanced</button>\n <button type=\"button\" data-q=\"light\" aria-pressed=\"false\">Light</button>\n </div>\n <select id=\"quality\" aria-label=\"Stream quality\" hidden>\n <option value=\"sharp\">Sharp</option>\n <option value=\"balanced\" selected>Balanced</option>\n <option value=\"light\">Light</option>\n </select>\n </div>\n <div class=\"menu-group\" id=\"phone-keys\" hidden>\n <span class=\"menu-label\">Phone</span>\n <div class=\"menu-row\">\n <button type=\"button\" id=\"home\" hidden>Home</button>\n <button type=\"button\" id=\"vol-down\" aria-label=\"Volume down\" hidden>Volume down</button>\n <button type=\"button\" id=\"vol-up\" aria-label=\"Volume up\" hidden>Volume up</button>\n </div>\n </div>\n </div>\n </div>\n </div>\n</header>\n<main id=\"stage\">\n <div id=\"device\">\n <span class=\"key\" id=\"key-action\" aria-hidden=\"true\"></span>\n <span class=\"key\" id=\"key-vol-up\" aria-hidden=\"true\" title=\"Volume up\"></span>\n <span class=\"key\" id=\"key-vol-down\" aria-hidden=\"true\" title=\"Volume down\"></span>\n <span class=\"key\" id=\"key-side\" aria-hidden=\"true\"></span>\n <div id=\"frame\" tabindex=\"0\" role=\"img\" aria-label=\"Live phone screen\">\n <img id=\"screen\" class=\"pic\" alt=\"\">\n <div id=\"empty\">Waiting for the first frame</div>\n <div id=\"overlay\" aria-hidden=\"true\"></div>\n <canvas id=\"trail\" aria-hidden=\"true\"></canvas>\n <div id=\"marks\" aria-hidden=\"true\"></div>\n </div>\n <div id=\"glass\" aria-hidden=\"true\"><div id=\"island\"></div></div>\n </div>\n <div id=\"caption\" aria-hidden=\"true\"></div>\n <aside id=\"steps\" aria-label=\"Steps\" hidden>\n <div class=\"steps-head\">\n <h2>Steps</h2>\n <button type=\"button\" id=\"steps-close\" aria-label=\"Close steps\">&times;</button>\n </div>\n <ol id=\"steps-list\"></ol>\n <p id=\"steps-empty\">No steps yet. Each touch, typing and scroll shows here.</p>\n </aside>\n <div id=\"notice\" role=\"alert\"></div>\n</main>\n<script>\n\"use strict\";\n\n/* parser:start */\nvar CRLF2 = new Uint8Array([13, 10, 13, 10]);\n\nfunction boundaryFromContentType(contentType) {\n var m = /boundary=(?:\"([^\"]+)\"|([^;\\s]+))/i.exec(contentType || \"\");\n return m ? (m[1] || m[2]) : \"frame\";\n}\n\nfunction createParserState(boundary) {\n var enc = new TextEncoder();\n return {\n delim: enc.encode(\"--\" + boundary),\n closer: enc.encode(\"\\r\\n--\" + boundary),\n mode: \"preamble\",\n buf: new Uint8Array(0),\n headers: null,\n length: -1\n };\n}\n\nfunction concatBytes(a, b) {\n if (a.length === 0) return b;\n var out = new Uint8Array(a.length + b.length);\n out.set(a, 0);\n out.set(b, a.length);\n return out;\n}\n\nfunction indexOfBytes(hay, needle, from) {\n var n = needle.length;\n var last = hay.length - n;\n var first = needle[0];\n for (var i = from || 0; i <= last; i++) {\n if (hay[i] !== first) continue;\n var j = 1;\n while (j < n && hay[i + j] === needle[j]) j++;\n if (j === n) return i;\n }\n return -1;\n}\n\nfunction parsePartHeaders(bytes) {\n var text = new TextDecoder().decode(bytes);\n var lines = text.split(\"\\r\\n\");\n var headers = {};\n for (var i = 0; i < lines.length; i++) {\n var at = lines[i].indexOf(\":\");\n if (at > 0) headers[lines[i].slice(0, at).trim().toLowerCase()] = lines[i].slice(at + 1).trim();\n }\n return headers;\n}\n\n/* Feed one chunk of the response body; returns the parts completed by it, each\n { headers, body } with lower-case header names and the exact body bytes. */\nfunction parseMultipartChunk(state, chunk) {\n var parts = [];\n state.buf = concatBytes(state.buf, chunk);\n for (;;) {\n var buf = state.buf;\n if (state.mode === \"done\") {\n state.buf = new Uint8Array(0);\n break;\n }\n if (state.mode === \"preamble\") {\n var at = indexOfBytes(buf, state.delim, 0);\n if (at < 0) {\n state.buf = buf.subarray(Math.max(0, buf.length - state.delim.length + 1));\n break;\n }\n state.buf = buf.subarray(at + state.delim.length);\n state.mode = \"headers\";\n continue;\n }\n if (state.mode === \"headers\") {\n if (buf.length >= 2 && buf[0] === 45 && buf[1] === 45) {\n state.mode = \"done\";\n continue;\n }\n var end = indexOfBytes(buf, CRLF2, 0);\n if (end < 0) break;\n state.headers = parsePartHeaders(buf.subarray(0, end));\n var len = parseInt(state.headers[\"content-length\"], 10);\n state.length = len >= 0 ? len : -1;\n state.buf = buf.subarray(end + 4);\n state.mode = \"body\";\n continue;\n }\n if (state.length >= 0) {\n if (buf.length < state.length) break;\n parts.push({ headers: state.headers, body: buf.slice(0, state.length) });\n state.buf = buf.subarray(state.length);\n } else {\n var stop = indexOfBytes(buf, state.closer, 0);\n if (stop < 0) break;\n parts.push({ headers: state.headers, body: buf.slice(0, stop) });\n state.buf = buf.subarray(stop + 2);\n }\n state.mode = \"preamble\";\n state.headers = null;\n }\n return parts;\n}\n/* parser:end */\n\n/* video-block:start */\n/* Video block: H.264 over a WebSocket (WS /video in docs/VIEWER-PROTOCOL.md). It is self-contained:\n paste it unchanged into another page. It reaches the page only through the object given to\n createVideoBlock(io), whose members are:\n io.socketUrl() returns the ws or wss address of /video on the page's own origin\n io.accepts() returns true while the latest status lists video in accepts\n io.picture() returns the picture element (an img) the canvas stands in for. The canvas is\n inserted after it with the same className, so the img needs a class that\n carries its position and size (here: pic). Its style.display is toggled.\n io.onVideoConfig(c) optional. c is { width, height, coverage, source }; called on every accepted\n config, so the page can update its aspect ratio and coverage note\n io.onVideoStarted() the first decoded frame is on screen: stop downloading the JPEG stream\n io.onVideoStopped(why) video is gone (why is a short string) and the picture element shows again:\n start the JPEG stream again\n It returns { update(), stop(), stats() }. Call update() once at start and whenever the status\n changes. Fallback: unsupported config, 3 decoder errors within 10 s, a closed socket, or accepts\n losing video all stop the block; it retries after 5 s only while io.accepts() is still true (an\n unsupported config is not retried until the page is reloaded). */\nfunction createVideoBlock(io) {\n var RETRY_MS = 5000;\n var QUEUE_MAX = 3;\n var ERRORS_MAX = 3;\n var ERRORS_WINDOW_MS = 10000;\n var PENDING_MAX = 120;\n var sock = null;\n var decoder = null;\n var cfg = null;\n var cfgSeq = 0;\n var ready = false;\n var pending = [];\n var needKey = true;\n var keyAsked = false;\n var canvas = null;\n var ctx = null;\n var visible = false;\n var blocked = false;\n var retryTimer = 0;\n var errorTimes = [];\n var counts = {\n configs: 0, pictures: 0, frames: 0, ignored: 0, skipped: 0,\n errors: 0, keyRequests: 0, fallbacks: 0, hardware: \"\", lastReason: \"\", lastError: \"\"\n };\n\n function hasDecoder() {\n return typeof window.VideoDecoder === \"function\" && typeof window.EncodedVideoChunk === \"function\";\n }\n\n function sendKey() {\n if (!sock || sock.readyState !== 1) return;\n try {\n sock.send('{\"type\":\"key\"}');\n counts.keyRequests++;\n } catch (e) { /* the close handler follows */ }\n }\n\n function closeDecoder() {\n var d = decoder;\n decoder = null;\n if (d) {\n try { d.close(); } catch (e) { /* already closed */ }\n }\n }\n\n function clearRetry() {\n if (retryTimer) clearTimeout(retryTimer);\n retryTimer = 0;\n }\n\n /* Drop the socket and the decoder; if video was showing, put the picture element back. */\n function teardown(reason) {\n var s = sock;\n sock = null;\n if (s) {\n s.onmessage = null;\n s.onclose = null;\n s.onerror = null;\n try { s.close(); } catch (e) { /* already closed */ }\n }\n cfgSeq++;\n ready = false;\n pending = [];\n closeDecoder();\n if (visible) {\n visible = false;\n if (canvas) canvas.style.display = \"none\";\n var pic = io.picture();\n if (pic) pic.style.display = \"\";\n counts.fallbacks++;\n counts.lastReason = reason;\n io.onVideoStopped(reason);\n }\n }\n\n function fail(reason) {\n counts.lastReason = reason;\n teardown(reason);\n clearRetry();\n if (hasDecoder() && !blocked && io.accepts()) {\n retryTimer = setTimeout(function () {\n retryTimer = 0;\n update();\n }, RETRY_MS);\n }\n }\n\n function update() {\n var want = hasDecoder() && !blocked && !!io.accepts();\n if (!want) {\n clearRetry();\n if (sock || visible) teardown(\"not offered\");\n return;\n }\n if (!sock && !retryTimer) open();\n }\n\n function stop() {\n clearRetry();\n teardown(\"stopped\");\n }\n\n function open() {\n var s;\n try {\n s = new WebSocket(io.socketUrl());\n s.binaryType = \"arraybuffer\";\n } catch (e) {\n fail(\"socket\");\n return;\n }\n sock = s;\n s.onmessage = function (ev) {\n if (sock !== s) return;\n if (typeof ev.data === \"string\") onText(ev.data);\n else onPicture(ev.data);\n };\n s.onclose = function () {\n if (sock !== s) return;\n sock = null;\n fail(\"closed\");\n };\n s.onerror = function () { /* a close follows */ };\n }\n\n function positive(v) { return typeof v === \"number\" && isFinite(v) && v > 0; }\n\n function onText(text) {\n var m;\n try { m = JSON.parse(text); } catch (e) { return; }\n if (!m || typeof m !== \"object\" || m.type !== \"config\") return;\n if (typeof m.codec !== \"string\" || !positive(m.width) || !positive(m.height) ||\n typeof m.description !== \"string\" || m.description === \"\") {\n fail(\"bad config\");\n return;\n }\n var description;\n try {\n var bin = atob(m.description);\n description = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) description[i] = bin.charCodeAt(i);\n } catch (e) {\n fail(\"bad config\");\n return;\n }\n counts.configs++;\n var seq = ++cfgSeq;\n ready = false;\n pending = [];\n needKey = true;\n keyAsked = false;\n closeDecoder();\n var base = {\n codec: m.codec,\n codedWidth: Math.round(m.width),\n codedHeight: Math.round(m.height),\n description: description,\n optimizeForLatency: true\n };\n chooseConfig(base).then(function (chosen) {\n if (seq !== cfgSeq) return;\n if (!chosen) {\n blocked = true;\n fail(\"unsupported\");\n return;\n }\n begin(chosen, m);\n }, function () {\n if (seq === cfgSeq) {\n blocked = true;\n fail(\"unsupported\");\n }\n });\n }\n\n /* Hardware first, then whatever the browser has; null when neither is supported. */\n function chooseConfig(base) {\n var hw = Object.assign({}, base, { hardwareAcceleration: \"prefer-hardware\" });\n return window.VideoDecoder.isConfigSupported(hw).then(function (r) {\n if (r && r.supported) return hw;\n var any = Object.assign({}, base, { hardwareAcceleration: \"no-preference\" });\n return window.VideoDecoder.isConfigSupported(any).then(function (r2) {\n return r2 && r2.supported ? any : null;\n });\n });\n }\n\n function makeDecoder() {\n var d = new window.VideoDecoder({\n output: function (frame) { onFrame(frame, d); },\n error: function (err) { onDecoderError(err, d); }\n });\n return d;\n }\n\n function begin(chosen, m) {\n try {\n if (!canvas) {\n var pic = io.picture();\n canvas = document.createElement(\"canvas\");\n canvas.className = pic.className;\n canvas.style.display = \"none\";\n canvas.setAttribute(\"aria-hidden\", \"true\");\n pic.parentNode.insertBefore(canvas, pic.nextSibling);\n ctx = canvas.getContext(\"2d\", { alpha: false, desynchronized: true });\n }\n if (canvas.width !== chosen.codedWidth) canvas.width = chosen.codedWidth;\n if (canvas.height !== chosen.codedHeight) canvas.height = chosen.codedHeight;\n cfg = chosen;\n counts.hardware = chosen.hardwareAcceleration;\n decoder = makeDecoder();\n decoder.configure(chosen);\n } catch (e) {\n counts.lastError = String(e && e.message ? e.message : e);\n fail(\"decoder\");\n return;\n }\n ready = true;\n if (typeof io.onVideoConfig === \"function\") {\n io.onVideoConfig({\n width: chosen.codedWidth,\n height: chosen.codedHeight,\n coverage: typeof m.coverage === \"string\" ? m.coverage : \"\",\n source: typeof m.source === \"string\" ? m.source : \"\"\n });\n }\n var queued = pending;\n pending = [];\n for (var i = 0; i < queued.length; i++) feed(queued[i]);\n }\n\n function onPicture(buf) {\n if (!(buf instanceof ArrayBuffer) || buf.byteLength <= 8) return;\n var head = new DataView(buf, 0, 8);\n var item = {\n key: (head.getUint8(0) & 1) === 1,\n ms: head.getUint32(4, false),\n data: new Uint8Array(buf, 8)\n };\n if (ready) {\n feed(item);\n } else {\n /* Configuring still: keep the pictures, but if it takes too long start over at a key frame. */\n if (pending.length >= PENDING_MAX) {\n pending = [];\n needKey = true;\n }\n pending.push(item);\n }\n }\n\n function feed(item) {\n if (!decoder || decoder.state !== \"configured\") return;\n if (item.key) {\n needKey = false;\n keyAsked = false;\n } else {\n if (needKey) {\n counts.ignored++;\n return;\n }\n if (decoder.decodeQueueSize > QUEUE_MAX) {\n needKey = true;\n counts.skipped++;\n if (!keyAsked) {\n keyAsked = true;\n sendKey();\n }\n return;\n }\n }\n try {\n decoder.decode(new window.EncodedVideoChunk({\n type: item.key ? \"key\" : \"delta\",\n timestamp: item.ms * 1000,\n data: item.data\n }));\n counts.pictures++;\n } catch (e) {\n onDecoderError(e, decoder);\n }\n }\n\n function onFrame(frame, d) {\n try {\n if (d !== decoder || !ctx) return;\n ctx.drawImage(frame, 0, 0);\n counts.frames++;\n if (!visible) {\n visible = true;\n canvas.style.display = \"\";\n var pic = io.picture();\n if (pic) pic.style.display = \"none\";\n io.onVideoStarted();\n }\n } finally {\n frame.close();\n }\n }\n\n /* A decoder error: reset, ask for a key frame and wait for it; too many errors give up. */\n function onDecoderError(err, d) {\n if (d !== decoder) return;\n counts.errors++;\n counts.lastError = String(err && err.message ? err.message : err);\n var now = Date.now();\n errorTimes.push(now);\n errorTimes = errorTimes.filter(function (t) { return now - t <= ERRORS_WINDOW_MS; });\n if (errorTimes.length >= ERRORS_MAX) {\n fail(\"decoder errors\");\n return;\n }\n try {\n if (d.state === \"closed\") {\n decoder = makeDecoder();\n decoder.configure(cfg);\n } else {\n d.reset();\n d.configure(cfg);\n }\n } catch (e) {\n fail(\"decoder\");\n return;\n }\n needKey = true;\n keyAsked = true;\n sendKey();\n }\n\n return {\n update: update,\n stop: stop,\n stats: function () {\n return {\n configs: counts.configs, pictures: counts.pictures, frames: counts.frames,\n ignored: counts.ignored, skipped: counts.skipped, errors: counts.errors,\n keyRequests: counts.keyRequests, fallbacks: counts.fallbacks, hardware: counts.hardware,\n lastReason: counts.lastReason, lastError: counts.lastError, active: visible\n };\n }\n };\n}\n/* video-block:end */\n\n(function () {\n var $ = function (id) { return document.getElementById(id); };\n var frame = $(\"frame\");\n var screenEl = $(\"screen\");\n var stage = $(\"stage\");\n var ovl = $(\"overlay\");\n var marks = $(\"marks\");\n var trail = $(\"trail\");\n var notice = $(\"notice\");\n var heading = $(\"heading\");\n\n /* The cookie set with this page authenticates /stream and /ws: take the token out of the\n address bar and the history. */\n if (location.search) history.replaceState(null, \"\", location.pathname);\n function endpoint(path, socket) {\n var u = new URL(path, location.href);\n u.search = \"\";\n u.hash = \"\";\n if (socket) u.protocol = location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n return u.href;\n }\n\n var status = null;\n var accepts = {};\n var ws = null;\n var wsOpen = false;\n var wsDelay = 500;\n var overlayOn = false;\n var overlay = null;\n var paused = false;\n var lastActionAt = 0;\n var objectUrl = null;\n var streamLive = false;\n var streamOn = true;\n var streamAbort = null;\n var streamWake = null;\n var videoOn = false;\n /* The video's own frame rate while it shows (null until the first second has passed). */\n var videoFps = null;\n var videoFpsTimer = null;\n var haveFrame = false;\n var shown = { rev: null, size: null, coverage: \"\", natural: null };\n var lastAnnounce = \"\";\n\n function isNum(v) { return typeof v === \"number\" && isFinite(v); }\n function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }\n function round4(v) { return Math.round(v * 10000) / 10000; }\n function pct(v) { return (v * 100).toFixed(3) + \"%\"; }\n\n function notify(text) {\n notice.textContent = text;\n notice.classList.add(\"on\");\n clearTimeout(notify.timer);\n notify.timer = setTimeout(function () { notice.classList.remove(\"on\"); }, 4000);\n }\n\n /* The tool's name comes from the status message; the page doesn't know it before that. */\n function applyTool() {\n var tool = status && typeof status.tool === \"string\" ? status.tool.trim().slice(0, 40) : \"\";\n var name = tool ? tool + \" live view\" : \"Live view\";\n document.title = name;\n heading.textContent = name;\n }\n\n /* Layout: the frame (the picture's rectangle) is scaled to fit the stage with the drawn phone around it,\n and keeps the phone's proportions. The body's measures follow the screen's short side. */\n var device = $(\"device\");\n var BEZEL = 0.045; /* screen edge to body edge, of the short side */\n var RIM = 0.009; /* the metal rim, of the short side */\n var CORNER = 0.128; /* the screen's corner radius, of the short side */\n function aspect() {\n var s = shown.size || (status && status.size) || shown.natural;\n if (s && isNum(s.w) && isNum(s.h) && s.w > 0 && s.h > 0) return s.w / s.h;\n return 9 / 19.5;\n }\n var trailCtx = null;\n var trailW = 0;\n var trailH = 0;\n var trailKey = \"\";\n /* The drag trail's canvas holds the frame's size in CSS pixels times the pixel ratio. */\n function sizeTrail(w, h) {\n if (typeof trail.getContext !== \"function\") return;\n var d = window.devicePixelRatio || 1;\n var key = w + \"x\" + h + \"@\" + d;\n if (key === trailKey) return;\n trailKey = key;\n trail.width = Math.max(1, Math.round(w * d));\n trail.height = Math.max(1, Math.round(h * d));\n trailW = w;\n trailH = h;\n trailCtx = trail.getContext(\"2d\");\n if (trailCtx) trailCtx.setTransform(d, 0, 0, d, 0, 0);\n }\n function layout() {\n var pad = 24;\n var room = 84; /* under the phone: the step caption */\n var aw = Math.max(40, stage.clientWidth - pad * 2);\n var ah = Math.max(40, stage.clientHeight - pad * 2 - room);\n var a = aspect();\n var sr = Math.min(1, 1 / a); /* the short side, per unit of width */\n var w = Math.min(aw / (1 + 2 * BEZEL * sr), ah / (1 / a + 2 * BEZEL * sr));\n var fw = Math.floor(w);\n var fh = Math.floor(w / a);\n var s = Math.min(fw, fh);\n var b = Math.max(6, Math.round(s * BEZEL));\n device.style.setProperty(\"--s\", s + \"px\");\n device.style.setProperty(\"--b\", b + \"px\");\n device.style.setProperty(\"--edge\", Math.max(2, Math.round(s * RIM)) + \"px\");\n device.style.setProperty(\"--r\", Math.round(s * CORNER) + \"px\");\n device.classList.toggle(\"wide\", a > 1);\n /* a phone's tall proportions (about 9 by 19.5) get the island; a tablet's don't */\n device.classList.toggle(\"no-island\", Math.max(a, 1 / a) < 1.9);\n frame.style.width = fw + \"px\";\n frame.style.height = fh + \"px\";\n sizeTrail(fw, fh);\n }\n if (typeof ResizeObserver === \"function\") new ResizeObserver(layout).observe(stage);\n window.addEventListener(\"resize\", layout);\n screenEl.addEventListener(\"load\", function () {\n shown.natural = { w: screenEl.naturalWidth, h: screenEl.naturalHeight };\n layout();\n });\n\n /* Status line and toolbar state. */\n var STATE_TEXT = {\n live: \"Live\",\n connecting: \"Connecting\",\n reconnecting: \"Reconnecting\",\n locked: \"Phone locked\",\n \"no-device\": \"No device\",\n \"not-answering\": \"App not answering\"\n };\n /* How the phone is reached, as people write it. */\n var VIA_TEXT = { usb: \"USB\", wifi: \"Wi-Fi\", tailscale: \"Tailscale\" };\n function renderStatus() {\n var dot = $(\"dot\");\n var core = [];\n var cls = \"\";\n var viaText = null;\n if (!wsOpen) {\n core.push(status ? \"Connection lost, retrying\" : \"Connecting\");\n cls = \"wait\";\n } else if (status) {\n var st = String(status.state || \"\");\n core.push(STATE_TEXT[st] || st || \"Unknown\");\n if (status.detail) core.push(String(status.detail));\n if (status.via) core.push(viaText = \"via \" + (VIA_TEXT[String(status.via)] || String(status.via)));\n if (st === \"live\") cls = \"live\";\n else if (st === \"locked\" || st === \"no-device\" || st === \"not-answering\") cls = \"bad\";\n else cls = \"wait\";\n } else {\n core.push(\"Waiting for status\");\n cls = \"wait\";\n }\n if (wsOpen && status) {\n if (status.driver === \"agent\" || status.driver === \"person\") core.push(status.driver + \" driving\");\n if (paused) core.push(\"agent paused\");\n }\n if (haveFrame && !streamLive && !videoOn) core.push(\"picture reconnecting\");\n if (shown.coverage === \"app-window\") core.push(\"app window only\");\n var plain = core.join(\" \\u00b7 \");\n var text = plain;\n var fps = videoOn ? videoFps : status && isNum(status.fps) ? status.fps : null;\n if (wsOpen && status && isNum(fps)) text += \" \\u00b7 \" + fps.toFixed(1) + \" fps\";\n /* The presentation view (Details off) says only the state and who drives (\"Live \\u00b7 agent driving\");\n how the phone is reached and the frame rate are the Details view's, and always in the tooltip. */\n var brief = core.filter(function (part) { return part !== viaText; }).join(\" \\u00b7 \");\n $(\"status\").textContent = document.body.classList.contains(\"details\") ? text : brief;\n $(\"status\").title = text; /* the whole line, when the bar cuts it short */\n dot.className = cls;\n if (plain !== lastAnnounce) {\n lastAnnounce = plain;\n $(\"announce\").textContent = plain;\n }\n var pause = $(\"pause\");\n pause.textContent = paused ? \"Resume agent\" : \"Pause agent\";\n pause.setAttribute(\"aria-pressed\", paused ? \"true\" : \"false\");\n $(\"empty\").textContent = streamLive || videoOn || haveFrame ? \"Waiting for the next frame\" : \"Waiting for the first frame\";\n }\n\n function canPoint() { return !!(accepts.tap || accepts.longPress || accepts.drag); }\n function applyAccepts() {\n accepts = {};\n if (status && Array.isArray(status.accepts)) {\n for (var i = 0; i < status.accepts.length; i++) accepts[String(status.accepts[i])] = true;\n }\n $(\"quality-wrap\").hidden = !accepts.quality;\n var q = status && status.quality;\n if ((q === \"sharp\" || q === \"balanced\" || q === \"light\") && document.activeElement !== $(\"quality\")) $(\"quality\").value = q;\n syncQuality();\n $(\"phone-keys\").hidden = !(accepts.home || accepts.volume);\n $(\"home\").hidden = !accepts.home;\n $(\"vol-down\").hidden = !accepts.volume;\n $(\"vol-up\").hidden = !accepts.volume;\n /* the drawn volume keys work only where the server takes the key; elsewhere they are drawing */\n $(\"key-vol-up\").classList.toggle(\"live\", !!accepts.volume);\n $(\"key-vol-down\").classList.toggle(\"live\", !!accepts.volume);\n $(\"boxes\").hidden = !accepts.overlay;\n if (!accepts.overlay && overlayOn) {\n /* The server stopped offering overlays: the button is gone, so put it back to off. */\n overlayOn = false;\n overlay = null;\n $(\"boxes\").setAttribute(\"aria-pressed\", \"false\");\n drawOverlay();\n }\n frame.classList.toggle(\"can-point\", canPoint());\n }\n\n /* Frames. */\n function showPart(part) {\n var h = part.headers;\n var url = URL.createObjectURL(new Blob([part.body], { type: \"image/jpeg\" }));\n var old = objectUrl;\n objectUrl = url;\n screenEl.src = url;\n if (old) URL.revokeObjectURL(old);\n var rev = h[\"x-tree-rev\"];\n shown.rev = rev === undefined || rev === \"\" ? null : String(rev);\n shown.coverage = h[\"x-coverage\"] || \"\";\n var m = /^(\\d+(?:\\.\\d+)?)x(\\d+(?:\\.\\d+)?)$/.exec(h[\"x-size\"] || \"\");\n shown.size = m ? { w: parseFloat(m[1]), h: parseFloat(m[2]) } : null;\n haveFrame = true;\n frame.classList.add(\"has-frame\");\n layout();\n drawOverlay();\n renderStatus();\n }\n\n function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }\n\n /* The JPEG stream runs unless video is showing: setStream(false) aborts the download and\n parks the loop, setStream(true) wakes it. */\n function setStream(on) {\n streamOn = on;\n if (!on) {\n if (streamAbort) streamAbort.abort();\n } else if (streamWake) {\n var wake = streamWake;\n streamWake = null;\n wake();\n }\n }\n\n async function runStream() {\n var delay = 400;\n for (;;) {\n if (!streamOn) {\n await new Promise(function (resolve) { streamWake = resolve; });\n delay = 400;\n continue;\n }\n var abort = typeof AbortController === \"function\" ? new AbortController() : null;\n streamAbort = abort;\n try {\n var res = await fetch(endpoint(\"/stream\", false), {\n cache: \"no-store\",\n credentials: \"same-origin\",\n signal: abort ? abort.signal : undefined\n });\n if (!res.ok || !res.body) throw new Error(\"stream \" + res.status);\n var state = createParserState(boundaryFromContentType(res.headers.get(\"content-type\")));\n var reader = res.body.getReader();\n for (;;) {\n var r = await reader.read();\n if (r.done || !streamOn) break;\n var parts = parseMultipartChunk(state, r.value);\n if (parts.length) {\n if (!streamLive) { streamLive = true; }\n delay = 400;\n showPart(parts[parts.length - 1]);\n }\n }\n if (!streamOn) {\n try { reader.cancel(); } catch (e) { /* already closed */ }\n }\n } catch (e) {\n /* fall through to the backoff */\n }\n streamLive = false;\n renderStatus();\n if (!streamOn) continue;\n await sleep(delay);\n delay = Math.min(Math.round(delay * 1.6), 5000);\n }\n }\n\n /* Overlay: element boxes, drawn only for the frame they belong to. */\n function drawOverlay() {\n ovl.textContent = \"\";\n if (!overlayOn || !overlay || !Array.isArray(overlay.elements)) return;\n var orev = overlay.rev === undefined || overlay.rev === null ? null : String(overlay.rev);\n if (orev !== null && shown.rev !== null && orev !== shown.rev) return;\n ovl.classList.toggle(\"stale\", isNum(overlay.at) && overlay.at < lastActionAt);\n var els = overlay.elements;\n var n = Math.min(els.length, 400);\n for (var i = 0; i < n; i++) {\n var e = els[i];\n var r = e && e.rect;\n if (!r || !isNum(r.x) || !isNum(r.y) || !isNum(r.w) || !isNum(r.h)) continue;\n var box = document.createElement(\"div\");\n box.className = \"box\";\n box.style.left = pct(r.x);\n box.style.top = pct(r.y);\n box.style.width = pct(r.w);\n box.style.height = pct(r.h);\n box.title = [e.ref, e.type, e.label, e.id].filter(Boolean).join(\" \");\n if (typeof e.ref === \"string\" && e.ref !== \"\") {\n var tag = document.createElement(\"span\");\n tag.textContent = e.ref;\n box.appendChild(tag);\n }\n ovl.appendChild(box);\n }\n }\n\n /* Pending marks: a ring drawn the moment a person's tap or long press is released, before\n the server answers. The person's action message then takes the ring over (claimPending);\n with no answer it fades from 1.7 s and is gone at 2 s; an error message drops it at once. */\n var PENDING_FADE_MS = 1700;\n var PENDING_END_MS = 2000;\n /* Both servers call a path a tap when no point of it is 10 points or more from the first\n (docs/VIEWER-PROTOCOL.md leaves the number to the servers; keep this in step with them). */\n var TAP_SLOP = 10;\n /* The server's action names the path's first point; the pending ring sits at its last, which\n can be up to TAP_SLOP away. So the match must allow at least TAP_SLOP, plus a margin for\n the rounding of both ends and for an element's rect edge: 6 points. */\n var NEAR_POINTS = TAP_SLOP + 6;\n var pendingMarks = [];\n\n function phoneSize() {\n var s = shown.size || (status && status.size) || null;\n return s && isNum(s.w) && isNum(s.h) && s.w > 0 && s.h > 0 ? s : null;\n }\n function removeMark(el) {\n if (el.parentNode) el.parentNode.removeChild(el);\n }\n function trimMarks() {\n while (marks.children.length > 12) marks.removeChild(marks.firstChild);\n }\n function forgetPending(entry) {\n clearTimeout(entry.fade);\n clearTimeout(entry.end);\n var i = pendingMarks.indexOf(entry);\n if (i >= 0) pendingMarks.splice(i, 1);\n }\n function dropPending() {\n var all = pendingMarks.slice();\n for (var i = 0; i < all.length; i++) {\n forgetPending(all[i]);\n removeMark(all[i].el);\n }\n }\n function addPending(x, y) {\n var el = document.createElement(\"div\");\n el.className = \"mark person ring pending\";\n el.style.left = pct(x);\n el.style.top = pct(y);\n marks.appendChild(el);\n trimMarks();\n var entry = { el: el, x: x, y: y, fade: 0, end: 0 };\n entry.fade = setTimeout(function () {\n var at = pendingMarks.indexOf(entry);\n if (at >= 0) pendingMarks.splice(at, 1);\n el.classList.add(\"gone\");\n }, PENDING_FADE_MS);\n entry.end = setTimeout(function () {\n forgetPending(entry);\n removeMark(el);\n }, PENDING_END_MS);\n pendingMarks.push(entry);\n }\n /* The oldest pending mark that this person action belongs to: the action's point is within\n NEAR_POINTS of it, or its rect holds it. A failed action with no position takes the oldest. */\n function claimPending(m, r, p) {\n if (m.by !== \"person\" || pendingMarks.length === 0) return null;\n var size = phoneSize();\n var tx = size ? NEAR_POINTS / size.w : 0.04;\n var ty = size ? NEAR_POINTS / size.h : 0.04;\n var lost = m.ok === false && !r && !p;\n for (var i = 0; i < pendingMarks.length; i++) {\n var e = pendingMarks[i];\n if (!e.el.parentNode) continue;\n var near = (p && Math.abs(p.x - e.x) <= tx && Math.abs(p.y - e.y) <= ty) ||\n (r && e.x >= r.x - tx && e.x <= r.x + r.w + tx && e.y >= r.y - ty && e.y <= r.y + r.h + ty);\n if (near || lost) {\n forgetPending(e);\n return e.el;\n }\n }\n return null;\n }\n /* How far, in phone points, the path gets from its first point. */\n function reach(points) {\n var box = frame.getBoundingClientRect();\n var size = phoneSize();\n var w = size ? size.w : Math.max(1, box.width);\n var h = size ? size.h : Math.max(1, box.height);\n var first = points[0];\n var far = 0;\n for (var i = 1; i < points.length; i++) {\n far = Math.max(far, Math.hypot((points[i][1] - first[1]) * w, (points[i][2] - first[2]) * h));\n }\n return far;\n }\n /* A tap or a long press stays within the slop the servers use; anything wider is a drag. */\n function isTapPath(points) {\n return reach(points) < TAP_SLOP;\n }\n\n /* Captions (VIEWER-PROTOCOL 1.4): the step in plain words. The server's caption when it sends one (the\n agent's steps), else one made from kind and label with the protocol's table. */\n var NAME_MAX = 32;\n var CAPTION_MAX = 60;\n function cut(text, max) { return text.length <= max ? text : text.slice(0, max - 1) + \"\\u2026\"; }\n function q(name) { return name ? \" \\\"\" + name + \"\\\"\" : \"\"; }\n var KIND_CAPTION = {\n tap: [\"Tap\", \"You tapped\"], double_tap: [\"Tap\", \"You tapped\"],\n long_press: [\"Press and hold\", \"You pressed and held\"], longPress: [\"Press and hold\", \"You pressed and held\"],\n type_text: [\"Type into\", \"You typed\"], text: [\"Type into\", \"You typed\"],\n enter: [\"Press Return\", \"You pressed Return\"], backspace: [\"Delete\", \"You deleted\"],\n scroll: [\"Scroll\", \"You scrolled\"], swipe: [\"Swipe\", \"You swiped\"], drag: [\"Swipe\", \"You swiped\"],\n toggle: [\"Switch\", \"You switched\"], back: [\"Go back\", \"You went back\"],\n alert: [\"Answer the alert\", \"You answered the alert\"], open_url: [\"Open link\", \"You opened a link\"],\n open_app: [\"Open\", \"You opened\"], close_app: [\"Close\", \"You closed\"],\n rotate: [\"Turn the phone\", \"You turned the phone\"], home: [\"Home\", \"You pressed Home\"],\n reload: [\"Reload the app\", \"You reloaded the app\"], app_command: [\"Run\", \"You ran\"]\n };\n /* kinds whose caption names the element; the rest stand alone (a link's address is never shown) */\n var NAMED = { tap: 1, double_tap: 1, long_press: 1, longPress: 1, type_text: 1, toggle: 1, app_command: 1 };\n var PLAIN_NAME = { open_app: 1, close_app: 1 };\n var REASON = {\n covered: \"covered\", off_screen: \"off screen\", disabled: \"disabled\", under_tab_bar: \"under the tab bar\",\n behind_alert: \"behind an alert\", no_action: \"nothing there\", stale: \"the screen changed\",\n busy: \"someone else is using it\", paused: \"paused\", not_answering: \"app not answering\", timeout: \"took too long\"\n };\n var KIND_ICON = {\n tap: \"\\u25cf\", double_tap: \"\\u25cf\", long_press: \"\\u25c9\", longPress: \"\\u25c9\", type_text: \"\\u270e\", text: \"\\u270e\",\n enter: \"\\u21b5\", backspace: \"\\u232b\", scroll: \"\\u2195\", swipe: \"\\u21c6\", drag: \"\\u21c6\", toggle: \"\\u25d0\",\n back: \"\\u2039\", alert: \"!\", open_url: \"\\u2197\", open_app: \"\\u2197\", close_app: \"\\u00d7\", rotate: \"\\u27f3\",\n home: \"\\u2302\", volume: \"\\u266a\", reload: \"\\u21bb\", app_command: \"\\u2318\"\n };\n function captionOf(m) {\n var kind = String(m.kind || \"\");\n if (kind === \"wait_for\") return null;\n var person = m.by === \"person\";\n var name = cut(String(m.label || \"\").trim(), NAME_MAX);\n var text;\n if (!person && typeof m.caption === \"string\" && m.caption.trim()) {\n text = m.caption.trim();\n } else if (kind === \"volume\") {\n var up = /up/i.test(name);\n text = person ? \"You pressed Volume \" + (up ? \"up\" : \"down\") : \"Volume \" + (up ? \"up\" : \"down\");\n } else if (KIND_CAPTION[kind]) {\n var w = KIND_CAPTION[kind][person ? 1 : 0];\n if (PLAIN_NAME[kind]) text = w + \" \" + (name || \"the app\");\n else if (NAMED[kind] && name && !(person && (kind === \"text\" || kind === \"type_text\"))) text = w + q(name);\n else if (kind === \"type_text\" && !name && !person) text = \"Type\";\n else text = w;\n } else {\n text = (person ? \"You: \" : \"\") + kind.replace(/_/g, \" \");\n }\n text = cut(text, CAPTION_MAX);\n if (m.ok === false) {\n var why = REASON[String(m.reason || \"\")] || REASON[String(m.code || \"\")] || \"didn\\u2019t work\";\n text += \" \\u2013 \" + why;\n }\n return text;\n }\n var captionEl = $(\"caption\");\n var stepsList = $(\"steps-list\");\n var steps = [];\n var lastSpoken = 0;\n function noteStep(m) {\n var text = captionOf(m);\n if (!text) return;\n var cls = (m.by === \"person\" ? \"person\" : \"agent\") + (m.ok === false ? \" fail\" : \"\");\n captionEl.textContent = text;\n captionEl.className = \"on \" + cls;\n clearTimeout(noteStep.timer);\n noteStep.timer = setTimeout(function () { captionEl.classList.remove(\"on\"); }, m.ok === false ? 4000 : 2500);\n var now = Date.now();\n if (now - lastSpoken >= 1000) { /* politely, at most once a second */\n lastSpoken = now;\n $(\"announce\").textContent = text;\n }\n steps.unshift({ text: text, cls: cls, icon: KIND_ICON[String(m.kind || \"\")] || \"\\u2022\", at: now });\n if (steps.length > 50) steps.length = 50;\n if (!$(\"steps\").hidden) renderSteps();\n }\n function ago(ms) {\n var s = Math.round(ms / 1000);\n if (s < 5) return \"now\";\n if (s < 60) return s + \" s\";\n var min = Math.round(s / 60);\n return min < 60 ? min + \" min\" : Math.round(min / 60) + \" h\";\n }\n function renderSteps() {\n var now = Date.now();\n stepsList.textContent = \"\";\n for (var i = 0; i < steps.length; i++) {\n var li = document.createElement(\"li\");\n li.className = steps[i].cls;\n var ico = document.createElement(\"span\");\n ico.className = \"ico\";\n ico.setAttribute(\"aria-hidden\", \"true\");\n ico.textContent = steps[i].icon;\n var txt = document.createElement(\"span\");\n txt.className = \"txt\";\n txt.textContent = steps[i].text;\n var when = document.createElement(\"span\");\n when.className = \"ago\";\n when.textContent = ago(now - steps[i].at);\n li.appendChild(ico);\n li.appendChild(txt);\n li.appendChild(when);\n stepsList.appendChild(li);\n }\n }\n setInterval(function () { if (!$(\"steps\").hidden) renderSteps(); }, 10000);\n\n /* Actions: short-lived marks. */\n function showAction(m) {\n if (isNum(m.at) && m.at > lastActionAt) {\n lastActionAt = m.at;\n drawOverlay();\n }\n var ok = m.ok !== false;\n var label = String(m.label || m.kind || \"\");\n if (!ok && m.code) label = label ? label + \" (\" + m.code + \")\" : String(m.code);\n var r = m.rect;\n var p = m.point;\n if (!(r && isNum(r.x) && isNum(r.y) && isNum(r.w) && isNum(r.h))) r = null;\n if (!(p && isNum(p.x) && isNum(p.y))) p = null;\n var el = claimPending(m, r, p);\n var reused = !!el;\n if (reused) {\n el.textContent = \"\";\n el.style.width = \"\";\n el.style.height = \"\";\n } else {\n el = document.createElement(\"div\");\n }\n el.className = \"mark \" + (m.by === \"person\" ? \"person\" : \"agent\") + (ok ? \"\" : \" fail\");\n if (r) {\n el.classList.add(\"rect\");\n el.style.left = pct(r.x);\n el.style.top = pct(r.y);\n el.style.width = pct(r.w);\n el.style.height = pct(r.h);\n } else if (p) {\n el.classList.add(\"ring\");\n if (p.x > 0.6) el.classList.add(\"flip\"); /* the tag on the ring's left, inside the picture */\n el.style.left = pct(p.x);\n el.style.top = pct(p.y);\n } else {\n el.classList.add(\"pill\");\n el.style.left = \"0\";\n el.style.top = \"0\";\n }\n if (label) {\n var tag = document.createElement(\"span\");\n tag.className = \"tag\";\n tag.textContent = label;\n el.appendChild(tag);\n }\n if (!reused) {\n marks.appendChild(el);\n trimMarks();\n }\n setTimeout(function () { removeMark(el); }, ok ? 1700 : 2700);\n noteStep(m);\n }\n\n /* WebSocket. */\n function send(obj) {\n if (ws && ws.readyState === 1) {\n ws.send(JSON.stringify(obj));\n return true;\n }\n notify(\"Not connected\");\n return false;\n }\n\n function handleMessage(data) {\n if (typeof data !== \"string\") return;\n var m;\n try { m = JSON.parse(data); } catch (e) { return; }\n if (!m || typeof m !== \"object\" || Array.isArray(m) || typeof m.type !== \"string\") return;\n if (m.type === \"status\") {\n status = m;\n paused = m.paused === true;\n applyTool();\n applyAccepts();\n video.update();\n layout();\n renderStatus();\n } else if (m.type === \"overlay\") {\n if (!overlayOn) return;\n overlay = m;\n drawOverlay();\n } else if (m.type === \"action\") {\n showAction(m);\n } else if (m.type === \"error\") {\n var text = String(m.message || \"Error\");\n dropPending();\n notify(m.code ? text + \" (\" + m.code + \")\" : text);\n }\n }\n\n function connectWs() {\n var sock;\n try {\n sock = new WebSocket(endpoint(\"/ws\", true));\n } catch (e) {\n scheduleWs();\n return;\n }\n ws = sock;\n sock.onopen = function () {\n wsOpen = true;\n wsDelay = 500;\n if (overlayOn && accepts.overlay) send({ type: \"overlay\", on: true });\n renderStatus();\n };\n sock.onmessage = function (ev) { handleMessage(ev.data); };\n sock.onclose = function () {\n if (ws !== sock) return;\n ws = null;\n wsOpen = false;\n dropPending();\n renderStatus();\n scheduleWs();\n };\n sock.onerror = function () { /* a close follows */ };\n }\n function scheduleWs() {\n setTimeout(connectWs, wsDelay);\n wsDelay = Math.min(Math.round(wsDelay * 1.7), 5000);\n }\n\n /* Toolbar. After a click with the mouse or a finger the control lets go of the focus, so the\n next Enter or Space goes to the phone and not to the same button again. A keyboard user\n keeps the focus where they put it. */\n var viaPointer = false;\n window.addEventListener(\"pointerdown\", function () { viaPointer = true; }, true);\n window.addEventListener(\"keydown\", function () { viaPointer = false; }, true);\n function letGo(el) {\n if (el && typeof el.blur === \"function\") el.blur();\n }\n function release(el) {\n if (viaPointer) letGo(el);\n }\n $(\"pause\").addEventListener(\"click\", function () {\n var next = !paused;\n if (send({ type: \"pause\", on: next })) {\n paused = next;\n renderStatus();\n }\n release(this);\n });\n $(\"boxes\").addEventListener(\"click\", function () {\n overlayOn = !overlayOn;\n this.setAttribute(\"aria-pressed\", overlayOn ? \"true\" : \"false\");\n if (!overlayOn) overlay = null;\n drawOverlay();\n send({ type: \"overlay\", on: overlayOn });\n release(this);\n });\n $(\"quality\").addEventListener(\"change\", function () {\n send({ type: \"quality\", preset: this.value });\n release(this);\n });\n $(\"home\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"home\" });\n release(this);\n });\n $(\"vol-up\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"volumeUp\" });\n release(this);\n });\n $(\"vol-down\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"volumeDown\" });\n release(this);\n });\n /* The drawn keys are a mouse shortcut for the toolbar's buttons (which stay the keyboard's way). */\n $(\"key-vol-up\").addEventListener(\"click\", function () { if (accepts.volume) $(\"vol-up\").click(); });\n $(\"key-vol-down\").addEventListener(\"click\", function () { if (accepts.volume) $(\"vol-down\").click(); });\n\n /* The More menu. The picture quality is three buttons drawn by the page; the select stays (hidden) as the one\n source of the value, so whatever drives it keeps working. */\n function syncQuality() {\n var v = $(\"quality\").value;\n var all = $(\"quality-seg\").querySelectorAll(\"button\");\n for (var i = 0; i < all.length; i++) all[i].setAttribute(\"aria-pressed\", all[i].getAttribute(\"data-q\") === v ? \"true\" : \"false\");\n }\n $(\"quality-seg\").addEventListener(\"click\", function (ev) {\n var b = ev.target && ev.target.closest ? ev.target.closest(\"button[data-q]\") : null;\n if (!b) return;\n var sel = $(\"quality\");\n if (sel.value !== b.getAttribute(\"data-q\")) {\n sel.value = b.getAttribute(\"data-q\");\n sel.dispatchEvent(new Event(\"change\"));\n }\n syncQuality();\n });\n $(\"quality\").addEventListener(\"change\", syncQuality);\n function setMore(open) {\n var menu = $(\"more-menu\");\n /* on a narrow screen the menu spans the width, just under the button */\n menu.style.top = open && window.innerWidth <= 560 ? Math.round($(\"bar\").getBoundingClientRect().bottom + 12) + \"px\" : \"\";\n menu.hidden = !open;\n $(\"more-btn\").setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n }\n $(\"more-btn\").addEventListener(\"click\", function () {\n setMore($(\"more-menu\").hidden);\n release(this);\n });\n document.addEventListener(\"pointerdown\", function (ev) {\n if (!$(\"more-menu\").hidden && ev.target && !ev.target.closest(\".more\")) setMore(false);\n }, true);\n document.addEventListener(\"keydown\", function (ev) {\n if (ev.key === \"Escape\" && !$(\"more-menu\").hidden) {\n setMore(false);\n $(\"more-btn\").focus();\n }\n });\n\n /* Steps drawer and the Details view (VIEWER-PROTOCOL 1.4, presentation view). Details is remembered per\n browser; storage may be unavailable (a private window), and then it simply starts off. */\n function setSteps(open) {\n $(\"steps\").hidden = !open;\n $(\"steps-btn\").setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n if (open) renderSteps();\n }\n $(\"steps-btn\").addEventListener(\"click\", function () {\n setSteps($(\"steps\").hidden);\n release(this);\n });\n $(\"steps-close\").addEventListener(\"click\", function () {\n setSteps(false);\n $(\"steps-btn\").focus();\n });\n function setDetails(on) {\n document.body.classList.toggle(\"details\", on);\n $(\"details\").setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n try { localStorage.setItem(\"viewer.details\", on ? \"1\" : \"0\"); } catch (e) { /* not kept */ }\n renderStatus();\n }\n $(\"details\").addEventListener(\"click\", function () {\n setDetails(!document.body.classList.contains(\"details\"));\n release(this);\n });\n try { if (localStorage.getItem(\"viewer.details\") === \"1\") setDetails(true); } catch (e) { /* starts off */ }\n\n /* Drag trail: the path under the pointer while a person drags, on its own canvas so it\n never mixes with the action marks. It is drawn in the person's colour with a dark edge,\n which reads on any picture (the picture doesn't follow the theme). */\n var TRAIL_CLEAR_MS = 400;\n var trailTimer = 0;\n function clearTrail() {\n clearTimeout(trailTimer);\n trailTimer = 0;\n if (trailCtx) trailCtx.clearRect(0, 0, trailW, trailH);\n }\n function drawTrail(points) {\n if (!trailCtx || points.length < 2) return;\n trailCtx.clearRect(0, 0, trailW, trailH);\n trailCtx.lineCap = \"round\";\n trailCtx.lineJoin = \"round\";\n var passes = [[\"rgba(0, 0, 0, 0.55)\", 7], [\"#8be28b\", 4]];\n for (var k = 0; k < passes.length; k++) {\n trailCtx.beginPath();\n for (var i = 0; i < points.length; i++) {\n var x = points[i][1] * trailW;\n var y = points[i][2] * trailH;\n if (i === 0) trailCtx.moveTo(x, y);\n else trailCtx.lineTo(x, y);\n }\n trailCtx.strokeStyle = passes[k][0];\n trailCtx.lineWidth = passes[k][1];\n trailCtx.stroke();\n }\n }\n\n /* Pointer: one finger or the mouse, sent as a path on release. A move is stored only when\n at least MOVE_GAP_MS have passed since the last stored point, so a fast mouse can't fill\n the path with near-duplicates; MAX_POINTS caps the whole path, the last point included. */\n var MOVE_GAP_MS = 16;\n var MAX_POINTS = 300;\n var drag = null;\n function fractionOf(ev) {\n var r = frame.getBoundingClientRect();\n return [\n round4(clamp01((ev.clientX - r.left) / Math.max(1, r.width))),\n round4(clamp01((ev.clientY - r.top) / Math.max(1, r.height)))\n ];\n }\n frame.addEventListener(\"pointerdown\", function (ev) {\n if (!canPoint() || drag || !ev.isPrimary) return;\n if (ev.pointerType === \"mouse\" && ev.button !== 0) return;\n ev.preventDefault();\n var active = document.activeElement;\n if (active && active !== document.body && typeof active.blur === \"function\") active.blur();\n try { frame.setPointerCapture(ev.pointerId); } catch (e) { /* not capturable */ }\n clearTrail();\n var p = fractionOf(ev);\n drag = { id: ev.pointerId, t0: ev.timeStamp, last: 0, far: false, pts: [[0, p[0], p[1]]] };\n });\n frame.addEventListener(\"pointermove\", function (ev) {\n if (!drag || ev.pointerId !== drag.id) return;\n var dt = ev.timeStamp - drag.t0;\n if (dt - drag.last < MOVE_GAP_MS) return;\n var p = fractionOf(ev);\n var pt = [Math.round(dt), p[0], p[1]];\n if (drag.pts.length >= MAX_POINTS - 1) drag.pts[drag.pts.length - 1] = pt;\n else drag.pts.push(pt);\n drag.last = dt;\n /* No trail for a tap or a hold: its ring comes when it is released. */\n if (!drag.far && !isTapPath(drag.pts)) drag.far = true;\n if (drag.far) drawTrail(drag.pts);\n });\n frame.addEventListener(\"pointerup\", function (ev) {\n if (!drag || ev.pointerId !== drag.id) return;\n var p = fractionOf(ev);\n drag.pts.push([Math.max(0, Math.round(ev.timeStamp - drag.t0)), p[0], p[1]]);\n var points = drag.pts;\n drag = null;\n var sent = send({ type: \"gesture\", points: points });\n var tap = isTapPath(points);\n if (!tap) {\n drawTrail(points);\n clearTimeout(trailTimer);\n trailTimer = setTimeout(clearTrail, TRAIL_CLEAR_MS);\n }\n /* A tap or long press shows its ring at once; a drag has no single point. */\n if (sent && (accepts.tap || accepts.longPress) && tap) {\n var end = points[points.length - 1];\n addPending(end[1], end[2]);\n }\n });\n frame.addEventListener(\"pointercancel\", function (ev) {\n if (drag && ev.pointerId === drag.id) {\n drag = null;\n clearTrail();\n }\n });\n\n /* Mouse wheel: events within 120 ms become one scroll. Each share is held to 0.8 of the\n frame (so a fast fling can't ask for more than the phone's screen), which also keeps it\n inside the protocol's -1..1. A scroll that comes to nothing is not sent. */\n var WHEEL_MAX = 0.8;\n var wheel = null;\n function share(v) {\n return round4(Math.max(-WHEEL_MAX, Math.min(WHEEL_MAX, v)));\n }\n function flushWheel() {\n var w = wheel;\n wheel = null;\n if (!w) return;\n var dx = share(w.dx);\n var dy = share(w.dy);\n if (dx === 0 && dy === 0) return;\n send({ type: \"scroll\", x: round4(w.x), y: round4(w.y), dx: dx, dy: dy });\n }\n frame.addEventListener(\"wheel\", function (ev) {\n if (!accepts.scroll) return;\n ev.preventDefault();\n var r = frame.getBoundingClientRect();\n var k = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? r.height : 1;\n if (!wheel) {\n wheel = {\n x: clamp01((ev.clientX - r.left) / Math.max(1, r.width)),\n y: clamp01((ev.clientY - r.top) / Math.max(1, r.height)),\n dx: 0,\n dy: 0\n };\n setTimeout(flushWheel, 120);\n }\n wheel.dx += (ev.deltaX * k) / Math.max(1, r.width);\n wheel.dy += (ev.deltaY * k) / Math.max(1, r.height);\n }, { passive: false });\n\n /* Keyboard and paste: only when the page itself has focus, not a toolbar control. */\n var typed = \"\";\n var typedTimer = 0;\n function flushText() {\n clearTimeout(typedTimer);\n typedTimer = 0;\n if (!typed) return;\n var t = typed;\n typed = \"\";\n send({ type: \"text\", text: t });\n }\n function inToolbar(target) {\n return !!(target && target.closest && target.closest(\"#bar\"));\n }\n document.addEventListener(\"keydown\", function (ev) {\n if (ev.defaultPrevented || ev.isComposing) return;\n if (inToolbar(ev.target)) {\n if (ev.key === \"Escape\") letGo(document.activeElement);\n return;\n }\n /* Ctrl, Alt and Meta make shortcuts, which stay the browser's. But AltGr (Alt Gr on a\n Windows layout reports Ctrl and Alt together) types characters such as @ or a with an\n accent, and those go to the phone. */\n var altGr = typeof ev.getModifierState === \"function\" && ev.getModifierState(\"AltGraph\");\n if (ev.metaKey || ((ev.ctrlKey || ev.altKey) && !altGr)) return;\n if (ev.key === \"Backspace\" && accepts.backspace) {\n ev.preventDefault();\n flushText();\n send({ type: \"key\", key: \"backspace\" });\n } else if (ev.key === \"Enter\" && accepts.enter) {\n ev.preventDefault();\n flushText();\n send({ type: \"key\", key: \"enter\" });\n } else if (accepts.text && Array.from(ev.key).length === 1) {\n ev.preventDefault();\n typed += ev.key;\n clearTimeout(typedTimer);\n typedTimer = setTimeout(flushText, 300);\n }\n });\n document.addEventListener(\"paste\", function (ev) {\n if (!accepts.text || inToolbar(ev.target)) return;\n var text = ev.clipboardData ? ev.clipboardData.getData(\"text\") : \"\";\n if (!text) return;\n ev.preventDefault();\n flushText();\n send({ type: \"text\", text: text });\n });\n\n /* Video: the self-contained block above, wired to this page. */\n var video = createVideoBlock({\n socketUrl: function () { return endpoint(\"/video\", true); },\n accepts: function () { return !!accepts.video; },\n picture: function () { return screenEl; },\n onVideoConfig: function (c) {\n shown.rev = null;\n shown.size = null;\n shown.coverage = c.coverage || \"\";\n shown.natural = { w: c.width, h: c.height };\n layout();\n drawOverlay();\n renderStatus();\n },\n onVideoStarted: function () {\n videoOn = true;\n setStream(false);\n streamLive = false;\n haveFrame = true;\n frame.classList.add(\"has-frame\");\n layout();\n startVideoFps();\n renderStatus();\n },\n onVideoStopped: function () {\n videoOn = false;\n stopVideoFps();\n setStream(true);\n renderStatus();\n }\n });\n\n /* While video shows, the status line's rate is the video's own: frames drawn in the last second.\n (The server's fps is its JPEG stream's.) */\n function startVideoFps() {\n stopVideoFps();\n var last = video.stats().frames;\n var lastAt = Date.now();\n videoFps = null;\n videoFpsTimer = setInterval(function () {\n var now = Date.now();\n var frames = video.stats().frames;\n var seconds = (now - lastAt) / 1000;\n if (seconds > 0) videoFps = Math.max(0, (frames - last) / seconds);\n last = frames;\n lastAt = now;\n renderStatus();\n }, 1000);\n }\n function stopVideoFps() {\n if (videoFpsTimer !== null) clearInterval(videoFpsTimer);\n videoFpsTimer = null;\n videoFps = null;\n }\n\n layout();\n renderStatus();\n connectWs();\n runStream();\n})();\n</script>\n</body>\n</html>\n";
9
+ export const VIEWER_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<title>Live view</title>\n<link rel=\"icon\" href=\"data:,\">\n<!-- The shared live view page: a client of docs/VIEWER-PROTOCOL.md. One file, inline CSS and\n script, no external references. It never names the tool that serves it: the tool's name comes\n in the status message (tool). The text between the video-block markers is shared byte for\n byte with other copies of this page. Change it only by agreement. -->\n<style>\n:root {\n color-scheme: light dark;\n --bg: #e9ecf1;\n --stage-hi: #ffffff;\n --stage-lo: #d9dee6;\n --panel: rgba(255, 255, 255, 0.72);\n --panel-solid: #ffffff;\n --line: rgba(17, 24, 39, 0.12);\n --text: #111827;\n --muted: #4b5563;\n --accent: #0a66d1;\n --btn: rgba(255, 255, 255, 0.9);\n --btn-line-hover: rgba(17, 24, 39, 0.32);\n --pressed: #dcebff;\n --dot-idle: #6b7480;\n --dot-live: #1f883d;\n --dot-wait: #9a6700;\n --dot-bad: #cf222e;\n --notice-bg: #fdecec;\n --notice-line: #c62828;\n --notice-text: #7a1212;\n /* The phone's body: a brushed-metal edge around a black bezel. It is drawn, never a photo. */\n --metal-1: #c9ccd1;\n --metal-2: #8d9197;\n --metal-3: #eceef1;\n --bezel: #050506;\n --frame-shadow: 0 30px 60px -20px rgba(15, 23, 42, 0.45), 0 18px 36px -18px rgba(15, 23, 42, 0.35);\n /* Marks and overlay labels sit on the phone's picture, which doesn't follow the theme, so\n they keep one set of colours: dark text on a light fill, readable on any picture. */\n --agent: #4cc2ff;\n --person: #8be28b;\n --fail: #ff6b6b;\n --on-mark: #000000;\n --label-bg: #ffd60a;\n --label-text: #000000;\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --bg: #0b0d11;\n --stage-hi: #2d3440;\n --stage-lo: #0b0d11;\n --panel: rgba(22, 26, 33, 0.78);\n --panel-solid: #161a21;\n --line: rgba(255, 255, 255, 0.1);\n --text: #e8ebf0;\n --muted: #9aa4b2;\n --accent: #7cc4ff;\n --btn: rgba(255, 255, 255, 0.06);\n --btn-line-hover: rgba(255, 255, 255, 0.32);\n --pressed: rgba(124, 196, 255, 0.18);\n --dot-live: #3fb950;\n --dot-wait: #d29922;\n --dot-bad: #ff6b6b;\n --notice-bg: #3a1c1c;\n --notice-line: #ff6b6b;\n --notice-text: #ffd7d7;\n --metal-1: #80858d;\n --metal-2: #3d4148;\n --metal-3: #c2c7ce;\n --frame-shadow: 0 40px 80px -24px rgba(0, 0, 0, 0.9), 0 0 0 1px rgba(255, 255, 255, 0.08), 0 0 60px 0 rgba(124, 196, 255, 0.06);\n }\n}\n* { box-sizing: border-box; }\n[hidden] { display: none !important; }\nhtml, body { margin: 0; height: 100%; }\nbody {\n height: 100vh;\n height: 100dvh;\n display: grid;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n background: var(--bg) radial-gradient(120% 90% at 50% 38%, var(--stage-hi) 0%, var(--stage-lo) 70%) no-repeat;\n color: var(--text);\n font: 14px/1.4 ui-rounded, \"SF Pro Rounded\", system-ui, -apple-system, \"Segoe UI Variable Text\", \"Segoe UI\", sans-serif;\n overflow: hidden;\n}\n#bar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px 12px;\n margin: max(10px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));\n padding: 6px 6px 6px 16px;\n background: var(--panel);\n border: 1px solid var(--line);\n border-radius: 999px;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);\n}\n#heading {\n flex: none;\n margin: 0;\n font-size: 15px;\n font-weight: 650;\n letter-spacing: -0.01em;\n line-height: 1.4;\n}\n#statusline {\n flex: 1 1 200px;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 8px;\n height: 36px;\n}\n#dot {\n flex: none;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--dot-idle);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-idle) 22%, transparent);\n}\n#dot.live { background: var(--dot-live); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-live) 22%, transparent); }\n#dot.wait { background: var(--dot-wait); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-wait) 22%, transparent); }\n#dot.bad { background: var(--dot-bad); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dot-bad) 22%, transparent); }\n#status {\n min-width: 0;\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n color: var(--muted);\n font-variant-numeric: tabular-nums;\n}\n#announce, .sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n overflow: hidden;\n clip: rect(0 0 0 0);\n white-space: nowrap;\n}\n#tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }\nbutton, select {\n min-height: 36px;\n padding: 0 14px;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--btn);\n color: var(--text);\n font: inherit;\n font-weight: 550;\n cursor: pointer;\n}\nselect { padding-right: 8px; }\n/* The list a select opens is drawn by the system, on its own background: the options get the theme's\n colours, or dark mode drew the page's light text on the system's white (seen by the owner, 2026-11-11). */\nselect option { background: #ffffff; color: #111827; }\n@media (prefers-color-scheme: dark) {\n select option { background: #1b2029; color: #e8ebf0; }\n}\nbutton:hover, select:hover { border-color: var(--btn-line-hover); }\nbutton[aria-pressed=\"true\"], #pause[data-paused=\"true\"] { background: var(--pressed); border-color: var(--accent); color: var(--accent); }\n:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\nlabel.q { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); padding-left: 6px; }\n@media (pointer: coarse) {\n button, select { min-height: 44px; }\n #statusline { height: 44px; }\n}\n@media (max-width: 560px) {\n #bar { border-radius: 18px; padding: 8px 8px 8px 14px; gap: 4px 10px; }\n #heading { font-size: 14px; }\n #statusline { flex: 1 1 0; height: 28px; }\n #tools { flex: 1 0 100%; gap: 6px; }\n #tools > * { flex: none; }\n button, select { min-height: 34px; padding: 0 12px; }\n label.q { padding-left: 0; font-size: 0; gap: 0; } /* the select keeps its own label for screen readers */\n label.q select { font-size: 14px; }\n /* short visible names on a narrow screen; the buttons' aria-labels keep the full ones */\n #vol-down, #vol-up { font-size: 0; padding: 0 12px; }\n #vol-down::after { content: \"Vol \\2212\"; font-size: 14px; }\n #vol-up::after { content: \"Vol +\"; font-size: 14px; }\n}\n@media (max-width: 560px) and (pointer: coarse) {\n button, select { min-height: 40px; }\n}\n#stage {\n position: relative;\n min-height: 0;\n display: grid;\n place-items: center;\n overflow: hidden;\n}\n/* The drawn phone. #frame inside it is the picture's own rectangle: every click is mapped from it,\n so nothing here may move it or catch an event over it. The bezel and the rounded screen corners\n are painted on top by #glass, which lets events through to the picture, corners included. */\n#device {\n --b: 14px; /* from the screen's edge to the body's outer edge */\n --edge: 3px; /* the metal rim, part of --b */\n --r: 44px; /* the screen's corner radius */\n --s: 200px; /* the screen's short side */\n position: relative;\n padding: var(--b);\n border-radius: calc(var(--r) + var(--b));\n background: linear-gradient(135deg, var(--metal-3) 0%, var(--metal-1) 22%, var(--metal-2) 50%, var(--metal-1) 78%, var(--metal-3) 100%);\n box-shadow: var(--frame-shadow), inset 0 0 0 1px rgba(255, 255, 255, 0.35), inset 0 0 0 2px rgba(0, 0, 0, 0.25);\n}\n#frame {\n position: relative;\n z-index: 1;\n width: 200px;\n height: 433px;\n overflow: hidden;\n outline: none;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n transform: translateZ(0); /* its own layer: a new picture repaints only the screen */\n}\n#frame.can-point { cursor: crosshair; }\n/* The picture's layers are clipped to the rounded screen. They take no events, so a click in a clipped\n corner still lands on #frame's plain rectangle and maps as before. */\n.pic, #empty, #overlay, #marks, #trail { clip-path: inset(0 round var(--r)); }\n#empty { background: #000; }\n#glass {\n position: absolute;\n z-index: 2;\n left: calc(var(--b) + 1px);\n top: calc(var(--b) + 1px);\n right: calc(var(--b) + 1px);\n bottom: calc(var(--b) + 1px);\n border-radius: calc(var(--r) - 1px);\n /* the black bezel, spread from the screen's rounded edge: it also covers the picture's square corners,\n and its inner 1 px overlaps the picture so no colour shows along the curve */\n box-shadow: 0 0 0 calc(var(--b) - var(--edge) + 1px) var(--bezel), inset 0 0 0 1px rgba(255, 255, 255, 0.05);\n pointer-events: none;\n}\n#frame:focus-visible + #glass { box-shadow: 0 0 0 calc(var(--b) - var(--edge) + 1px) var(--bezel), inset 0 0 0 2px var(--accent); }\n#island {\n position: absolute;\n left: 50%;\n top: calc(var(--s) * 0.026);\n width: calc(var(--s) * 0.293);\n height: calc(var(--s) * 0.086);\n transform: translateX(-50%);\n border-radius: 999px;\n background: #000;\n pointer-events: none;\n}\n#device.wide #island {\n left: calc(var(--s) * 0.026);\n top: 50%;\n width: calc(var(--s) * 0.086);\n height: calc(var(--s) * 0.293);\n transform: translateY(-50%);\n}\n#device.no-island #island { display: none; }\n/* Side keys: drawn on the body's edge. They take a click only where the server accepts the key\n (status.accepts), and then only as a shortcut for the labelled buttons in the toolbar. */\n.key {\n position: absolute;\n z-index: 0;\n padding: 0;\n min-height: 0;\n border: 0;\n border-radius: 2px;\n background: var(--metal-1);\n box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25), 0 0 0 0.5px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n cursor: default;\n}\n.key.live { pointer-events: auto; cursor: pointer; }\n.key.live:hover { filter: brightness(1.12); }\n.key.live:active { transform: scale(0.96); }\n#key-action { left: -4px; width: 5px; top: 17%; height: 4%; }\n#key-vol-up { left: -4px; width: 5px; top: 25%; height: 7.5%; }\n#key-vol-down { left: -4px; width: 5px; top: 34%; height: 7.5%; }\n#key-side { right: -4px; width: 5px; top: 27%; height: 11%; }\n/* Landscape: the phone turned with its top to the left, so the left keys are at the bottom. */\n#device.wide #key-action { left: 17%; top: auto; bottom: -4px; width: 4%; height: 5px; }\n#device.wide #key-vol-up { left: 25%; top: auto; bottom: -4px; width: 7.5%; height: 5px; }\n#device.wide #key-vol-down { left: 34%; top: auto; bottom: -4px; width: 7.5%; height: 5px; }\n#device.wide #key-side { left: 27%; right: auto; top: -4px; width: 11%; height: 5px; }\n.pic {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n object-fit: fill;\n pointer-events: none;\n -webkit-user-drag: none;\n}\n#empty {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n padding: 16px;\n text-align: center;\n color: #8b93a1;\n}\n#frame.has-frame #empty { display: none; }\n#overlay, #marks, #trail { position: absolute; inset: 0; pointer-events: none; }\n#trail { width: 100%; height: 100%; }\n#overlay.stale { opacity: 0.3; }\n.box {\n position: absolute;\n outline: 1px solid rgba(255, 214, 10, 0.9);\n outline-offset: -1px;\n}\n.box > span {\n position: absolute;\n left: -1px;\n bottom: 100%; /* above the box, so it doesn't cover what the box marks */\n max-width: 100%;\n overflow: hidden;\n padding: 0 2px;\n font: 10px/12px ui-monospace, Consolas, monospace;\n color: var(--label-text);\n background: var(--label-bg);\n white-space: nowrap;\n}\n.mark {\n --c: var(--agent);\n position: absolute;\n animation: markfade 1.6s ease-out forwards;\n}\n.mark.person { --c: var(--person); }\n.mark.fail { --c: var(--fail); animation-duration: 2.6s; }\n.mark .tag {\n position: absolute;\n padding: 1px 7px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 16px;\n white-space: nowrap;\n color: var(--on-mark);\n background: var(--c);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);\n}\n.mark.ring { width: 0; height: 0; }\n.mark.ring::before {\n content: \"\";\n position: absolute;\n left: -16px;\n top: -16px;\n width: 32px;\n height: 32px;\n border: 3px solid var(--c);\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(0, 0, 0, 0.6);\n}\n.mark.ring .tag { left: 20px; top: -8px; }\n/* near the right edge the tag goes on the ring's left, so it stays on the picture */\n.mark.ring.flip .tag { left: auto; right: 20px; }\n.mark.rect {\n border: 2px solid var(--c);\n border-radius: 4px;\n background: rgba(255, 255, 255, 0.08);\n box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(0, 0, 0, 0.6);\n}\n.mark.rect .tag { left: -2px; top: -20px; }\n.mark.pill .tag { position: absolute; left: 8px; top: 8px; }\n.mark.pending { animation: none; opacity: 0.55; }\n.mark.pending.gone { opacity: 0; transition: opacity 0.3s ease-out; }\n@keyframes markfade {\n 0% { opacity: 1; }\n 70% { opacity: 1; }\n 100% { opacity: 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n .mark { animation: none; }\n .mark.pending.gone { transition: none; }\n .key.live:active { transform: none; }\n}\n#notice {\n position: absolute;\n z-index: 3;\n left: 50%;\n bottom: 14px;\n max-width: calc(100% - 32px);\n transform: translateX(-50%);\n padding: 8px 14px;\n border-radius: 999px;\n background: var(--notice-bg);\n border: 1px solid var(--notice-line);\n color: var(--notice-text);\n opacity: 0;\n pointer-events: none;\n}\n#notice.on { opacity: 1; }\n#notice { bottom: auto; top: 12px; }\n/* The More menu: everything technical (Details, boxes, picture quality, the phone's keys), out of the bar. Drawn\n by the page itself, so it follows the theme; a native select's list is drawn by the system (light text on white\n in dark mode on Windows, seen by the owner). */\n.more { position: relative; }\n#more-btn[aria-expanded=\"true\"] { background: var(--pressed); border-color: var(--accent); color: var(--accent); }\n#more-menu {\n position: absolute;\n z-index: 10;\n right: 0;\n top: calc(100% + 14px);\n width: 260px;\n max-width: calc(100vw - 32px);\n display: flex;\n flex-direction: column;\n gap: 14px;\n padding: 14px;\n border-radius: 18px;\n background: var(--panel-solid);\n border: 1px solid var(--line);\n box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);\n}\n.menu-group { display: flex; flex-direction: column; gap: 8px; }\n.menu-label { color: var(--text); opacity: 0.9; font-size: 12px; font-weight: 650; letter-spacing: 0.02em; }\n.menu-row { display: flex; flex-wrap: wrap; gap: 6px; }\n.seg { display: flex; padding: 3px; gap: 2px; border: 1px solid var(--line); border-radius: 999px; }\n.seg button { flex: 1; min-height: 32px; padding: 0 10px; border: 0; background: transparent; }\n.seg button[aria-pressed=\"true\"] { background: var(--accent); color: var(--panel-solid); }\n#phone-keys .menu-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }\n#phone-keys button { padding: 0 6px; }\n/* short visible names in the menu; the buttons' aria-labels keep the full ones */\n#more-menu #vol-down, #more-menu #vol-up { font-size: 0; }\n#more-menu #vol-down::after { content: \"Vol \\2212\"; font-size: 14px; }\n#more-menu #vol-up::after { content: \"Vol +\"; font-size: 14px; }\n@media (max-width: 560px) {\n #more-menu { position: fixed; left: 16px; right: 16px; width: auto; max-width: none; }\n}\n/* Presentation view (VIEWER-PROTOCOL 1.4): people see the step in plain words under the phone and a ripple at\n the touch. The element's name beside a touch, the boxes and the coordinates are the Details view's. */\nbody:not(.details) .mark .tag { display: none; }\n.mark.ring::after {\n content: \"\";\n position: absolute;\n left: -16px;\n top: -16px;\n width: 32px;\n height: 32px;\n border-radius: 50%;\n border: 2px solid var(--c);\n animation: ripple 0.7s ease-out forwards;\n}\n@keyframes ripple {\n 0% { transform: scale(0.6); opacity: 0.9; }\n 100% { transform: scale(2.4); opacity: 0; }\n}\n#caption {\n position: absolute;\n z-index: 3;\n left: 50%;\n bottom: 18px;\n max-width: calc(100% - 32px);\n transform: translate(-50%, 6px);\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 16px 8px 12px;\n border-radius: 999px;\n background: var(--panel);\n border: 1px solid var(--line);\n box-shadow: 0 6px 20px -8px rgba(0, 0, 0, 0.35);\n color: var(--text);\n font-size: 15px;\n font-weight: 600;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n /* nearly opaque: the pill can sit over the picture, and its text must read on any of it */\n background: color-mix(in srgb, var(--panel-solid) 94%, transparent);\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.2s ease-out, transform 0.2s ease-out;\n}\n#caption.on { opacity: 1; transform: translate(-50%, 0); }\n#caption::before {\n content: \"\";\n flex: none;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--agent);\n}\n#caption.person::before { background: var(--person); }\n#caption.fail { border-color: var(--notice-line); color: var(--notice-text); background: var(--notice-bg); }\n#caption.fail::before { background: var(--fail); }\n#steps {\n position: absolute;\n z-index: 4;\n top: 12px;\n right: 16px;\n bottom: 64px;\n width: 320px;\n max-width: calc(100% - 32px);\n display: flex;\n flex-direction: column;\n border-radius: 20px;\n background: var(--panel-solid);\n border: 1px solid var(--line);\n box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);\n overflow: hidden;\n}\n.steps-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 10px 10px 18px;\n border-bottom: 1px solid var(--line); }\n.steps-head h2 { margin: 0; font-size: 15px; font-weight: 650; }\n#steps-close { min-height: 32px; width: 32px; padding: 0; font-size: 20px; line-height: 1; }\n#steps-list { list-style: none; margin: 0; padding: 6px 8px 10px; overflow-y: auto; flex: 1; }\n#steps-list li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; align-items: center; gap: 10px;\n padding: 8px 10px; border-radius: 12px; }\n#steps-list li + li { margin-top: 2px; }\n#steps-list .ico { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font: 15px/1 \"Segoe UI Symbol\", \"Apple Symbols\", system-ui, sans-serif;\n color: var(--on-mark); background: var(--agent); }\n#steps-list li.person .ico { background: var(--person); }\n#steps-list li.fail .ico { background: var(--fail); }\n#steps-list .txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n#steps-list li.fail .txt { color: var(--dot-bad); }\n#steps-list .ago { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }\n#steps-empty { margin: 0; padding: 18px; color: var(--muted); }\n#steps-list:not(:empty) + #steps-empty { display: none; }\n@media (max-width: 560px) {\n #steps { top: auto; left: 12px; right: 12px; bottom: 72px; width: auto; max-width: none; height: 48%; }\n}\n/* The person's OK (VIEWER-PROTOCOL 1.5): a small dialog over the phone area when the agent touches something the safety\n policy guards. Same tokens as the Steps drawer and the More menu, so it follows the theme. */\n#confirm {\n position: absolute;\n z-index: 6;\n inset: 0;\n display: grid;\n place-items: center;\n padding: 16px;\n background: rgba(15, 23, 42, 0.38);\n}\n#confirm-card {\n width: min(340px, 100%);\n display: flex;\n flex-direction: column;\n gap: 8px;\n padding: 18px;\n border-radius: 20px;\n background: var(--panel-solid);\n border: 1px solid var(--line);\n box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.45);\n}\n#confirm-title { margin: 0; color: var(--muted); font-size: 12px; font-weight: 650; letter-spacing: 0.02em; }\n#confirm-text { margin: 0; font-size: 17px; font-weight: 650; overflow-wrap: anywhere; }\n#confirm-word { margin: 0; color: var(--muted); }\n#confirm-word:empty { display: none; }\n#confirm-actions { display: flex; gap: 8px; margin-top: 8px; }\n#confirm-actions button { flex: 1; min-height: 40px; }\n#confirm-allow { background: var(--accent); border-color: var(--accent); color: var(--panel-solid); }\n@media (prefers-reduced-motion: reduce) {\n .mark.ring::after { animation: none; opacity: 0; }\n #caption { transition: none; }\n}\n/* The caption's text sits in its own span: the visible words are hidden from screen readers, and the live region\n (the pill itself) holds a second, throttled copy that is spoken, at most once a second. */\n#caption-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n#steps-list:focus-visible { outline-offset: -2px; }\n/* More contrast asked for by the system: solid panels, darker text, stronger borders, a thicker focus ring. */\n@media (prefers-contrast: more) {\n :root {\n --text: #000000;\n --muted: #1f2937;\n --line: #374151;\n --btn-line-hover: #000000;\n --panel: #ffffff;\n --panel-solid: #ffffff;\n --btn: #ffffff;\n --accent: #0a4a9e;\n --pressed: #cfe3ff;\n --dot-idle: #4b5563;\n }\n :focus-visible { outline-width: 3px; }\n #bar, #steps, #more-menu, #confirm-card, #caption, .seg, button, select { border-width: 2px; }\n .seg button { border-width: 0; }\n .menu-label { opacity: 1; }\n #frame:focus-visible + #glass { box-shadow: 0 0 0 calc(var(--b) - var(--edge) + 1px) var(--bezel), inset 0 0 0 4px var(--accent); }\n}\n@media (prefers-contrast: more) and (prefers-color-scheme: dark) {\n :root {\n --text: #ffffff;\n --muted: #d1d7e0;\n --line: #c9d1db;\n --btn-line-hover: #ffffff;\n --panel: #10131a;\n --panel-solid: #10131a;\n --btn: #10131a;\n --accent: #a5d8ff;\n --pressed: #1d3550;\n --dot-idle: #9aa4b2;\n }\n}\n/* Windows high contrast and the like: the system's colours; keep the on/off states and the focus visible. */\n@media (forced-colors: active) {\n button[aria-pressed=\"true\"], #pause[data-paused=\"true\"], #more-btn[aria-expanded=\"true\"] { border-color: Highlight; box-shadow: inset 0 0 0 2px Highlight; }\n #frame:focus-visible { outline: 2px solid Highlight; outline-offset: 4px; }\n}\n</style>\n</head>\n<body>\n<header id=\"bar\">\n <h1 id=\"heading\">Live view</h1>\n <div id=\"statusline\">\n <span id=\"dot\" aria-hidden=\"true\"></span>\n <span id=\"status\">Connecting</span>\n <span id=\"announce\" role=\"status\" aria-live=\"polite\"></span>\n </div>\n <div id=\"tools\" role=\"toolbar\" aria-label=\"Viewer controls\">\n <button type=\"button\" id=\"pause\">Pause agent</button>\n <button type=\"button\" id=\"steps-btn\" aria-expanded=\"false\" aria-controls=\"steps\">Steps</button>\n <div class=\"more\">\n <button type=\"button\" id=\"more-btn\" aria-haspopup=\"dialog\" aria-expanded=\"false\" aria-controls=\"more-menu\">More</button>\n <div id=\"more-menu\" role=\"dialog\" aria-label=\"More options\" hidden>\n <div class=\"menu-row\">\n <button type=\"button\" id=\"details\" aria-pressed=\"false\">Details</button>\n <button type=\"button\" id=\"boxes\" aria-pressed=\"false\" hidden>Boxes</button>\n </div>\n <div class=\"menu-group\" id=\"quality-wrap\" role=\"group\" aria-labelledby=\"quality-label\" hidden>\n <span class=\"menu-label\" id=\"quality-label\">Picture</span>\n <div class=\"seg\" id=\"quality-seg\">\n <button type=\"button\" data-q=\"sharp\" aria-pressed=\"false\">Sharp</button>\n <button type=\"button\" data-q=\"balanced\" aria-pressed=\"true\">Balanced</button>\n <button type=\"button\" data-q=\"light\" aria-pressed=\"false\">Light</button>\n </div>\n <select id=\"quality\" aria-label=\"Stream quality\" hidden>\n <option value=\"sharp\">Sharp</option>\n <option value=\"balanced\" selected>Balanced</option>\n <option value=\"light\">Light</option>\n </select>\n </div>\n <div class=\"menu-group\" id=\"phone-keys\" hidden>\n <span class=\"menu-label\">Phone</span>\n <div class=\"menu-row\">\n <button type=\"button\" id=\"home\" hidden>Home</button>\n <button type=\"button\" id=\"vol-down\" aria-label=\"Volume down\" hidden>Volume down</button>\n <button type=\"button\" id=\"vol-up\" aria-label=\"Volume up\" hidden>Volume up</button>\n </div>\n </div>\n </div>\n </div>\n </div>\n</header>\n<main id=\"stage\">\n <div id=\"device\">\n <span class=\"key\" id=\"key-action\" aria-hidden=\"true\"></span>\n <span class=\"key\" id=\"key-vol-up\" aria-hidden=\"true\" title=\"Volume up\"></span>\n <span class=\"key\" id=\"key-vol-down\" aria-hidden=\"true\" title=\"Volume down\"></span>\n <span class=\"key\" id=\"key-side\" aria-hidden=\"true\"></span>\n <div id=\"frame\" tabindex=\"0\" role=\"img\" aria-label=\"Live view of the phone\" aria-describedby=\"frame-hint\">\n <img id=\"screen\" class=\"pic\" alt=\"\">\n <div id=\"empty\">Waiting for the first frame</div>\n <div id=\"overlay\" aria-hidden=\"true\"></div>\n <canvas id=\"trail\" aria-hidden=\"true\"></canvas>\n <div id=\"marks\" aria-hidden=\"true\"></div>\n </div>\n <div id=\"glass\" aria-hidden=\"true\"><div id=\"island\"></div></div>\n </div>\n <span id=\"frame-hint\" hidden></span>\n <div id=\"caption\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\"><span id=\"caption-text\" aria-hidden=\"true\"></span><span id=\"caption-sr\" class=\"sr-only\"></span></div>\n <aside id=\"steps\" aria-label=\"Steps\" hidden>\n <div class=\"steps-head\">\n <h2>Steps</h2>\n <button type=\"button\" id=\"steps-close\" aria-label=\"Close steps\">&times;</button>\n </div>\n <ol id=\"steps-list\" role=\"list\" tabindex=\"0\" aria-label=\"Recent steps\"></ol>\n <p id=\"steps-empty\">No steps yet. Each touch, typing and scroll shows here.</p>\n </aside>\n <div id=\"notice\" role=\"alert\"></div>\n <div id=\"confirm\" hidden>\n <div id=\"confirm-card\" role=\"alertdialog\" aria-modal=\"true\" aria-labelledby=\"confirm-title\" aria-describedby=\"confirm-text confirm-word\">\n <h2 id=\"confirm-title\">Allow this step?</h2>\n <p id=\"confirm-text\"></p>\n <p id=\"confirm-word\"></p>\n <div id=\"confirm-actions\">\n <button type=\"button\" id=\"confirm-allow\">Allow once</button>\n <button type=\"button\" id=\"confirm-deny\">Don't allow</button>\n </div>\n </div>\n </div>\n</main>\n<script>\n\"use strict\";\n\n/* parser:start */\nvar CRLF2 = new Uint8Array([13, 10, 13, 10]);\n\nfunction boundaryFromContentType(contentType) {\n var m = /boundary=(?:\"([^\"]+)\"|([^;\\s]+))/i.exec(contentType || \"\");\n return m ? (m[1] || m[2]) : \"frame\";\n}\n\nfunction createParserState(boundary) {\n var enc = new TextEncoder();\n return {\n delim: enc.encode(\"--\" + boundary),\n closer: enc.encode(\"\\r\\n--\" + boundary),\n mode: \"preamble\",\n buf: new Uint8Array(0),\n headers: null,\n length: -1\n };\n}\n\nfunction concatBytes(a, b) {\n if (a.length === 0) return b;\n var out = new Uint8Array(a.length + b.length);\n out.set(a, 0);\n out.set(b, a.length);\n return out;\n}\n\nfunction indexOfBytes(hay, needle, from) {\n var n = needle.length;\n var last = hay.length - n;\n var first = needle[0];\n for (var i = from || 0; i <= last; i++) {\n if (hay[i] !== first) continue;\n var j = 1;\n while (j < n && hay[i + j] === needle[j]) j++;\n if (j === n) return i;\n }\n return -1;\n}\n\nfunction parsePartHeaders(bytes) {\n var text = new TextDecoder().decode(bytes);\n var lines = text.split(\"\\r\\n\");\n var headers = {};\n for (var i = 0; i < lines.length; i++) {\n var at = lines[i].indexOf(\":\");\n if (at > 0) headers[lines[i].slice(0, at).trim().toLowerCase()] = lines[i].slice(at + 1).trim();\n }\n return headers;\n}\n\n/* Feed one chunk of the response body; returns the parts completed by it, each\n { headers, body } with lower-case header names and the exact body bytes. */\nfunction parseMultipartChunk(state, chunk) {\n var parts = [];\n state.buf = concatBytes(state.buf, chunk);\n for (;;) {\n var buf = state.buf;\n if (state.mode === \"done\") {\n state.buf = new Uint8Array(0);\n break;\n }\n if (state.mode === \"preamble\") {\n var at = indexOfBytes(buf, state.delim, 0);\n if (at < 0) {\n state.buf = buf.subarray(Math.max(0, buf.length - state.delim.length + 1));\n break;\n }\n state.buf = buf.subarray(at + state.delim.length);\n state.mode = \"headers\";\n continue;\n }\n if (state.mode === \"headers\") {\n if (buf.length >= 2 && buf[0] === 45 && buf[1] === 45) {\n state.mode = \"done\";\n continue;\n }\n var end = indexOfBytes(buf, CRLF2, 0);\n if (end < 0) break;\n state.headers = parsePartHeaders(buf.subarray(0, end));\n var len = parseInt(state.headers[\"content-length\"], 10);\n state.length = len >= 0 ? len : -1;\n state.buf = buf.subarray(end + 4);\n state.mode = \"body\";\n continue;\n }\n if (state.length >= 0) {\n if (buf.length < state.length) break;\n parts.push({ headers: state.headers, body: buf.slice(0, state.length) });\n state.buf = buf.subarray(state.length);\n } else {\n var stop = indexOfBytes(buf, state.closer, 0);\n if (stop < 0) break;\n parts.push({ headers: state.headers, body: buf.slice(0, stop) });\n state.buf = buf.subarray(stop + 2);\n }\n state.mode = \"preamble\";\n state.headers = null;\n }\n return parts;\n}\n/* parser:end */\n\n/* video-block:start */\n/* Video block: H.264 over a WebSocket (WS /video in docs/VIEWER-PROTOCOL.md). It is self-contained:\n paste it unchanged into another page. It reaches the page only through the object given to\n createVideoBlock(io), whose members are:\n io.socketUrl() returns the ws or wss address of /video on the page's own origin\n io.accepts() returns true while the latest status lists video in accepts\n io.picture() returns the picture element (an img) the canvas stands in for. The canvas is\n inserted after it with the same className, so the img needs a class that\n carries its position and size (here: pic). Its style.display is toggled.\n io.onVideoConfig(c) optional. c is { width, height, coverage, source }; called on every accepted\n config, so the page can update its aspect ratio and coverage note\n io.onVideoStarted() the first decoded frame is on screen: stop downloading the JPEG stream\n io.onVideoStopped(why) video is gone (why is a short string) and the picture element shows again:\n start the JPEG stream again\n It returns { update(), stop(), stats() }. Call update() once at start and whenever the status\n changes. Fallback: unsupported config, 3 decoder errors within 10 s, a closed socket, or accepts\n losing video all stop the block; it retries after 5 s only while io.accepts() is still true (an\n unsupported config is not retried until the page is reloaded). */\nfunction createVideoBlock(io) {\n var RETRY_MS = 5000;\n var QUEUE_MAX = 3;\n var ERRORS_MAX = 3;\n var ERRORS_WINDOW_MS = 10000;\n var PENDING_MAX = 120;\n var sock = null;\n var decoder = null;\n var cfg = null;\n var cfgSeq = 0;\n var ready = false;\n var pending = [];\n var needKey = true;\n var keyAsked = false;\n var canvas = null;\n var ctx = null;\n var visible = false;\n var blocked = false;\n var retryTimer = 0;\n var errorTimes = [];\n var counts = {\n configs: 0, pictures: 0, frames: 0, ignored: 0, skipped: 0,\n errors: 0, keyRequests: 0, fallbacks: 0, hardware: \"\", lastReason: \"\", lastError: \"\"\n };\n\n function hasDecoder() {\n return typeof window.VideoDecoder === \"function\" && typeof window.EncodedVideoChunk === \"function\";\n }\n\n function sendKey() {\n if (!sock || sock.readyState !== 1) return;\n try {\n sock.send('{\"type\":\"key\"}');\n counts.keyRequests++;\n } catch (e) { /* the close handler follows */ }\n }\n\n function closeDecoder() {\n var d = decoder;\n decoder = null;\n if (d) {\n try { d.close(); } catch (e) { /* already closed */ }\n }\n }\n\n function clearRetry() {\n if (retryTimer) clearTimeout(retryTimer);\n retryTimer = 0;\n }\n\n /* Drop the socket and the decoder; if video was showing, put the picture element back. */\n function teardown(reason) {\n var s = sock;\n sock = null;\n if (s) {\n s.onmessage = null;\n s.onclose = null;\n s.onerror = null;\n try { s.close(); } catch (e) { /* already closed */ }\n }\n cfgSeq++;\n ready = false;\n pending = [];\n closeDecoder();\n if (visible) {\n visible = false;\n if (canvas) canvas.style.display = \"none\";\n var pic = io.picture();\n if (pic) pic.style.display = \"\";\n counts.fallbacks++;\n counts.lastReason = reason;\n io.onVideoStopped(reason);\n }\n }\n\n function fail(reason) {\n counts.lastReason = reason;\n teardown(reason);\n clearRetry();\n if (hasDecoder() && !blocked && io.accepts()) {\n retryTimer = setTimeout(function () {\n retryTimer = 0;\n update();\n }, RETRY_MS);\n }\n }\n\n function update() {\n var want = hasDecoder() && !blocked && !!io.accepts();\n if (!want) {\n clearRetry();\n if (sock || visible) teardown(\"not offered\");\n return;\n }\n if (!sock && !retryTimer) open();\n }\n\n function stop() {\n clearRetry();\n teardown(\"stopped\");\n }\n\n function open() {\n var s;\n try {\n s = new WebSocket(io.socketUrl());\n s.binaryType = \"arraybuffer\";\n } catch (e) {\n fail(\"socket\");\n return;\n }\n sock = s;\n s.onmessage = function (ev) {\n if (sock !== s) return;\n if (typeof ev.data === \"string\") onText(ev.data);\n else onPicture(ev.data);\n };\n s.onclose = function () {\n if (sock !== s) return;\n sock = null;\n fail(\"closed\");\n };\n s.onerror = function () { /* a close follows */ };\n }\n\n function positive(v) { return typeof v === \"number\" && isFinite(v) && v > 0; }\n\n function onText(text) {\n var m;\n try { m = JSON.parse(text); } catch (e) { return; }\n if (!m || typeof m !== \"object\" || m.type !== \"config\") return;\n if (typeof m.codec !== \"string\" || !positive(m.width) || !positive(m.height) ||\n typeof m.description !== \"string\" || m.description === \"\") {\n fail(\"bad config\");\n return;\n }\n var description;\n try {\n var bin = atob(m.description);\n description = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) description[i] = bin.charCodeAt(i);\n } catch (e) {\n fail(\"bad config\");\n return;\n }\n counts.configs++;\n var seq = ++cfgSeq;\n ready = false;\n pending = [];\n needKey = true;\n keyAsked = false;\n closeDecoder();\n var base = {\n codec: m.codec,\n codedWidth: Math.round(m.width),\n codedHeight: Math.round(m.height),\n description: description,\n optimizeForLatency: true\n };\n chooseConfig(base).then(function (chosen) {\n if (seq !== cfgSeq) return;\n if (!chosen) {\n blocked = true;\n fail(\"unsupported\");\n return;\n }\n begin(chosen, m);\n }, function () {\n if (seq === cfgSeq) {\n blocked = true;\n fail(\"unsupported\");\n }\n });\n }\n\n /* Hardware first, then whatever the browser has; null when neither is supported. */\n function chooseConfig(base) {\n var hw = Object.assign({}, base, { hardwareAcceleration: \"prefer-hardware\" });\n return window.VideoDecoder.isConfigSupported(hw).then(function (r) {\n if (r && r.supported) return hw;\n var any = Object.assign({}, base, { hardwareAcceleration: \"no-preference\" });\n return window.VideoDecoder.isConfigSupported(any).then(function (r2) {\n return r2 && r2.supported ? any : null;\n });\n });\n }\n\n function makeDecoder() {\n var d = new window.VideoDecoder({\n output: function (frame) { onFrame(frame, d); },\n error: function (err) { onDecoderError(err, d); }\n });\n return d;\n }\n\n function begin(chosen, m) {\n try {\n if (!canvas) {\n var pic = io.picture();\n canvas = document.createElement(\"canvas\");\n canvas.className = pic.className;\n canvas.style.display = \"none\";\n canvas.setAttribute(\"aria-hidden\", \"true\");\n pic.parentNode.insertBefore(canvas, pic.nextSibling);\n ctx = canvas.getContext(\"2d\", { alpha: false, desynchronized: true });\n }\n if (canvas.width !== chosen.codedWidth) canvas.width = chosen.codedWidth;\n if (canvas.height !== chosen.codedHeight) canvas.height = chosen.codedHeight;\n cfg = chosen;\n counts.hardware = chosen.hardwareAcceleration;\n decoder = makeDecoder();\n decoder.configure(chosen);\n } catch (e) {\n counts.lastError = String(e && e.message ? e.message : e);\n fail(\"decoder\");\n return;\n }\n ready = true;\n if (typeof io.onVideoConfig === \"function\") {\n io.onVideoConfig({\n width: chosen.codedWidth,\n height: chosen.codedHeight,\n coverage: typeof m.coverage === \"string\" ? m.coverage : \"\",\n source: typeof m.source === \"string\" ? m.source : \"\"\n });\n }\n var queued = pending;\n pending = [];\n for (var i = 0; i < queued.length; i++) feed(queued[i]);\n }\n\n function onPicture(buf) {\n if (!(buf instanceof ArrayBuffer) || buf.byteLength <= 8) return;\n var head = new DataView(buf, 0, 8);\n var item = {\n key: (head.getUint8(0) & 1) === 1,\n ms: head.getUint32(4, false),\n data: new Uint8Array(buf, 8)\n };\n if (ready) {\n feed(item);\n } else {\n /* Configuring still: keep the pictures, but if it takes too long start over at a key frame. */\n if (pending.length >= PENDING_MAX) {\n pending = [];\n needKey = true;\n }\n pending.push(item);\n }\n }\n\n function feed(item) {\n if (!decoder || decoder.state !== \"configured\") return;\n if (item.key) {\n needKey = false;\n keyAsked = false;\n } else {\n if (needKey) {\n counts.ignored++;\n return;\n }\n if (decoder.decodeQueueSize > QUEUE_MAX) {\n needKey = true;\n counts.skipped++;\n if (!keyAsked) {\n keyAsked = true;\n sendKey();\n }\n return;\n }\n }\n try {\n decoder.decode(new window.EncodedVideoChunk({\n type: item.key ? \"key\" : \"delta\",\n timestamp: item.ms * 1000,\n data: item.data\n }));\n counts.pictures++;\n } catch (e) {\n onDecoderError(e, decoder);\n }\n }\n\n function onFrame(frame, d) {\n try {\n if (d !== decoder || !ctx) return;\n ctx.drawImage(frame, 0, 0);\n counts.frames++;\n if (!visible) {\n visible = true;\n canvas.style.display = \"\";\n var pic = io.picture();\n if (pic) pic.style.display = \"none\";\n io.onVideoStarted();\n }\n } finally {\n frame.close();\n }\n }\n\n /* A decoder error: reset, ask for a key frame and wait for it; too many errors give up. */\n function onDecoderError(err, d) {\n if (d !== decoder) return;\n counts.errors++;\n counts.lastError = String(err && err.message ? err.message : err);\n var now = Date.now();\n errorTimes.push(now);\n errorTimes = errorTimes.filter(function (t) { return now - t <= ERRORS_WINDOW_MS; });\n if (errorTimes.length >= ERRORS_MAX) {\n fail(\"decoder errors\");\n return;\n }\n try {\n if (d.state === \"closed\") {\n decoder = makeDecoder();\n decoder.configure(cfg);\n } else {\n d.reset();\n d.configure(cfg);\n }\n } catch (e) {\n fail(\"decoder\");\n return;\n }\n needKey = true;\n keyAsked = true;\n sendKey();\n }\n\n return {\n update: update,\n stop: stop,\n stats: function () {\n return {\n configs: counts.configs, pictures: counts.pictures, frames: counts.frames,\n ignored: counts.ignored, skipped: counts.skipped, errors: counts.errors,\n keyRequests: counts.keyRequests, fallbacks: counts.fallbacks, hardware: counts.hardware,\n lastReason: counts.lastReason, lastError: counts.lastError, active: visible\n };\n }\n };\n}\n/* video-block:end */\n\n(function () {\n var $ = function (id) { return document.getElementById(id); };\n var frame = $(\"frame\");\n var screenEl = $(\"screen\");\n var stage = $(\"stage\");\n var ovl = $(\"overlay\");\n var marks = $(\"marks\");\n var trail = $(\"trail\");\n var notice = $(\"notice\");\n var heading = $(\"heading\");\n\n /* The cookie set with this page authenticates /stream and /ws: take the token out of the\n address bar and the history. */\n if (location.search) history.replaceState(null, \"\", location.pathname);\n function endpoint(path, socket) {\n var u = new URL(path, location.href);\n u.search = \"\";\n u.hash = \"\";\n if (socket) u.protocol = location.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n return u.href;\n }\n\n var status = null;\n var accepts = {};\n var ws = null;\n var wsOpen = false;\n var wsDelay = 500;\n var overlayOn = false;\n var overlay = null;\n var paused = false;\n var lastActionAt = 0;\n var objectUrl = null;\n var streamLive = false;\n var streamOn = true;\n var streamAbort = null;\n var streamWake = null;\n var videoOn = false;\n /* The video's own frame rate while it shows (null until the first second has passed). */\n var videoFps = null;\n var videoFpsTimer = null;\n var haveFrame = false;\n var shown = { rev: null, size: null, coverage: \"\", natural: null };\n var lastAnnounce = \"\";\n\n function isNum(v) { return typeof v === \"number\" && isFinite(v); }\n function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }\n function round4(v) { return Math.round(v * 10000) / 10000; }\n function pct(v) { return (v * 100).toFixed(3) + \"%\"; }\n\n function notify(text) {\n notice.textContent = text;\n notice.classList.add(\"on\");\n clearTimeout(notify.timer);\n notify.timer = setTimeout(function () { notice.classList.remove(\"on\"); }, 4000);\n }\n\n /* The tool's name comes from the status message; the page doesn't know it before that. */\n function applyTool() {\n var tool = status && typeof status.tool === \"string\" ? status.tool.trim().slice(0, 40) : \"\";\n var name = tool ? tool + \" live view\" : \"Live view\";\n document.title = name;\n heading.textContent = name;\n }\n\n /* Layout: the frame (the picture's rectangle) is scaled to fit the stage with the drawn phone around it,\n and keeps the phone's proportions. The body's measures follow the screen's short side. */\n var device = $(\"device\");\n var BEZEL = 0.045; /* screen edge to body edge, of the short side */\n var RIM = 0.009; /* the metal rim, of the short side */\n var CORNER = 0.128; /* the screen's corner radius, of the short side */\n function aspect() {\n var s = shown.size || (status && status.size) || shown.natural;\n if (s && isNum(s.w) && isNum(s.h) && s.w > 0 && s.h > 0) return s.w / s.h;\n return 9 / 19.5;\n }\n var trailCtx = null;\n var trailW = 0;\n var trailH = 0;\n var trailKey = \"\";\n /* The drag trail's canvas holds the frame's size in CSS pixels times the pixel ratio. */\n function sizeTrail(w, h) {\n if (typeof trail.getContext !== \"function\") return;\n var d = window.devicePixelRatio || 1;\n var key = w + \"x\" + h + \"@\" + d;\n if (key === trailKey) return;\n trailKey = key;\n trail.width = Math.max(1, Math.round(w * d));\n trail.height = Math.max(1, Math.round(h * d));\n trailW = w;\n trailH = h;\n trailCtx = trail.getContext(\"2d\");\n if (trailCtx) trailCtx.setTransform(d, 0, 0, d, 0, 0);\n }\n function layout() {\n var pad = 24;\n var room = 84; /* under the phone: the step caption */\n var aw = Math.max(40, stage.clientWidth - pad * 2);\n var ah = Math.max(40, stage.clientHeight - pad * 2 - room);\n var a = aspect();\n var sr = Math.min(1, 1 / a); /* the short side, per unit of width */\n var w = Math.min(aw / (1 + 2 * BEZEL * sr), ah / (1 / a + 2 * BEZEL * sr));\n var fw = Math.floor(w);\n var fh = Math.floor(w / a);\n var s = Math.min(fw, fh);\n var b = Math.max(6, Math.round(s * BEZEL));\n device.style.setProperty(\"--s\", s + \"px\");\n device.style.setProperty(\"--b\", b + \"px\");\n device.style.setProperty(\"--edge\", Math.max(2, Math.round(s * RIM)) + \"px\");\n device.style.setProperty(\"--r\", Math.round(s * CORNER) + \"px\");\n device.classList.toggle(\"wide\", a > 1);\n /* a phone's tall proportions (about 9 by 19.5) get the island; a tablet's don't */\n device.classList.toggle(\"no-island\", Math.max(a, 1 / a) < 1.9);\n frame.style.width = fw + \"px\";\n frame.style.height = fh + \"px\";\n sizeTrail(fw, fh);\n }\n if (typeof ResizeObserver === \"function\") new ResizeObserver(layout).observe(stage);\n window.addEventListener(\"resize\", layout);\n screenEl.addEventListener(\"load\", function () {\n shown.natural = { w: screenEl.naturalWidth, h: screenEl.naturalHeight };\n layout();\n });\n\n /* Status line and toolbar state. */\n var STATE_TEXT = {\n live: \"Live\",\n connecting: \"Connecting\",\n reconnecting: \"Reconnecting\",\n locked: \"Phone locked\",\n \"no-device\": \"No device\",\n \"not-answering\": \"App not answering\"\n };\n /* How the phone is reached, as people write it. */\n var VIA_TEXT = { usb: \"USB\", wifi: \"Wi-Fi\", tailscale: \"Tailscale\" };\n function renderStatus() {\n var dot = $(\"dot\");\n var core = [];\n var cls = \"\";\n var viaText = null;\n if (!wsOpen) {\n core.push(status ? \"Connection lost, retrying\" : \"Connecting\");\n cls = \"wait\";\n } else if (status) {\n var st = String(status.state || \"\");\n core.push(STATE_TEXT[st] || st || \"Unknown\");\n if (status.detail) core.push(String(status.detail));\n if (status.via) core.push(viaText = \"via \" + (VIA_TEXT[String(status.via)] || String(status.via)));\n if (st === \"live\") cls = \"live\";\n else if (st === \"locked\" || st === \"no-device\" || st === \"not-answering\") cls = \"bad\";\n else cls = \"wait\";\n } else {\n core.push(\"Waiting for status\");\n cls = \"wait\";\n }\n if (wsOpen && status) {\n if (status.driver === \"agent\" || status.driver === \"person\") core.push(status.driver + \" driving\");\n if (paused) core.push(\"agent paused\");\n }\n if (haveFrame && !streamLive && !videoOn) core.push(\"picture reconnecting\");\n if (shown.coverage === \"app-window\") core.push(\"app window only\");\n var plain = core.join(\" \\u00b7 \");\n var text = plain;\n var fps = videoOn ? videoFps : status && isNum(status.fps) ? status.fps : null;\n if (wsOpen && status && isNum(fps)) text += \" \\u00b7 \" + fps.toFixed(1) + \" fps\";\n /* The presentation view (Details off) says only the state and who drives (\"Live \\u00b7 agent driving\");\n how the phone is reached and the frame rate are the Details view's, and always in the tooltip. */\n var brief = core.filter(function (part) { return part !== viaText; }).join(\" \\u00b7 \");\n $(\"status\").textContent = document.body.classList.contains(\"details\") ? text : brief;\n $(\"status\").title = text; /* the whole line, when the bar cuts it short */\n dot.className = cls;\n if (plain !== lastAnnounce) {\n lastAnnounce = plain;\n $(\"announce\").textContent = plain;\n }\n var pause = $(\"pause\");\n pause.textContent = paused ? \"Resume agent\" : \"Pause agent\";\n /* The name already says which way it goes (Pause or Resume), so no aria-pressed on top of it. */\n pause.setAttribute(\"data-paused\", paused ? \"true\" : \"false\");\n $(\"empty\").textContent = streamLive || videoOn || haveFrame ? \"Waiting for the next frame\" : \"Waiting for the first frame\";\n }\n\n function canPoint() { return !!(accepts.tap || accepts.longPress || accepts.drag); }\n function applyAccepts() {\n accepts = {};\n if (status && Array.isArray(status.accepts)) {\n for (var i = 0; i < status.accepts.length; i++) accepts[String(status.accepts[i])] = true;\n }\n $(\"quality-wrap\").hidden = !accepts.quality;\n var q = status && status.quality;\n if ((q === \"sharp\" || q === \"balanced\" || q === \"light\") && document.activeElement !== $(\"quality\")) $(\"quality\").value = q;\n syncQuality();\n $(\"phone-keys\").hidden = !(accepts.home || accepts.volume);\n $(\"home\").hidden = !accepts.home;\n $(\"vol-down\").hidden = !accepts.volume;\n $(\"vol-up\").hidden = !accepts.volume;\n /* the drawn volume keys work only where the server takes the key; elsewhere they are drawing */\n $(\"key-vol-up\").classList.toggle(\"live\", !!accepts.volume);\n $(\"key-vol-down\").classList.toggle(\"live\", !!accepts.volume);\n $(\"boxes\").hidden = !accepts.overlay;\n if (!accepts.overlay && overlayOn) {\n /* The server stopped offering overlays: the button is gone, so put it back to off. */\n overlayOn = false;\n overlay = null;\n $(\"boxes\").setAttribute(\"aria-pressed\", \"false\");\n drawOverlay();\n }\n frame.classList.toggle(\"can-point\", canPoint());\n $(\"frame-hint\").textContent = accepts.text || accepts.enter || accepts.backspace\n ? \"Keys typed while this has the focus go to the phone.\" : \"\";\n }\n\n /* Frames. */\n function showPart(part) {\n var h = part.headers;\n var url = URL.createObjectURL(new Blob([part.body], { type: \"image/jpeg\" }));\n var old = objectUrl;\n objectUrl = url;\n screenEl.src = url;\n if (old) URL.revokeObjectURL(old);\n var rev = h[\"x-tree-rev\"];\n shown.rev = rev === undefined || rev === \"\" ? null : String(rev);\n shown.coverage = h[\"x-coverage\"] || \"\";\n var m = /^(\\d+(?:\\.\\d+)?)x(\\d+(?:\\.\\d+)?)$/.exec(h[\"x-size\"] || \"\");\n shown.size = m ? { w: parseFloat(m[1]), h: parseFloat(m[2]) } : null;\n haveFrame = true;\n frame.classList.add(\"has-frame\");\n layout();\n drawOverlay();\n renderStatus();\n }\n\n function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }\n\n /* The JPEG stream runs unless video is showing: setStream(false) aborts the download and\n parks the loop, setStream(true) wakes it. */\n function setStream(on) {\n streamOn = on;\n if (!on) {\n if (streamAbort) streamAbort.abort();\n } else if (streamWake) {\n var wake = streamWake;\n streamWake = null;\n wake();\n }\n }\n\n async function runStream() {\n var delay = 400;\n for (;;) {\n if (!streamOn) {\n await new Promise(function (resolve) { streamWake = resolve; });\n delay = 400;\n continue;\n }\n var abort = typeof AbortController === \"function\" ? new AbortController() : null;\n streamAbort = abort;\n try {\n var res = await fetch(endpoint(\"/stream\", false), {\n cache: \"no-store\",\n credentials: \"same-origin\",\n signal: abort ? abort.signal : undefined\n });\n if (!res.ok || !res.body) throw new Error(\"stream \" + res.status);\n var state = createParserState(boundaryFromContentType(res.headers.get(\"content-type\")));\n var reader = res.body.getReader();\n for (;;) {\n var r = await reader.read();\n if (r.done || !streamOn) break;\n var parts = parseMultipartChunk(state, r.value);\n if (parts.length) {\n if (!streamLive) { streamLive = true; }\n delay = 400;\n showPart(parts[parts.length - 1]);\n }\n }\n if (!streamOn) {\n try { reader.cancel(); } catch (e) { /* already closed */ }\n }\n } catch (e) {\n /* fall through to the backoff */\n }\n streamLive = false;\n renderStatus();\n if (!streamOn) continue;\n await sleep(delay);\n delay = Math.min(Math.round(delay * 1.6), 5000);\n }\n }\n\n /* Overlay: element boxes, drawn only for the frame they belong to. */\n function drawOverlay() {\n ovl.textContent = \"\";\n if (!overlayOn || !overlay || !Array.isArray(overlay.elements)) return;\n var orev = overlay.rev === undefined || overlay.rev === null ? null : String(overlay.rev);\n if (orev !== null && shown.rev !== null && orev !== shown.rev) return;\n ovl.classList.toggle(\"stale\", isNum(overlay.at) && overlay.at < lastActionAt);\n var els = overlay.elements;\n var n = Math.min(els.length, 400);\n for (var i = 0; i < n; i++) {\n var e = els[i];\n var r = e && e.rect;\n if (!r || !isNum(r.x) || !isNum(r.y) || !isNum(r.w) || !isNum(r.h)) continue;\n var box = document.createElement(\"div\");\n box.className = \"box\";\n box.style.left = pct(r.x);\n box.style.top = pct(r.y);\n box.style.width = pct(r.w);\n box.style.height = pct(r.h);\n box.title = [e.ref, e.type, e.label, e.id].filter(Boolean).join(\" \");\n if (typeof e.ref === \"string\" && e.ref !== \"\") {\n var tag = document.createElement(\"span\");\n tag.textContent = e.ref;\n box.appendChild(tag);\n }\n ovl.appendChild(box);\n }\n }\n\n /* Pending marks: a ring drawn the moment a person's tap or long press is released, before\n the server answers. The person's action message then takes the ring over (claimPending);\n with no answer it fades from 1.7 s and is gone at 2 s; an error message drops it at once. */\n var PENDING_FADE_MS = 1700;\n var PENDING_END_MS = 2000;\n /* Both servers call a path a tap when no point of it is 10 points or more from the first\n (docs/VIEWER-PROTOCOL.md leaves the number to the servers; keep this in step with them). */\n var TAP_SLOP = 10;\n /* The server's action names the path's first point; the pending ring sits at its last, which\n can be up to TAP_SLOP away. So the match must allow at least TAP_SLOP, plus a margin for\n the rounding of both ends and for an element's rect edge: 6 points. */\n var NEAR_POINTS = TAP_SLOP + 6;\n var pendingMarks = [];\n\n function phoneSize() {\n var s = shown.size || (status && status.size) || null;\n return s && isNum(s.w) && isNum(s.h) && s.w > 0 && s.h > 0 ? s : null;\n }\n function removeMark(el) {\n if (el.parentNode) el.parentNode.removeChild(el);\n }\n function trimMarks() {\n while (marks.children.length > 12) marks.removeChild(marks.firstChild);\n }\n function forgetPending(entry) {\n clearTimeout(entry.fade);\n clearTimeout(entry.end);\n var i = pendingMarks.indexOf(entry);\n if (i >= 0) pendingMarks.splice(i, 1);\n }\n function dropPending() {\n var all = pendingMarks.slice();\n for (var i = 0; i < all.length; i++) {\n forgetPending(all[i]);\n removeMark(all[i].el);\n }\n }\n function addPending(x, y) {\n var el = document.createElement(\"div\");\n el.className = \"mark person ring pending\";\n el.style.left = pct(x);\n el.style.top = pct(y);\n marks.appendChild(el);\n trimMarks();\n var entry = { el: el, x: x, y: y, fade: 0, end: 0 };\n entry.fade = setTimeout(function () {\n var at = pendingMarks.indexOf(entry);\n if (at >= 0) pendingMarks.splice(at, 1);\n el.classList.add(\"gone\");\n }, PENDING_FADE_MS);\n entry.end = setTimeout(function () {\n forgetPending(entry);\n removeMark(el);\n }, PENDING_END_MS);\n pendingMarks.push(entry);\n }\n /* The oldest pending mark that this person action belongs to: the action's point is within\n NEAR_POINTS of it, or its rect holds it. A failed action with no position takes the oldest. */\n function claimPending(m, r, p) {\n if (m.by !== \"person\" || pendingMarks.length === 0) return null;\n var size = phoneSize();\n var tx = size ? NEAR_POINTS / size.w : 0.04;\n var ty = size ? NEAR_POINTS / size.h : 0.04;\n var lost = m.ok === false && !r && !p;\n for (var i = 0; i < pendingMarks.length; i++) {\n var e = pendingMarks[i];\n if (!e.el.parentNode) continue;\n var near = (p && Math.abs(p.x - e.x) <= tx && Math.abs(p.y - e.y) <= ty) ||\n (r && e.x >= r.x - tx && e.x <= r.x + r.w + tx && e.y >= r.y - ty && e.y <= r.y + r.h + ty);\n if (near || lost) {\n forgetPending(e);\n return e.el;\n }\n }\n return null;\n }\n /* How far, in phone points, the path gets from its first point. */\n function reach(points) {\n var box = frame.getBoundingClientRect();\n var size = phoneSize();\n var w = size ? size.w : Math.max(1, box.width);\n var h = size ? size.h : Math.max(1, box.height);\n var first = points[0];\n var far = 0;\n for (var i = 1; i < points.length; i++) {\n far = Math.max(far, Math.hypot((points[i][1] - first[1]) * w, (points[i][2] - first[2]) * h));\n }\n return far;\n }\n /* A tap or a long press stays within the slop the servers use; anything wider is a drag. */\n function isTapPath(points) {\n return reach(points) < TAP_SLOP;\n }\n\n /* Captions (VIEWER-PROTOCOL 1.4): the step in plain words. The server's caption when it sends one (the\n agent's steps), else one made from kind and label with the protocol's table. */\n var NAME_MAX = 32;\n var CAPTION_MAX = 60;\n function cut(text, max) { return text.length <= max ? text : text.slice(0, max - 1) + \"\\u2026\"; }\n function q(name) { return name ? \" \\\"\" + name + \"\\\"\" : \"\"; }\n var KIND_CAPTION = {\n tap: [\"Tap\", \"You tapped\"], double_tap: [\"Tap\", \"You tapped\"],\n long_press: [\"Press and hold\", \"You pressed and held\"], longPress: [\"Press and hold\", \"You pressed and held\"],\n type_text: [\"Type into\", \"You typed\"], text: [\"Type into\", \"You typed\"],\n enter: [\"Press Return\", \"You pressed Return\"], backspace: [\"Delete\", \"You deleted\"],\n scroll: [\"Scroll\", \"You scrolled\"], swipe: [\"Swipe\", \"You swiped\"], drag: [\"Swipe\", \"You swiped\"],\n toggle: [\"Switch\", \"You switched\"], back: [\"Go back\", \"You went back\"],\n alert: [\"Answer the alert\", \"You answered the alert\"], open_url: [\"Open link\", \"You opened a link\"],\n open_app: [\"Open\", \"You opened\"], close_app: [\"Close\", \"You closed\"],\n rotate: [\"Turn the phone\", \"You turned the phone\"], home: [\"Home\", \"You pressed Home\"],\n reload: [\"Reload the app\", \"You reloaded the app\"], app_command: [\"Run\", \"You ran\"]\n };\n /* kinds whose caption names the element; the rest stand alone (a link's address is never shown) */\n var NAMED = { tap: 1, double_tap: 1, long_press: 1, longPress: 1, type_text: 1, toggle: 1, app_command: 1 };\n var PLAIN_NAME = { open_app: 1, close_app: 1 };\n var REASON = {\n covered: \"covered\", off_screen: \"off screen\", disabled: \"disabled\", under_tab_bar: \"under the tab bar\",\n behind_alert: \"behind an alert\", no_action: \"nothing there\", stale: \"the screen changed\",\n busy: \"someone else is using it\", paused: \"paused\", not_answering: \"app not answering\", timeout: \"took too long\"\n };\n var KIND_ICON = {\n tap: \"\\u25cf\", double_tap: \"\\u25cf\", long_press: \"\\u25c9\", longPress: \"\\u25c9\", type_text: \"\\u270e\", text: \"\\u270e\",\n enter: \"\\u21b5\", backspace: \"\\u232b\", scroll: \"\\u2195\", swipe: \"\\u21c6\", drag: \"\\u21c6\", toggle: \"\\u25d0\",\n back: \"\\u2039\", alert: \"!\", open_url: \"\\u2197\", open_app: \"\\u2197\", close_app: \"\\u00d7\", rotate: \"\\u27f3\",\n home: \"\\u2302\", volume: \"\\u266a\", reload: \"\\u21bb\", app_command: \"\\u2318\"\n };\n function captionOf(m) {\n var kind = String(m.kind || \"\");\n if (kind === \"wait_for\") return null;\n var person = m.by === \"person\";\n var name = cut(String(m.label || \"\").trim(), NAME_MAX);\n var text;\n if (!person && typeof m.caption === \"string\" && m.caption.trim()) {\n text = m.caption.trim();\n } else if (kind === \"volume\") {\n var up = /up/i.test(name);\n text = person ? \"You pressed Volume \" + (up ? \"up\" : \"down\") : \"Volume \" + (up ? \"up\" : \"down\");\n } else if (KIND_CAPTION[kind]) {\n var w = KIND_CAPTION[kind][person ? 1 : 0];\n if (PLAIN_NAME[kind]) text = w + \" \" + (name || \"the app\");\n else if (NAMED[kind] && name && !(person && (kind === \"text\" || kind === \"type_text\"))) text = w + q(name);\n else if (kind === \"type_text\" && !name && !person) text = \"Type\";\n else text = w;\n } else {\n text = (person ? \"You: \" : \"\") + kind.replace(/_/g, \" \");\n }\n text = cut(text, CAPTION_MAX);\n if (m.ok === false) {\n var why = REASON[String(m.reason || \"\")] || REASON[String(m.code || \"\")] || \"didn\\u2019t work\";\n text += \" \\u2013 \" + why;\n }\n return text;\n }\n var captionEl = $(\"caption\");\n var stepsList = $(\"steps-list\");\n var steps = [];\n var lastSpoken = 0;\n var speakTimer = 0;\n /* The caption pill is a polite live region. What it speaks is a second copy of the words, at most once a second:\n a step that comes sooner waits for the second to pass (and a newer one replaces it), so none is lost. */\n function speak(text) {\n clearTimeout(speakTimer);\n var wait = lastSpoken + 1000 - Date.now();\n var say = function () {\n speakTimer = 0;\n lastSpoken = Date.now();\n $(\"caption-sr\").textContent = text;\n };\n if (wait <= 0) say();\n else speakTimer = setTimeout(say, wait);\n }\n function noteStep(m) {\n var text = captionOf(m);\n if (!text) return;\n var cls = (m.by === \"person\" ? \"person\" : \"agent\") + (m.ok === false ? \" fail\" : \"\");\n $(\"caption-text\").textContent = text;\n captionEl.className = \"on \" + cls;\n clearTimeout(noteStep.timer);\n noteStep.timer = setTimeout(function () { captionEl.classList.remove(\"on\"); }, m.ok === false ? 4000 : 2500);\n var now = Date.now();\n speak(text);\n steps.unshift({ text: text, cls: cls, icon: KIND_ICON[String(m.kind || \"\")] || \"\\u2022\", at: now });\n if (steps.length > 50) steps.length = 50;\n if (!$(\"steps\").hidden) renderSteps();\n }\n function ago(ms) {\n var s = Math.round(ms / 1000);\n if (s < 5) return \"now\";\n if (s < 60) return s + \" s\";\n var min = Math.round(s / 60);\n return min < 60 ? min + \" min\" : Math.round(min / 60) + \" h\";\n }\n function renderSteps() {\n var now = Date.now();\n stepsList.textContent = \"\";\n for (var i = 0; i < steps.length; i++) {\n var li = document.createElement(\"li\");\n li.className = steps[i].cls;\n var ico = document.createElement(\"span\");\n ico.className = \"ico\";\n ico.setAttribute(\"aria-hidden\", \"true\");\n ico.textContent = steps[i].icon;\n var txt = document.createElement(\"span\");\n txt.className = \"txt\";\n txt.textContent = steps[i].text;\n var when = document.createElement(\"span\");\n when.className = \"ago\";\n when.textContent = ago(now - steps[i].at);\n li.appendChild(ico);\n li.appendChild(txt);\n li.appendChild(when);\n stepsList.appendChild(li);\n }\n }\n setInterval(function () { if (!$(\"steps\").hidden) renderSteps(); }, 10000);\n\n /* Actions: short-lived marks. */\n function showAction(m) {\n if (isNum(m.at) && m.at > lastActionAt) {\n lastActionAt = m.at;\n drawOverlay();\n }\n var ok = m.ok !== false;\n var label = String(m.label || m.kind || \"\");\n if (!ok && m.code) label = label ? label + \" (\" + m.code + \")\" : String(m.code);\n var r = m.rect;\n var p = m.point;\n if (!(r && isNum(r.x) && isNum(r.y) && isNum(r.w) && isNum(r.h))) r = null;\n if (!(p && isNum(p.x) && isNum(p.y))) p = null;\n var el = claimPending(m, r, p);\n var reused = !!el;\n if (reused) {\n el.textContent = \"\";\n el.style.width = \"\";\n el.style.height = \"\";\n } else {\n el = document.createElement(\"div\");\n }\n el.className = \"mark \" + (m.by === \"person\" ? \"person\" : \"agent\") + (ok ? \"\" : \" fail\");\n if (r) {\n el.classList.add(\"rect\");\n el.style.left = pct(r.x);\n el.style.top = pct(r.y);\n el.style.width = pct(r.w);\n el.style.height = pct(r.h);\n } else if (p) {\n el.classList.add(\"ring\");\n if (p.x > 0.6) el.classList.add(\"flip\"); /* the tag on the ring's left, inside the picture */\n el.style.left = pct(p.x);\n el.style.top = pct(p.y);\n } else {\n el.classList.add(\"pill\");\n el.style.left = \"0\";\n el.style.top = \"0\";\n }\n if (label) {\n var tag = document.createElement(\"span\");\n tag.className = \"tag\";\n tag.textContent = label;\n el.appendChild(tag);\n }\n if (!reused) {\n marks.appendChild(el);\n trimMarks();\n }\n setTimeout(function () { removeMark(el); }, ok ? 1700 : 2700);\n noteStep(m);\n }\n\n /* WebSocket. */\n function send(obj) {\n if (ws && ws.readyState === 1) {\n ws.send(JSON.stringify(obj));\n return true;\n }\n notify(\"Not connected\");\n return false;\n }\n\n /* The person's OK (VIEWER-PROTOCOL 1.5): the agent touched something the safety policy guards, and the server asks\n here. One request shows at a time, the others wait. It closes on an answer (here or in another live view, which\n the server reports as confirm_closed) or when it expires. */\n var asks = [];\n var asking = null;\n var askTimer = null;\n var askFrom = null;\n var askSpace = false;\n /* While the dialog is up the rest of the page is inert: no focus, no clicks, and it is out of the way of screen readers. */\n function setBehind(on) {\n [\"bar\", \"device\", \"steps\"].forEach(function (name) {\n if (on) $(name).setAttribute(\"inert\", \"\");\n else $(name).removeAttribute(\"inert\");\n });\n }\n function addAsk(m) {\n if (typeof m.id !== \"string\" || !m.id) return;\n asks.push({\n id: m.id,\n caption: typeof m.caption === \"string\" && m.caption ? m.caption : \"An action\",\n word: typeof m.word === \"string\" ? m.word : \"\",\n expires: isNum(m.expires) ? m.expires : 0\n });\n showAsk();\n }\n function showAsk() {\n if (asking || !asks.length) return;\n asking = asks.shift();\n $(\"confirm-text\").textContent = asking.caption;\n $(\"confirm-word\").textContent = asking.word ? \"Policy word: \\\"\" + asking.word + \"\\\"\" : \"\";\n askFrom = document.activeElement;\n if (!$(\"more-menu\").hidden) {\n /* an open menu would be left half-usable behind the dialog: close it, and come back to its button */\n if (askFrom && $(\"more-menu\").contains(askFrom)) askFrom = $(\"more-btn\");\n setMore(false);\n }\n $(\"confirm\").hidden = false;\n $(\"confirm-deny\").focus();\n setBehind(true);\n var left = asking.expires - Date.now();\n if (!(left > 0 && left <= 120000)) left = 120000;\n var id = asking.id;\n askTimer = setTimeout(function () { endAsk(id, true); }, left);\n }\n function endAsk(id, expired) {\n if (!asking || asking.id !== id) {\n asks = asks.filter(function (a) { return a.id !== id; });\n return;\n }\n clearTimeout(askTimer);\n asking = null;\n $(\"confirm\").hidden = true;\n setBehind(false);\n if (askFrom && typeof askFrom.focus === \"function\" && document.contains(askFrom)) askFrom.focus();\n askFrom = null;\n if (expired) notify(\"The request ran out\");\n showAsk();\n }\n function answerAsk(allow) {\n if (!asking) return;\n if (send({ type: \"confirm_answer\", id: asking.id, allow: allow })) endAsk(asking.id, false);\n }\n $(\"confirm-allow\").addEventListener(\"click\", function () { answerAsk(true); });\n $(\"confirm-deny\").addEventListener(\"click\", function () { answerAsk(false); });\n /* While the dialog is up every key is its own: Escape is Don't allow, Enter and Space press the focused button,\n Tab and the arrows move between the two. Nothing reaches the phone (typing goes to it otherwise). */\n document.addEventListener(\"keydown\", function (ev) {\n if (!asking) return;\n var altGr = typeof ev.getModifierState === \"function\" && ev.getModifierState(\"AltGraph\");\n if (ev.metaKey || ((ev.ctrlKey || ev.altKey) && !altGr)) return;\n ev.preventDefault();\n ev.stopPropagation();\n var allow = $(\"confirm-allow\");\n var deny = $(\"confirm-deny\");\n if (ev.key === \"Escape\") {\n answerAsk(false);\n } else if (ev.key === \"Enter\" || ev.key === \" \") {\n askSpace = ev.key === \" \";\n answerAsk(document.activeElement === allow);\n } else if (ev.key === \"Tab\" || ev.key.indexOf(\"Arrow\") === 0) {\n (document.activeElement === deny ? allow : deny).focus();\n }\n }, true);\n /* a Space pressed in the dialog must not click what has the focus once the dialog has closed */\n document.addEventListener(\"keyup\", function (ev) {\n if (ev.key === \" \" && askSpace) {\n askSpace = false;\n ev.preventDefault();\n ev.stopPropagation();\n }\n }, true);\n\n function handleMessage(data) {\n if (typeof data !== \"string\") return;\n var m;\n try { m = JSON.parse(data); } catch (e) { return; }\n if (!m || typeof m !== \"object\" || Array.isArray(m) || typeof m.type !== \"string\") return;\n if (m.type === \"status\") {\n status = m;\n paused = m.paused === true;\n applyTool();\n applyAccepts();\n video.update();\n layout();\n renderStatus();\n } else if (m.type === \"overlay\") {\n if (!overlayOn) return;\n overlay = m;\n drawOverlay();\n } else if (m.type === \"action\") {\n showAction(m);\n } else if (m.type === \"confirm\") {\n addAsk(m);\n } else if (m.type === \"confirm_closed\") {\n endAsk(String(m.id), false);\n } else if (m.type === \"error\") {\n var text = String(m.message || \"Error\");\n dropPending();\n notify(m.code ? text + \" (\" + m.code + \")\" : text);\n }\n }\n\n function connectWs() {\n var sock;\n try {\n sock = new WebSocket(endpoint(\"/ws\", true));\n } catch (e) {\n scheduleWs();\n return;\n }\n ws = sock;\n sock.onopen = function () {\n wsOpen = true;\n wsDelay = 500;\n if (overlayOn && accepts.overlay) send({ type: \"overlay\", on: true });\n renderStatus();\n };\n sock.onmessage = function (ev) { handleMessage(ev.data); };\n sock.onclose = function () {\n if (ws !== sock) return;\n ws = null;\n wsOpen = false;\n dropPending();\n renderStatus();\n scheduleWs();\n };\n sock.onerror = function () { /* a close follows */ };\n }\n function scheduleWs() {\n setTimeout(connectWs, wsDelay);\n wsDelay = Math.min(Math.round(wsDelay * 1.7), 5000);\n }\n\n /* Toolbar. After a click with the mouse or a finger the control lets go of the focus, so the\n next Enter or Space goes to the phone and not to the same button again. A keyboard user\n keeps the focus where they put it. */\n var viaPointer = false;\n window.addEventListener(\"pointerdown\", function () { viaPointer = true; }, true);\n window.addEventListener(\"keydown\", function () { viaPointer = false; }, true);\n function letGo(el) {\n if (el && typeof el.blur === \"function\") el.blur();\n }\n function release(el) {\n if (viaPointer) letGo(el);\n }\n $(\"pause\").addEventListener(\"click\", function () {\n var next = !paused;\n if (send({ type: \"pause\", on: next })) {\n paused = next;\n renderStatus();\n }\n release(this);\n });\n $(\"boxes\").addEventListener(\"click\", function () {\n overlayOn = !overlayOn;\n this.setAttribute(\"aria-pressed\", overlayOn ? \"true\" : \"false\");\n if (!overlayOn) overlay = null;\n drawOverlay();\n send({ type: \"overlay\", on: overlayOn });\n release(this);\n });\n $(\"quality\").addEventListener(\"change\", function () {\n send({ type: \"quality\", preset: this.value });\n release(this);\n });\n $(\"home\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"home\" });\n release(this);\n });\n $(\"vol-up\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"volumeUp\" });\n release(this);\n });\n $(\"vol-down\").addEventListener(\"click\", function () {\n send({ type: \"button\", name: \"volumeDown\" });\n release(this);\n });\n /* The drawn keys are a mouse shortcut for the toolbar's buttons (which stay the keyboard's way). */\n $(\"key-vol-up\").addEventListener(\"click\", function () { if (accepts.volume) $(\"vol-up\").click(); });\n $(\"key-vol-down\").addEventListener(\"click\", function () { if (accepts.volume) $(\"vol-down\").click(); });\n\n /* The More menu. The picture quality is three buttons drawn by the page; the select stays (hidden) as the one\n source of the value, so whatever drives it keeps working. */\n function syncQuality() {\n var v = $(\"quality\").value;\n var all = $(\"quality-seg\").querySelectorAll(\"button\");\n for (var i = 0; i < all.length; i++) all[i].setAttribute(\"aria-pressed\", all[i].getAttribute(\"data-q\") === v ? \"true\" : \"false\");\n }\n $(\"quality-seg\").addEventListener(\"click\", function (ev) {\n var b = ev.target && ev.target.closest ? ev.target.closest(\"button[data-q]\") : null;\n if (!b) return;\n var sel = $(\"quality\");\n if (sel.value !== b.getAttribute(\"data-q\")) {\n sel.value = b.getAttribute(\"data-q\");\n sel.dispatchEvent(new Event(\"change\"));\n }\n syncQuality();\n });\n $(\"quality\").addEventListener(\"change\", syncQuality);\n function setMore(open) {\n var menu = $(\"more-menu\");\n /* on a narrow screen the menu spans the width, just under the button */\n menu.style.top = open && window.innerWidth <= 560 ? Math.round($(\"bar\").getBoundingClientRect().bottom + 12) + \"px\" : \"\";\n menu.hidden = !open;\n $(\"more-btn\").setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n }\n /* The controls of the open menu, in order; the hidden ones (and the hidden select) are left out. */\n function menuItems() {\n return Array.prototype.filter.call($(\"more-menu\").querySelectorAll(\"button\"), function (b) {\n return !b.closest(\"[hidden]\");\n });\n }\n function focusMenuItem(last) {\n var items = menuItems();\n if (items.length) items[last ? items.length - 1 : 0].focus();\n }\n $(\"more-btn\").addEventListener(\"click\", function () {\n var open = $(\"more-menu\").hidden;\n setMore(open);\n /* opened with the keyboard: the focus goes in, so the arrow keys and Escape work from the first press */\n if (open && !viaPointer) focusMenuItem(false);\n else release(this);\n });\n document.addEventListener(\"pointerdown\", function (ev) {\n if (!$(\"more-menu\").hidden && ev.target && !ev.target.closest(\".more\")) setMore(false);\n }, true);\n /* Tab out of the menu closes it (the focus goes where Tab sends it). */\n document.querySelector(\".more\").addEventListener(\"focusout\", function (ev) {\n if (!$(\"more-menu\").hidden && ev.relatedTarget && !this.contains(ev.relatedTarget)) setMore(false);\n });\n /* Escape closes what is open, the menu first and then the Steps drawer, and gives the focus back to the\n button that opened it when it was inside. Arrow keys, Home and End move between the menu's controls;\n Down or Up on the More button opens it. Returns true when the key was used. */\n function navigateKeys(ev) {\n var active = document.activeElement;\n var key = ev.key;\n if (key === \"Escape\") {\n if (!$(\"more-menu\").hidden) {\n var inMenu = !!(active && active.closest && active.closest(\".more\"));\n setMore(false);\n if (inMenu) $(\"more-btn\").focus();\n return true;\n }\n if (!$(\"steps\").hidden) {\n var inDrawer = !!(active && (active === $(\"steps-btn\") || $(\"steps\").contains(active)));\n setSteps(false);\n if (inDrawer) $(\"steps-btn\").focus();\n return true;\n }\n return false;\n }\n if (active === $(\"more-btn\") && (key === \"ArrowDown\" || key === \"ArrowUp\")) {\n if ($(\"more-menu\").hidden) setMore(true);\n focusMenuItem(key === \"ArrowUp\");\n return true;\n }\n if (!$(\"more-menu\").hidden && active && $(\"more-menu\").contains(active) &&\n (key.indexOf(\"Arrow\") === 0 || key === \"Home\" || key === \"End\")) {\n var items = menuItems();\n var at = items.indexOf(active);\n if (!items.length) return true;\n var to = key === \"Home\" ? 0 : key === \"End\" ? items.length - 1\n : (key === \"ArrowDown\" || key === \"ArrowRight\") ? (at + 1) % items.length\n : (at <= 0 ? items.length - 1 : at - 1);\n items[to].focus();\n return true;\n }\n return false;\n }\n\n /* Steps drawer and the Details view (VIEWER-PROTOCOL 1.4, presentation view). Details is remembered per\n browser; storage may be unavailable (a private window), and then it simply starts off. */\n function setSteps(open) {\n $(\"steps\").hidden = !open;\n $(\"steps-btn\").setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n if (open) renderSteps();\n }\n $(\"steps-btn\").addEventListener(\"click\", function () {\n var open = $(\"steps\").hidden;\n setSteps(open);\n /* opened with the keyboard: the focus goes into the drawer (Escape or Close brings it back to this button) */\n if (open && !viaPointer) $(\"steps-close\").focus();\n else release(this);\n });\n $(\"steps-close\").addEventListener(\"click\", function () {\n setSteps(false);\n $(\"steps-btn\").focus();\n });\n function setDetails(on) {\n document.body.classList.toggle(\"details\", on);\n $(\"details\").setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n try { localStorage.setItem(\"viewer.details\", on ? \"1\" : \"0\"); } catch (e) { /* not kept */ }\n renderStatus();\n }\n $(\"details\").addEventListener(\"click\", function () {\n setDetails(!document.body.classList.contains(\"details\"));\n release(this);\n });\n try { if (localStorage.getItem(\"viewer.details\") === \"1\") setDetails(true); } catch (e) { /* starts off */ }\n\n /* Drag trail: the path under the pointer while a person drags, on its own canvas so it\n never mixes with the action marks. It is drawn in the person's colour with a dark edge,\n which reads on any picture (the picture doesn't follow the theme). */\n var TRAIL_CLEAR_MS = 400;\n var trailTimer = 0;\n function clearTrail() {\n clearTimeout(trailTimer);\n trailTimer = 0;\n if (trailCtx) trailCtx.clearRect(0, 0, trailW, trailH);\n }\n function drawTrail(points) {\n if (!trailCtx || points.length < 2) return;\n trailCtx.clearRect(0, 0, trailW, trailH);\n trailCtx.lineCap = \"round\";\n trailCtx.lineJoin = \"round\";\n var passes = [[\"rgba(0, 0, 0, 0.55)\", 7], [\"#8be28b\", 4]];\n for (var k = 0; k < passes.length; k++) {\n trailCtx.beginPath();\n for (var i = 0; i < points.length; i++) {\n var x = points[i][1] * trailW;\n var y = points[i][2] * trailH;\n if (i === 0) trailCtx.moveTo(x, y);\n else trailCtx.lineTo(x, y);\n }\n trailCtx.strokeStyle = passes[k][0];\n trailCtx.lineWidth = passes[k][1];\n trailCtx.stroke();\n }\n }\n\n /* Pointer: one finger or the mouse, sent as a path on release. A move is stored only when\n at least MOVE_GAP_MS have passed since the last stored point, so a fast mouse can't fill\n the path with near-duplicates; MAX_POINTS caps the whole path, the last point included. */\n var MOVE_GAP_MS = 16;\n var MAX_POINTS = 300;\n var drag = null;\n function fractionOf(ev) {\n var r = frame.getBoundingClientRect();\n return [\n round4(clamp01((ev.clientX - r.left) / Math.max(1, r.width))),\n round4(clamp01((ev.clientY - r.top) / Math.max(1, r.height)))\n ];\n }\n frame.addEventListener(\"pointerdown\", function (ev) {\n if (!canPoint() || drag || !ev.isPrimary) return;\n if (ev.pointerType === \"mouse\" && ev.button !== 0) return;\n ev.preventDefault();\n var active = document.activeElement;\n if (active && active !== document.body && typeof active.blur === \"function\") active.blur();\n try { frame.setPointerCapture(ev.pointerId); } catch (e) { /* not capturable */ }\n clearTrail();\n var p = fractionOf(ev);\n drag = { id: ev.pointerId, t0: ev.timeStamp, last: 0, far: false, pts: [[0, p[0], p[1]]] };\n });\n frame.addEventListener(\"pointermove\", function (ev) {\n if (!drag || ev.pointerId !== drag.id) return;\n var dt = ev.timeStamp - drag.t0;\n if (dt - drag.last < MOVE_GAP_MS) return;\n var p = fractionOf(ev);\n var pt = [Math.round(dt), p[0], p[1]];\n if (drag.pts.length >= MAX_POINTS - 1) drag.pts[drag.pts.length - 1] = pt;\n else drag.pts.push(pt);\n drag.last = dt;\n /* No trail for a tap or a hold: its ring comes when it is released. */\n if (!drag.far && !isTapPath(drag.pts)) drag.far = true;\n if (drag.far) drawTrail(drag.pts);\n });\n frame.addEventListener(\"pointerup\", function (ev) {\n if (!drag || ev.pointerId !== drag.id) return;\n var p = fractionOf(ev);\n drag.pts.push([Math.max(0, Math.round(ev.timeStamp - drag.t0)), p[0], p[1]]);\n var points = drag.pts;\n drag = null;\n var sent = send({ type: \"gesture\", points: points });\n var tap = isTapPath(points);\n if (!tap) {\n drawTrail(points);\n clearTimeout(trailTimer);\n trailTimer = setTimeout(clearTrail, TRAIL_CLEAR_MS);\n }\n /* A tap or long press shows its ring at once; a drag has no single point. */\n if (sent && (accepts.tap || accepts.longPress) && tap) {\n var end = points[points.length - 1];\n addPending(end[1], end[2]);\n }\n });\n frame.addEventListener(\"pointercancel\", function (ev) {\n if (drag && ev.pointerId === drag.id) {\n drag = null;\n clearTrail();\n }\n });\n\n /* Mouse wheel: events within 120 ms become one scroll. Each share is held to 0.8 of the\n frame (so a fast fling can't ask for more than the phone's screen), which also keeps it\n inside the protocol's -1..1. A scroll that comes to nothing is not sent. */\n var WHEEL_MAX = 0.8;\n var wheel = null;\n function share(v) {\n return round4(Math.max(-WHEEL_MAX, Math.min(WHEEL_MAX, v)));\n }\n function flushWheel() {\n var w = wheel;\n wheel = null;\n if (!w) return;\n var dx = share(w.dx);\n var dy = share(w.dy);\n if (dx === 0 && dy === 0) return;\n send({ type: \"scroll\", x: round4(w.x), y: round4(w.y), dx: dx, dy: dy });\n }\n frame.addEventListener(\"wheel\", function (ev) {\n if (!accepts.scroll) return;\n ev.preventDefault();\n var r = frame.getBoundingClientRect();\n var k = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? r.height : 1;\n if (!wheel) {\n wheel = {\n x: clamp01((ev.clientX - r.left) / Math.max(1, r.width)),\n y: clamp01((ev.clientY - r.top) / Math.max(1, r.height)),\n dx: 0,\n dy: 0\n };\n setTimeout(flushWheel, 120);\n }\n wheel.dx += (ev.deltaX * k) / Math.max(1, r.width);\n wheel.dy += (ev.deltaY * k) / Math.max(1, r.height);\n }, { passive: false });\n\n /* Keyboard and paste: only when the page itself has focus, not a toolbar control. */\n var typed = \"\";\n var typedTimer = 0;\n function flushText() {\n clearTimeout(typedTimer);\n typedTimer = 0;\n if (!typed) return;\n var t = typed;\n typed = \"\";\n send({ type: \"text\", text: t });\n }\n function inToolbar(target) {\n return !!(target && target.closest && target.closest(\"#bar\"));\n }\n /* One of the page's own controls (or something inside the bar, the menu, the drawer or the dialog) has the focus:\n its keys are its own, and nothing it receives goes to the phone. */\n function inOwnControl(target) {\n return !!(target && target.closest &&\n target.closest(\"#bar, #more-menu, #steps, #confirm, button, select, input, textarea, [contenteditable]\"));\n }\n document.addEventListener(\"keydown\", function (ev) {\n if (ev.defaultPrevented || ev.isComposing) return;\n if (ev.key === \"Escape\" || ev.key.indexOf(\"Arrow\") === 0 || ev.key === \"Home\" || ev.key === \"End\") {\n if (!ev.metaKey && !ev.ctrlKey && !ev.altKey && navigateKeys(ev)) {\n ev.preventDefault();\n return;\n }\n }\n if (inOwnControl(ev.target)) {\n /* with nothing open to close, Escape lets go of a toolbar control so the keys go to the phone again */\n if (ev.key === \"Escape\" && inToolbar(ev.target)) letGo(document.activeElement);\n return;\n }\n /* Ctrl, Alt and Meta make shortcuts, which stay the browser's. But AltGr (Alt Gr on a\n Windows layout reports Ctrl and Alt together) types characters such as @ or a with an\n accent, and those go to the phone. */\n var altGr = typeof ev.getModifierState === \"function\" && ev.getModifierState(\"AltGraph\");\n if (ev.metaKey || ((ev.ctrlKey || ev.altKey) && !altGr)) return;\n if (ev.key === \"Backspace\" && accepts.backspace) {\n ev.preventDefault();\n flushText();\n send({ type: \"key\", key: \"backspace\" });\n } else if (ev.key === \"Enter\" && accepts.enter) {\n ev.preventDefault();\n flushText();\n send({ type: \"key\", key: \"enter\" });\n } else if (accepts.text && Array.from(ev.key).length === 1) {\n ev.preventDefault();\n typed += ev.key;\n clearTimeout(typedTimer);\n typedTimer = setTimeout(flushText, 300);\n }\n });\n document.addEventListener(\"paste\", function (ev) {\n if (!accepts.text || inOwnControl(ev.target)) return;\n var text = ev.clipboardData ? ev.clipboardData.getData(\"text\") : \"\";\n if (!text) return;\n ev.preventDefault();\n flushText();\n send({ type: \"text\", text: text });\n });\n\n /* Video: the self-contained block above, wired to this page. */\n var video = createVideoBlock({\n socketUrl: function () { return endpoint(\"/video\", true); },\n accepts: function () { return !!accepts.video; },\n picture: function () { return screenEl; },\n onVideoConfig: function (c) {\n shown.rev = null;\n shown.size = null;\n shown.coverage = c.coverage || \"\";\n shown.natural = { w: c.width, h: c.height };\n layout();\n drawOverlay();\n renderStatus();\n },\n onVideoStarted: function () {\n videoOn = true;\n setStream(false);\n streamLive = false;\n haveFrame = true;\n frame.classList.add(\"has-frame\");\n layout();\n startVideoFps();\n renderStatus();\n },\n onVideoStopped: function () {\n videoOn = false;\n stopVideoFps();\n setStream(true);\n renderStatus();\n }\n });\n\n /* While video shows, the status line's rate is the video's own: frames drawn in the last second.\n (The server's fps is its JPEG stream's.) */\n function startVideoFps() {\n stopVideoFps();\n var last = video.stats().frames;\n var lastAt = Date.now();\n videoFps = null;\n videoFpsTimer = setInterval(function () {\n var now = Date.now();\n var frames = video.stats().frames;\n var seconds = (now - lastAt) / 1000;\n if (seconds > 0) videoFps = Math.max(0, (frames - last) / seconds);\n last = frames;\n lastAt = now;\n renderStatus();\n }, 1000);\n }\n function stopVideoFps() {\n if (videoFpsTimer !== null) clearInterval(videoFpsTimer);\n videoFpsTimer = null;\n videoFps = null;\n }\n\n layout();\n renderStatus();\n connectWs();\n runStream();\n})();\n</script>\n</body>\n</html>\n";
10
10
  //# sourceMappingURL=viewer-page.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"viewer-page.js","sourceRoot":"","sources":["../src/viewer-page.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,WAAW,GAAW,y92EAAy92E,CAAC"}
1
+ {"version":3,"file":"viewer-page.js","sourceRoot":"","sources":["../src/viewer-page.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,WAAW,GAAW,u/uFAAu/uF,CAAC"}
package/dist/viewer.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Duplex } from "node:stream";
3
+ import type { Confirmations } from "./confirm.js";
3
4
  import type { ControlQueue } from "./control.js";
4
5
  import { type AppFrame, type HttpExtension, type TaplineHost } from "./server.js";
5
6
  /**
@@ -89,6 +90,8 @@ export interface ViewerHubOptions {
89
90
  idleStopMs?: number;
90
91
  /** How often to resend the status when the measured frame rate changed. Default 2000 ms. */
91
92
  statusEveryMs?: number;
93
+ /** A person's OK for guarded touches (VIEWER-PROTOCOL 1.5): shown to every viewer, answered from any. */
94
+ confirmations?: Confirmations;
92
95
  }
93
96
  interface StatusFields {
94
97
  state: "live" | "connecting" | "reconnecting" | "locked" | "no-device" | "not-answering";
package/dist/viewer.js CHANGED
@@ -103,7 +103,12 @@ export class ViewerHub {
103
103
  fpsTimer;
104
104
  constructor(options) {
105
105
  this.options = options;
106
- this.unsubscribe = options.control.onChange(() => this.broadcastStatus());
106
+ const offControl = options.control.onChange(() => this.broadcastStatus());
107
+ const offConfirm = options.confirmations?.onEvent((event) => this.broadcast({ ...event }));
108
+ this.unsubscribe = () => {
109
+ offControl();
110
+ offConfirm?.();
111
+ };
107
112
  }
108
113
  /** While anyone is connected, resend the status when the measured frame rate changes. */
109
114
  watchFps() {
@@ -263,11 +268,12 @@ export class ViewerHub {
263
268
  socket.destroy();
264
269
  return true;
265
270
  }
266
- this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws));
271
+ const fromPage = origin !== undefined && originOk;
272
+ this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, fromPage));
267
273
  return true;
268
274
  }
269
- onSocket(ws) {
270
- const client = { ws, overlay: false };
275
+ onSocket(ws, fromPage = false) {
276
+ const client = { ws, overlay: false, fromPage };
271
277
  this.sockets.add(client);
272
278
  ws.on("close", () => {
273
279
  this.sockets.delete(client);
@@ -288,6 +294,9 @@ export class ViewerHub {
288
294
  });
289
295
  this.viewersChanged();
290
296
  this.send(client, this.statusMessage());
297
+ // A request still waiting for an answer shows on a viewer that joins now, too.
298
+ for (const request of this.options.confirmations?.open() ?? [])
299
+ this.send(client, { ...request });
291
300
  }
292
301
  async onMessage(client, message) {
293
302
  switch (message["type"]) {
@@ -304,6 +313,14 @@ export class ViewerHub {
304
313
  }
305
314
  }
306
315
  return;
316
+ case "confirm_answer": {
317
+ const id = message["id"];
318
+ const allow = message["allow"];
319
+ // A person's OK comes from the page in a browser, never from a bare socket.
320
+ if (client.fromPage && typeof id === "string" && typeof allow === "boolean")
321
+ this.options.confirmations?.answer(id, allow);
322
+ return;
323
+ }
307
324
  case "quality": {
308
325
  const preset = message["preset"];
309
326
  if (preset === "sharp" || preset === "balanced" || preset === "light") {