@taqwright/taqwright 0.0.25 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin/init.js +3 -2
- package/dist/capabilities.js +3 -0
- package/dist/inspector/ui.d.ts +1 -1
- package/dist/inspector/ui.js +102 -12
- package/package.json +3 -2
package/dist/inspector/ui.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const INSPECTOR_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>taqwright codegen</title>\n<link rel=\"icon\" type=\"image/png\" href=\"/static/logo.png\" />\n<style>\n :root {\n color-scheme: light;\n --bg: #ffffff;\n --panel: #f6f8fa;\n --panel-2: #eaeef2;\n --border: #d0d7de;\n --border-strong: #afb8c1;\n --text: #1f2328;\n --text-dim: #656d76;\n --text-muted: #8b949e;\n --accent: #0969da;\n --accent-hover: #0550ae;\n --success: #1a7f37;\n --warn: #9a6700;\n --danger: #cf222e;\n --code-bg: #f6f8fa;\n --hl: rgba(9, 105, 218, 0.10);\n --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;\n }\n * { box-sizing: border-box; }\n html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);\n font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif;\n -webkit-font-smoothing: antialiased; }\n /* \u2500\u2500\u2500 View switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n body.view-setup main { display: none; }\n body.view-setup .inspector-only { display: none !important; }\n body.view-inspector #setup { display: none; }\n body.view-inspector .setup-only { display: none !important; }\n /* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n header { display: flex; align-items: center; gap: 10px; padding: 8px 16px;\n background: var(--panel); border-bottom: 1px solid var(--border); height: 52px; }\n header .logo { height: 32px; width: auto; object-fit: contain; border-radius: 6px;\n flex-shrink: 0; }\n header h1 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -0.01em;\n color: var(--text); }\n header h1 .brand { color: var(--accent); font-weight: 700; }\n header .dot { color: var(--text-muted); margin: 0 4px; }\n header .meta { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }\n header .spacer { flex: 1; }\n header .header-ad { display: inline-flex; align-items: center; gap: 5px;\n text-decoration: none; font-size: 11.5px; color: var(--text-dim);\n background: var(--panel-2); border: 1px solid var(--border);\n padding: 4px 10px; border-radius: 999px; white-space: nowrap;\n transition: color 0.1s, border-color 0.1s, background 0.1s; }\n header .header-ad:hover { color: var(--accent); border-color: var(--accent);\n background: var(--bg); }\n header .header-ad-arrow { font-size: 11px; opacity: 0.8; }\n @media (max-width: 720px) { header .header-ad-text { display: none; } }\n button.icon { background: var(--panel-2); border: 1px solid var(--border);\n color: var(--text-dim); padding: 6px 10px; border-radius: 6px; font: inherit;\n cursor: pointer; white-space: nowrap; transition: background 0.1s, color 0.1s; }\n button.icon:hover { background: var(--border); color: var(--text); }\n button.icon.active { background: var(--accent); color: #fff; border-color: var(--accent); }\n button.icon.danger { background: var(--danger); color: #fff; border-color: var(--danger); }\n button.icon.danger:hover { background: #b81c28; border-color: #b81c28; color: #fff; }\n button.icon:disabled { opacity: 0.5; cursor: not-allowed; }\n /* \u2500\u2500\u2500 Setup landing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #setup { padding: 16px 20px; max-width: 1100px; margin: 0 auto;\n height: calc(100vh - 52px); display: flex; flex-direction: column;\n gap: 12px; box-sizing: border-box; }\n /* \u2500\u2500\u2500 Wizard (3-step setup flow) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .wizard-stepper { display: flex; align-items: center; gap: 0;\n padding: 4px 4px 8px; flex-shrink: 0; }\n .wizard-step-pill { display: inline-flex; align-items: center; gap: 9px;\n padding: 5px 14px 5px 5px; border-radius: 999px;\n background: var(--panel); border: 1px solid var(--border);\n color: var(--text-dim); font-size: 12.5px; font-weight: 500;\n user-select: none; transition: all 0.15s; }\n .wizard-step-pill .num { display: inline-flex; align-items: center;\n justify-content: center; width: 22px; height: 22px; border-radius: 50%;\n font-weight: 700; font-size: 11.5px; background: var(--panel-2);\n color: var(--text-muted); border: 1px solid var(--border);\n font-family: var(--mono); flex-shrink: 0; }\n .wizard-step-pill.active { color: var(--text); border-color: var(--accent);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.10); }\n .wizard-step-pill.active .num { background: var(--accent); color: white;\n border-color: var(--accent); }\n .wizard-step-pill.done { color: var(--success);\n border-color: rgba(26,127,55,0.35); cursor: pointer; }\n .wizard-step-pill.done:hover { background: #dafbe1; }\n .wizard-step-pill.done .num { background: var(--success); color: white;\n border-color: var(--success); }\n .wizard-step-pill.done .num .digit { display: none; }\n .wizard-step-pill.done .num::before { content: \"\u2713\"; }\n .wizard-line { flex: 1; height: 2px; background: var(--border); margin: 0 6px;\n border-radius: 1px; transition: background 0.25s; min-width: 24px;\n max-width: 80px; }\n .wizard-line.done { background: var(--success); }\n .wizard-content { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 0 2px; }\n .wizard-page { display: none; }\n .wizard-page.active { display: block; animation: wizardIn 0.22s ease-out; }\n @keyframes wizardIn {\n from { opacity: 0; transform: translateY(6px); }\n to { opacity: 1; transform: translateY(0); }\n }\n .wizard-page-head { margin: 0 0 14px; padding: 0 2px; }\n .wizard-page-head h2 { font-size: 17px; font-weight: 600; margin: 0 0 4px;\n letter-spacing: -0.01em; color: var(--text); }\n .wizard-page-head p { font-size: 12.5px; color: var(--text-dim); margin: 0;\n line-height: 1.5; }\n /* Step 1: connection-mode picker */\n .conn-mode-card { margin: 0 0 14px; }\n .conn-mode-label { font-size: 12px; font-weight: 600; color: var(--text-dim);\n text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;\n padding-left: 2px; }\n .conn-mode-toggle { display: grid; grid-template-columns: repeat(3, 1fr);\n gap: 10px; }\n @media (max-width: 800px) { .conn-mode-toggle { grid-template-columns: 1fr; } }\n .conn-mode-btn { display: flex; align-items: center; gap: 12px;\n padding: 12px 14px; background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; cursor: pointer; text-align: left;\n transition: border-color 0.1s, background 0.1s, box-shadow 0.1s;\n font: inherit; color: var(--text); min-width: 0; }\n .conn-mode-btn:hover { background: var(--panel-2);\n border-color: var(--border-strong); }\n .conn-mode-btn.active { border-color: var(--accent);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.10); }\n .conn-mode-ico { font-size: 22px; line-height: 1; flex-shrink: 0;\n width: 32px; height: 32px; border-radius: 8px;\n background: var(--panel-2); display: inline-flex;\n align-items: center; justify-content: center;\n border: 1px solid var(--border); }\n .conn-mode-btn.active .conn-mode-ico { background: var(--accent); color: white;\n border-color: var(--accent); }\n .conn-mode-body { display: flex; flex-direction: column; gap: 2px;\n min-width: 0; }\n .conn-mode-title { font-weight: 600; font-size: 13.5px; color: var(--text); }\n .conn-mode-sub { font-size: 11.5px; color: var(--text-dim); }\n /* Step 1: prereqs grid */\n .prereq-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;\n align-content: start; }\n @media (max-width: 800px) { .prereq-grid { grid-template-columns: 1fr; } }\n /* Indeterminate \"checking\" progress bar above prereqs */\n .prereq-progress { height: 3px; background: var(--panel-2); border-radius: 2px;\n overflow: hidden; margin: 0 2px 14px; opacity: 1; position: relative;\n transition: opacity 0.35s; }\n .prereq-progress.done { opacity: 0; pointer-events: none; }\n .prereq-progress::before { content: \"\"; position: absolute; top: 0; bottom: 0;\n background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);\n width: 35%; left: 0;\n animation: prereqSlide 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite; }\n @keyframes prereqSlide {\n from { transform: translateX(-100%); }\n to { transform: translateX(380%); }\n }\n /* Step 3: app browse row */\n .app-browse-row { display: grid; grid-template-columns: 90px 1fr auto;\n align-items: center; gap: 8px; margin-bottom: 4px; }\n .app-browse-row label { font-size: 12px; color: var(--text-dim); }\n .app-browse-row input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; min-width: 0; width: 100%; }\n .app-browse-row input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .app-browse-row .browse-btn { padding: 5px 12px; flex-shrink: 0; }\n .app-inspect-status { font-size: 11.5px; color: var(--text-dim);\n margin: 0 0 8px 98px; min-height: 14px; font-family: var(--mono); }\n .app-inspect-status.ok { color: var(--success); }\n .app-inspect-status.err { color: var(--danger); }\n .app-inspect-status.busy { color: var(--accent); }\n .app-inspect-status .spinner { display: inline-block; width: 10px; height: 10px;\n border: 2px solid rgba(9,105,218,0.25); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite;\n margin-right: 6px; vertical-align: -2px; }\n /* Wizard footer reuses .action-bar styling. Back button alignment. */\n .action-bar.wizard-bar { justify-content: flex-start; }\n .action-bar.wizard-bar .grow { flex: 1; }\n /* Devices card */\n .device-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border);\n margin-bottom: 12px; padding-bottom: 0; }\n .device-tab { background: transparent; border: none; color: var(--text-dim);\n font: 12.5px inherit; padding: 8px 14px; cursor: pointer;\n border-bottom: 2px solid transparent; margin-bottom: -1px;\n display: inline-flex; align-items: center; gap: 6px; }\n .device-tab:hover { color: var(--text); }\n .device-tab.active { color: var(--text); border-bottom-color: var(--accent);\n font-weight: 600; }\n .device-tab .count { font-size: 10.5px; color: var(--text-muted);\n background: var(--panel-2); padding: 1px 7px; border-radius: 999px;\n border: 1px solid var(--border); font-family: var(--mono); font-weight: 500; }\n .device-tab.active .count { color: var(--accent); border-color: rgba(9,105,218,0.3);\n background: #ddf4ff; }\n .device-grid { display: grid; gap: 10px;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }\n .device-pagination { display: flex; align-items: center; justify-content: center;\n gap: 12px; margin-top: 12px; padding-top: 8px; }\n .device-pagination .info { font-size: 11.5px; color: var(--text-dim);\n font-family: var(--mono); }\n .device-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }\n .device-tile { display: flex; flex-direction: column; gap: 4px;\n padding: 12px 12px 10px; border-radius: 8px; background: var(--panel-2);\n border: 1px solid var(--border); position: relative;\n transition: border-color 0.1s, background 0.1s, box-shadow 0.1s; }\n .device-tile.selectable { cursor: pointer; }\n .device-tile.selectable:hover { border-color: rgba(9,105,218,0.4);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%); }\n .device-tile.selected { border-color: var(--accent); border-width: 2px;\n padding: 11px 11px 9px;\n background: linear-gradient(180deg, #ddf4ff 0%, #f1f8ff 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .device-tile .check { position: absolute; bottom: 8px; right: 8px;\n width: 22px; height: 22px; border-radius: 50%; background: var(--accent);\n color: white; display: none; align-items: center; justify-content: center;\n font-size: 12px; font-weight: 700; line-height: 1;\n box-shadow: 0 2px 6px rgba(9,105,218,0.35); }\n .device-tile.selected .check { display: inline-flex; }\n .device-tile.booted { background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n border-color: rgba(9,105,218,0.3); }\n .device-tile.booting { background: linear-gradient(180deg, #fff8c5 0%, var(--panel) 100%);\n border-color: rgba(154,103,0,0.35); }\n .device-tile .pill.booting { color: var(--warn);\n border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .device-tile .pill.booting .led { display: none; }\n .device-tile .pill.booting .spinner { display: inline-block; width: 9px; height: 9px;\n border: 1.5px solid rgba(154,103,0,0.25); border-top-color: var(--warn);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .device-tile .top { display: flex; align-items: center; gap: 8px; }\n .device-tile .icon { font-size: 22px; line-height: 1; }\n .device-tile .name { flex: 1; font-weight: 600; font-size: 13px; color: var(--text);\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .device-tile .meta { font-size: 11.5px; color: var(--text-dim); font-family: var(--mono);\n margin-left: 30px; }\n .device-tile .udid { font-size: 10.5px; color: var(--text-muted); font-family: var(--mono);\n margin-left: 30px; word-break: break-all; }\n .device-tile .pill { padding: 1px 7px; font-size: 10px; }\n .device-tile .actions { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }\n .device-tile .actions .icon { padding: 4px 9px; font-size: 11.5px; }\n .device-tile .actions .icon.use { background: var(--accent); color: white;\n border-color: var(--accent); }\n .device-tile .actions .icon.use:hover { background: var(--accent-hover);\n border-color: var(--accent-hover); }\n .device-empty { padding: 12px 0; color: var(--text-muted); font-size: 12px; font-style: italic; }\n .device-empty .rec-sel-spinner { width: 13px; height: 13px; border-width: 1.5px;\n vertical-align: -2px; margin-right: 6px; font-style: normal; }\n .device-warn { padding: 8px 10px; margin-bottom: 10px; font-size: 12px;\n background: #fff8c5; border: 1px solid rgba(154,103,0,0.35);\n color: var(--warn); border-radius: 5px; font-family: var(--mono); }\n .card { background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; padding: 12px 14px; }\n .card.flex { display: flex; flex-direction: column; min-height: 0; }\n .card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }\n .card-head h2 { font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); margin: 0; }\n .card-head .grow { flex: 1; }\n /* Doctor */\n .doctor-summary { display: flex; align-items: center; gap: 8px;\n padding: 6px 8px; border-radius: 5px; font-size: 12.5px;\n background: var(--panel-2); cursor: pointer; user-select: none; }\n .doctor-summary:hover { background: var(--border); }\n .doctor-summary .twisty { color: var(--text-muted); margin-left: auto; font-size: 10px; }\n .doctor-summary .pill { padding: 1px 7px; font-size: 10px; }\n .doctor-list { list-style: none; margin: 8px 0 0; padding: 0; display: none; }\n .doctor-list.expanded { display: block;\n max-height: clamp(140px, calc(100vh - 430px), 360px); overflow-y: auto; }\n .doctor-list li { display: block; padding: 3px 8px; font-size: 12px; }\n .doctor-row { display: flex; align-items: center; gap: 8px; min-width: 0; }\n .doctor-list .ico { width: 14px; flex-shrink: 0; text-align: center; font-weight: 700;\n font-family: var(--mono); font-size: 11px; }\n .doctor-list .ico.ok { color: var(--success); }\n .doctor-list .ico.warn { color: var(--warn); }\n .doctor-list .ico.error { color: var(--danger); }\n .doctor-list .name { color: var(--text); min-width: 0; overflow-wrap: anywhere; }\n .doctor-list .detail { color: var(--text-dim); font-family: var(--mono);\n font-size: 11px; margin-left: auto; text-align: right;\n min-width: 0; overflow-wrap: anywhere; }\n .doctor-list .detail-block { margin: 2px 0 4px 22px; color: var(--text-dim);\n font-family: var(--mono); font-size: 11px; line-height: 1.45;\n overflow-wrap: anywhere; word-break: break-word; }\n /* Inputs */\n .field { display: grid; grid-template-columns: 90px 1fr; align-items: center;\n gap: 8px; margin-bottom: 6px; }\n .field label { font-size: 12px; color: var(--text-dim); }\n .field input, .field select { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; width: 100%; }\n .field select { font-family: inherit; cursor: pointer; }\n .field input:focus, .field select:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .field-tri { display: grid; grid-template-columns: 90px 1fr 60px 90px;\n align-items: center; gap: 8px; margin-bottom: 8px; }\n .field-tri label { font-size: 12px; color: var(--text-dim); }\n .field-tri input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; min-width: 0; width: 100%; }\n .field-tri input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .checkbox { display: flex; align-items: center; gap: 6px; padding: 4px 0;\n font-size: 12px; color: var(--text); cursor: pointer; }\n /* Standalone checkbox row \u2014 used for noReset etc. (no left-gutter) */\n .checkbox-row { display: flex; align-items: center; gap: 8px;\n padding: 8px 10px; margin: 4px 0; border-radius: 5px;\n background: var(--panel-2); border: 1px solid var(--border);\n cursor: pointer; user-select: none; }\n .checkbox-row:hover { background: var(--border); }\n .checkbox-row input { margin: 0; flex-shrink: 0; }\n .checkbox-row .label { color: var(--text); font-size: 13px; font-weight: 500; }\n .checkbox-row .hint { color: var(--text-dim); font-size: 12px; margin-left: auto; }\n /* Pills */\n .pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;\n border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em;\n text-transform: uppercase; border: 1px solid var(--border); }\n .pill .led { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }\n .pill.live { color: var(--success); border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .pill.live .led { background: var(--success); box-shadow: 0 0 6px rgba(26,127,55,0.5); }\n .pill.down { color: var(--warn); border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .pill.down .led { background: var(--warn); }\n .pill.booting { color: var(--warn); border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .pill.booting .led { width: 9px; height: 9px; background: transparent; box-shadow: none;\n border: 1.5px solid rgba(154,103,0,0.3); border-top-color: var(--warn);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .appium-hint { font-size: 11px; color: var(--text-dim); line-height: 1.45; margin-top: 6px; }\n /* Capabilities */\n .caps-fields { flex: 1; min-height: 0; overflow: auto; padding-right: 4px; }\n .extras-head { display: flex; align-items: center; gap: 8px;\n color: var(--text-dim); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.08em;\n border-top: 1px solid var(--border); margin-top: 10px; padding: 12px 0 6px; }\n .extras-list { display: flex; flex-direction: column; gap: 6px; }\n .extras-list .empty-row { color: var(--text-muted); font-size: 12px;\n font-style: italic; padding: 6px 0; }\n .extra-cap { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(0,1fr) 28px;\n gap: 6px; align-items: center; }\n .extra-cap input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 12.5px var(--mono); outline: none; width: 100%; min-width: 0; }\n .extra-cap input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .x-btn { background: transparent; border: 1px solid var(--border);\n color: var(--text-muted); width: 28px; height: 28px; border-radius: 5px;\n font-size: 16px; line-height: 1; cursor: pointer; padding: 0;\n display: inline-flex; align-items: center; justify-content: center; }\n .x-btn:hover { color: var(--danger); border-color: rgba(207,34,46,0.4);\n background: #ffebe9; }\n .add-cap-btn { display: inline-flex; align-items: center; gap: 6px;\n padding: 6px 12px; background: transparent; color: var(--accent);\n border: 1px dashed var(--border); border-radius: 5px;\n font: 12.5px inherit; cursor: pointer; margin-top: 8px; }\n .add-cap-btn:hover { background: var(--panel-2); border-color: var(--accent); }\n .add-cap-btn .plus { font-weight: 700; font-size: 14px; line-height: 1; }\n /* Sticky action bar */\n .action-bar { flex-shrink: 0; display: flex; align-items: center; gap: 12px;\n padding: 12px 16px; background: var(--panel); border-radius: 8px;\n border: 1px solid var(--border); }\n .action-summary { color: var(--text-dim); font-size: 12px; font-family: var(--mono);\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .action-summary strong { color: var(--text); font-weight: 600; }\n button.primary { background: var(--accent); color: white; border: none;\n padding: 8px 22px; border-radius: 6px; font: 600 13px inherit; cursor: pointer;\n transition: background 0.1s; }\n button.primary:hover { background: var(--accent-hover); }\n button.primary:disabled { opacity: 0.5; cursor: not-allowed; }\n .err-banner { color: var(--danger); font-size: 12px; padding: 6px 10px;\n background: #ffebe9; border: 1px solid rgba(207,34,46,0.3);\n border-radius: 5px; margin-top: 8px; font-family: var(--mono); display: none; }\n .err-banner.shown { display: block; }\n .info-banner { color: var(--text-dim); font-size: 12px; padding: 8px 10px;\n background: #ddf4ff; border: 1px solid rgba(9,105,218,0.3);\n border-radius: 5px; margin-top: 10px; }\n .btn-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }\n .btn-row .grow { flex: 1; }\n /* \u2500\u2500\u2500 Layout (inspector view) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n main { display: grid; grid-template-columns: minmax(280px, 30%) 1fr minmax(360px, 36%);\n height: calc(100vh - 52px); }\n .pane { overflow: hidden; display: flex; flex-direction: column;\n border-right: 1px solid var(--border); background: var(--bg); min-width: 0; }\n .pane:last-child { border-right: none; }\n .pane-head { padding: 10px 14px; border-bottom: 1px solid var(--border);\n display: flex; align-items: center; gap: 8px; background: var(--panel);\n flex-shrink: 0; height: 40px; }\n .pane-title { font-size: 11px; font-weight: 600; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); }\n .pane-body { flex: 1; overflow: auto; min-height: 0; }\n /* \u2500\u2500\u2500 Tree pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* Hierarchy view-mode toggle (Tree / XML) */\n .hier-mode-toggle { display: inline-flex; gap: 0; flex-shrink: 0;\n border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }\n .hier-mode-btn { background: var(--panel-2); border: none; color: var(--text-dim);\n padding: 3px 9px; font: 11px inherit; cursor: pointer;\n border-right: 1px solid var(--border); transition: background 0.1s, color 0.1s; }\n .hier-mode-btn:last-child { border-right: none; }\n .hier-mode-btn:hover { color: var(--text); }\n .hier-mode-btn.active { background: var(--accent); color: white; font-weight: 600; }\n .context-select { flex-shrink: 0; background: var(--panel-2); color: var(--text);\n border: 1px solid var(--border); border-radius: 5px; padding: 3px 7px;\n font: 11px inherit; cursor: pointer; max-width: 220px; }\n .context-select.web { border-color: var(--accent); color: var(--accent); font-weight: 600; }\n .context-hint { flex-shrink: 0; color: var(--muted); font: 11px inherit;\n cursor: pointer; padding: 3px 6px; border-radius: 5px; }\n .context-hint:hover { color: var(--text); background: var(--panel-2); }\n /* Hierarchy XML view */\n .hier-xml-body { padding: 0; background: var(--code-bg); }\n #hier-xml-pre { font-family: var(--mono); font-size: 11.5px;\n line-height: 1.45; white-space: pre; color: var(--text);\n margin: 0; padding: 8px 12px; }\n .tree-search { width: 100%; background: var(--panel-2); color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: inherit; outline: none; }\n .tree-search:focus { border-color: var(--accent); }\n .hier-xml-body mark.xml-match { background: #fff3b0; color: inherit; border-radius: 2px; }\n .tree-body { padding: 6px 6px 12px; }\n ul.tree, ul.tree ul { list-style: none; padding-left: 14px; margin: 0; }\n ul.tree { padding-left: 4px; }\n li.node { white-space: nowrap; }\n li.node > .label { display: inline-flex; align-items: center; gap: 4px;\n padding: 3px 6px; cursor: pointer; border-radius: 4px; user-select: none;\n max-width: 100%; }\n li.node > .label:hover { background: rgba(0,0,0,0.04); }\n li.node.selected > .label { background: var(--hl); color: var(--text);\n box-shadow: inset 2px 0 0 var(--accent); }\n li.node.match > .label { outline: 1px solid var(--warn); outline-offset: -1px; }\n .twisty { display: inline-block; width: 12px; color: var(--text-muted);\n font-size: 9px; text-align: center; }\n .twisty.empty { visibility: hidden; }\n .tag { color: var(--accent); font-family: var(--mono); font-size: 12px; }\n .ident { color: var(--warn); font-family: var(--mono); font-size: 12px; }\n .text-snippet { color: var(--success); font-family: var(--mono); font-size: 12px; }\n /* \u2500\u2500\u2500 Screen pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #screen-wrap { display: flex; justify-content: center; align-items: flex-start;\n padding: 16px; }\n #screen-host { position: relative; display: inline-block; max-width: 100%;\n border: 1px solid var(--border); border-radius: 6px; overflow: hidden;\n background: #000; box-shadow: 0 6px 22px rgba(0,0,0,0.10); }\n #screen-img { display: block; max-width: 100%; max-height: calc(100vh - 100px);\n user-select: none; -webkit-user-drag: none; }\n #highlight { position: absolute; border: 2px solid var(--accent);\n background: rgba(9,105,218,0.12); box-shadow: 0 0 0 9999px rgba(0,0,0,0.40) inset;\n pointer-events: none; transition: all 0.12s ease-out; }\n .screen-action-overlay { position: absolute; inset: 0; display: none; z-index: 5;\n align-items: center; justify-content: center; background: rgba(0,0,0,0.32); }\n .screen-action-overlay.shown { display: flex; }\n .screen-action-card { display: flex; align-items: center; gap: 10px;\n background: var(--panel); color: var(--text); border: 1px solid var(--border);\n border-radius: 8px; padding: 10px 14px; font-size: 12.5px; font-weight: 600;\n box-shadow: 0 6px 22px rgba(0,0,0,0.25); }\n .screen-action-check { display: none; color: var(--success); font-size: 16px; font-weight: 700; }\n .screen-action-overlay.done .rec-sel-spinner { display: none; }\n .screen-action-overlay.done .screen-action-check { display: inline; }\n .screen-action-overlay.done .screen-action-card { color: var(--success); }\n /* \u2500\u2500\u2500 Right pane (tabs) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .tabs { display: flex; background: var(--panel); border-bottom: 1px solid var(--border);\n flex-shrink: 0; }\n .tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);\n font-size: 12px; font-weight: 500; border-bottom: 2px solid transparent;\n transition: color 0.1s, border-color 0.1s; }\n .tab:hover { color: var(--text); }\n .tab.active { color: var(--text); border-bottom-color: var(--accent); }\n .tab-content { padding: 14px 16px; overflow: auto; flex: 1; min-height: 0; }\n .tab-content.hidden { display: none; }\n /* \u2500\u2500\u2500 Attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n table.attrs { width: 100%; border-collapse: collapse; font-size: 12px; }\n table.attrs td { padding: 5px 8px; vertical-align: top; }\n table.attrs tr:nth-child(even) { background: rgba(0,0,0,0.025); }\n table.attrs td:first-child { color: var(--text-dim); white-space: nowrap;\n width: 130px; font-family: var(--mono); }\n table.attrs td:last-child { color: var(--text); word-break: break-all; font-family: var(--mono); }\n /* \u2500\u2500\u2500 Type-into-field card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .type-card { background: var(--panel); border: 1px solid var(--accent);\n border-radius: 8px; padding: 12px; margin-bottom: 14px;\n box-shadow: 0 0 0 1px rgba(9,105,218,0.15); }\n .type-row { display: flex; gap: 6px; margin-top: 8px; }\n .type-input { flex: 1; background: var(--code-bg); color: var(--text);\n border: 1px solid var(--border); padding: 7px 11px; border-radius: 5px;\n font: 13px var(--mono); outline: none; }\n .type-input:focus { border-color: var(--accent); }\n .type-hint { font-size: 11px; color: var(--text-dim); margin-top: 6px;\n font-family: var(--mono); }\n .type-hint code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px;\n border: 1px solid var(--border); }\n /* \"Build relative xpath\" affordance + result card */\n .build-rel-btn { display: flex; align-items: center; gap: 10px;\n width: 100%; padding: 10px 12px; margin-top: 10px;\n background: var(--panel); color: var(--text);\n border: 1px dashed var(--border); border-radius: 8px;\n font: 13px inherit; cursor: pointer; text-align: left;\n transition: border-color 0.1s, background 0.1s; }\n .build-rel-btn:hover { background: var(--panel-2); border-color: var(--accent); }\n .build-rel-btn .ico { font-size: 16px; line-height: 1; }\n .build-rel-btn .body { flex: 1; min-width: 0;\n display: flex; flex-direction: column; gap: 2px; }\n .build-rel-btn .title { display: block; font-weight: 600; font-size: 13px; }\n .build-rel-btn .sub { display: block; font-size: 11.5px; color: var(--text-dim); }\n .rel-card { background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n border: 1px solid rgba(9,105,218,0.35); border-radius: 8px;\n padding: 12px; margin-bottom: 12px; }\n .rel-card .anchor-line { font-size: 11.5px; color: var(--text-dim);\n margin-bottom: 8px; }\n .rel-card .anchor-line strong { color: var(--accent); font-family: var(--mono); }\n .rel-card .rel-tip { display: flex; gap: 8px; align-items: flex-start;\n margin-top: 10px; padding: 9px 11px; border-radius: 6px;\n background: #fff8c5; border: 1px solid rgba(154,103,0,0.35);\n color: #4d3800; font-size: 11.5px; line-height: 1.45; }\n .rel-card .rel-tip .ico { flex-shrink: 0; font-size: 14px; line-height: 1.2; }\n .rel-card .rel-tip code { background: rgba(154,103,0,0.12); padding: 1px 5px;\n border-radius: 3px; font-family: var(--mono); font-size: 11px; }\n /* \u2500\u2500\u2500 Locator cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .loc-card { background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; padding: 12px; margin-bottom: 12px; }\n .loc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }\n .cat-badge { font-size: 10px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.06em; padding: 3px 8px; border-radius: 4px;\n background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); }\n .cat-badge.id { color: #0969da; border-color: rgba(9,105,218,0.35); background: #ddf4ff; }\n .cat-badge.uiautomator,\n .cat-badge.predicate { color: #6639ba; border-color: rgba(102,57,186,0.35); background: #f3e8ff; }\n .cat-badge.classChain { color: #9a6700; border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .cat-badge.xpath { color: #1a7f37; border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .cat-sub { font-size: 11px; color: var(--text-dim); }\n .badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600;\n text-transform: uppercase; letter-spacing: 0.04em; }\n .badge.unique { background: #dafbe1; color: var(--success);\n border: 1px solid rgba(26,127,55,0.35); }\n .badge.collision { background: #ffebe9; color: var(--danger);\n border: 1px solid rgba(207,34,46,0.35); }\n .badge.empty { background: var(--panel); color: var(--text-muted);\n border: 1px solid var(--border); }\n .badge.positional { background: #fff4e0; color: #9a6700;\n border: 1px solid rgba(154,103,0,0.40); }\n .badge.recommended { background: #fff8c5; color: #7a5c00;\n border: 1px solid rgba(154,103,0,0.45); font-weight: 700; }\n .loc-card.is-rec { border-color: rgba(154,103,0,0.55);\n box-shadow: 0 0 0 1px rgba(154,103,0,0.25) inset; }\n .loc-spacer { flex: 1; }\n .loc-code { font-family: var(--mono); font-size: 12.5px; background: var(--code-bg);\n padding: 9px 11px; border-radius: 5px; word-break: break-all; line-height: 1.5;\n color: var(--text); border: 1px solid var(--border); }\n .loc-actions { display: flex; gap: 6px; margin-top: 9px; }\n .loc-actions button { flex-shrink: 0; }\n /* \u2500\u2500\u2500 Record tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* Recording toggle banner \u2014 top of the Record tab. */\n .rec-toggle { display: flex; align-items: center; gap: 12px;\n padding: 10px 14px; border-radius: 10px; margin-bottom: 14px;\n border: 1px solid var(--border); background: var(--panel);\n transition: background 0.15s, border-color 0.15s; }\n .rec-toggle.live { border-color: rgba(207,34,46,0.4);\n background: linear-gradient(180deg, #fff5f5 0%, var(--panel) 100%); }\n .rec-toggle .rec-led { width: 10px; height: 10px; border-radius: 50%;\n background: var(--text-muted); flex-shrink: 0; }\n .rec-toggle.live .rec-led { background: var(--danger);\n animation: rec-led-pulse 1.4s ease-in-out infinite; }\n @keyframes rec-led-pulse {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(207,34,46,0.55); transform: scale(1); }\n 70% { box-shadow: 0 0 0 8px rgba(207,34,46,0); transform: scale(0.92); }\n }\n .rec-toggle .rec-status { flex: 1; font-size: 12.5px; color: var(--text-dim);\n line-height: 1.4; min-width: 0; }\n .rec-toggle .rec-status strong { color: var(--text); font-weight: 600; }\n .rec-toggle.live .rec-status strong { color: var(--danger); }\n .btn-rec-toggle { background: var(--danger); color: white; border: none;\n padding: 9px 16px; border-radius: 6px; font: 600 13px inherit;\n cursor: pointer; display: inline-flex; align-items: center; gap: 8px;\n transition: background 0.1s, transform 0.05s; flex-shrink: 0; }\n .btn-rec-toggle:hover { background: #a40e1c; }\n .btn-rec-toggle:active { transform: translateY(0.5px); }\n .btn-rec-toggle.stop { background: #1f2328; }\n .btn-rec-toggle.stop:hover { background: #0d1117; }\n .btn-rec-toggle .rec-ico { width: 12px; height: 12px; background: white;\n border-radius: 50%; flex-shrink: 0; }\n .btn-rec-toggle.stop .rec-ico { border-radius: 2px; }\n /* Selected element card \u2014 sticky context block at the top of the tab. */\n .rec-selected { display: flex; gap: 12px; align-items: flex-start;\n padding: 12px 14px; border-radius: 10px; margin-bottom: 16px;\n border: 1px solid var(--border); background: var(--panel);\n transition: background 0.15s, border-color 0.15s; }\n .rec-selected.has { border-color: rgba(9,105,218,0.35);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%); }\n .rec-sel-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px;\n background: var(--panel-2); color: var(--text-muted); font-size: 16px;\n display: flex; align-items: center; justify-content: center;\n border: 1px solid var(--border); }\n .rec-selected.has .rec-sel-icon { background: var(--accent); color: white;\n border-color: var(--accent); }\n .rec-sel-body { flex: 1; min-width: 0; }\n .rec-sel-title { font-weight: 600; font-size: 13.5px; color: var(--text);\n line-height: 1.3; }\n .rec-sel-sub { font-size: 11.5px; color: var(--text-dim); margin-top: 4px;\n font-family: var(--mono); word-break: break-all;\n background: var(--code-bg); padding: 4px 8px; border-radius: 4px;\n border: 1px solid var(--border); display: inline-block; max-width: 100%; }\n .rec-selected:not(.has) .rec-sel-sub { background: transparent; border: none;\n padding: 0; font-family: inherit; color: var(--text-muted);\n display: block; width: 100%; }\n .rec-no-unique { color: var(--warn); font-size: 12px; line-height: 1.45;\n margin-bottom: 8px; }\n .rec-sel-spinner { display: inline-block; width: 16px; height: 16px;\n border: 2px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .rec-resolving-hint { display: block; margin-top: 4px; font-size: 11px;\n color: var(--text-muted); line-height: 1.4; }\n .empty-state .rec-sel-spinner { width: 13px; height: 13px; border-width: 1.5px;\n vertical-align: -2px; margin-right: 6px; }\n\n /* Action groups */\n .rec-group { margin-bottom: 18px; }\n .rec-group-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }\n .rec-group-title .grow { flex: 1; }\n .rec-subtitle { font-size: 11px; color: var(--text-muted); margin: 14px 0 8px;\n font-weight: 500; letter-spacing: 0.02em; }\n .rec-subtitle:first-child { margin-top: 0; }\n\n /* Action buttons */\n .rec-grid { display: grid; gap: 7px;\n grid-template-columns: repeat(auto-fit, minmax(115px, 1fr)); }\n .rec-grid.cols-2 { grid-template-columns: repeat(2, 1fr); }\n .rec-act { background: var(--panel); color: var(--text);\n border: 1px solid var(--border); padding: 9px 11px; border-radius: 6px;\n font: 13px inherit; cursor: pointer;\n transition: background 0.1s, border-color 0.1s, transform 0.05s, box-shadow 0.1s;\n display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n white-space: nowrap; min-width: 0; }\n .rec-act .ico { font-size: 14px; line-height: 1; }\n .rec-act:hover:not(:disabled) { background: var(--panel-2);\n border-color: var(--border-strong);\n box-shadow: 0 1px 3px rgba(0,0,0,0.04); }\n .rec-act:active:not(:disabled) { transform: translateY(0.5px); box-shadow: none; }\n .rec-act:disabled { opacity: 0.4; cursor: not-allowed; }\n .rec-act.primary { background: var(--accent); color: white;\n border-color: var(--accent); font-weight: 600; padding: 11px 14px; }\n .rec-act.primary:hover:not(:disabled) { background: var(--accent-hover);\n border-color: var(--accent-hover); }\n .rec-act.primary:disabled { background: var(--accent); opacity: 0.35; }\n\n /* Y/X-range row for custom screen-scroll */\n .rec-y-range { display: flex; flex-direction: column; gap: 8px;\n margin-top: 8px; padding: 8px 10px; border-radius: 5px;\n border: 1px dashed var(--border); background: var(--panel-2); }\n .rec-y-range-label { display: flex; gap: 8px; align-items: baseline;\n font-size: 11px; color: var(--text-dim); flex-wrap: wrap; }\n .rec-y-range-defaults { color: var(--text-muted); font-size: 10.5px;\n font-family: var(--mono); }\n .rec-y-range-fields { display: flex; align-items: center; gap: 12px;\n flex-wrap: wrap; }\n .rec-y-cell { display: inline-flex; align-items: center; gap: 4px;\n color: var(--text-dim); font-size: 12px; }\n .rec-y-cell input { width: 48px; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 4px 6px; border-radius: 4px;\n font: 12px var(--mono); outline: none; text-align: right; }\n .rec-y-cell input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n /* Text-input row */\n .rec-input-row { display: flex; gap: 6px; }\n .rec-input { flex: 1; min-width: 0; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 8px 12px; border-radius: 6px;\n font: 13px var(--mono); outline: none; }\n .rec-input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .rec-input:disabled { background: var(--panel); color: var(--text-muted); }\n .rec-input-row .rec-act { padding: 7px 12px; flex-shrink: 0; }\n\n /* Assertion row inputs (text/value) */\n .rec-assert-row { display: flex; gap: 6px; margin-top: 6px; align-items: center; }\n .rec-assert-row input { flex: 1; min-width: 0; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 7px 11px; border-radius: 6px;\n font: 12.5px var(--mono); outline: none; }\n .rec-assert-row input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .rec-assert-row input:disabled { background: var(--panel); color: var(--text-muted); }\n /* Record subtabs (Actions / Screen / Assertions) */\n .rec-subtabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;\n padding: 3px; margin-bottom: 14px; background: var(--panel-2);\n border: 1px solid var(--border); border-radius: 8px; }\n .rec-subtab { border: none; background: transparent; color: var(--text-dim);\n font: 12px inherit; font-weight: 600; padding: 7px 10px; border-radius: 6px;\n cursor: pointer; }\n .rec-subtab:hover { color: var(--text); }\n .rec-subtab.active { background: var(--panel); color: var(--text);\n box-shadow: 0 1px 2px rgba(0,0,0,0.06); }\n .rec-pane.hidden { display: none; }\n /* Pick-target hint banner */\n .rec-pickhint { display: flex; align-items: center; gap: 8px;\n background: #fff8c5; color: var(--warn); border: 1px solid rgba(154,103,0,0.35);\n padding: 9px 13px; border-radius: 6px; font-size: 12.5px; margin-bottom: 14px; }\n .rec-pickhint .pulse { width: 8px; height: 8px; border-radius: 50%;\n background: var(--warn); flex-shrink: 0;\n animation: rec-pulse 1.2s ease-in-out infinite; }\n @keyframes rec-pulse {\n 0%, 100% { opacity: 1; transform: scale(1); }\n 50% { opacity: 0.55; transform: scale(0.8); }\n }\n .rec-pickhint button { margin-left: auto; }\n\n /* Recorded script */\n .lang-seg { display: inline-flex; gap: 2px; margin-right: 6px; }\n .lang-seg button { padding: 3px 8px; font-size: 11px; }\n .lang-seg button.active { background: var(--accent); color: #fff; border-color: var(--accent); }\n .rec-lang-note { font-size: 11px; color: var(--text-dim); margin: 2px 2px 8px; }\n .rec-script-card { background: var(--code-bg); border: 1px solid var(--border);\n border-radius: 8px; padding: 0; overflow: hidden; }\n .rec-script-card pre { background: transparent; padding: 12px 14px;\n font-family: var(--mono); font-size: 12.5px; line-height: 1.6;\n white-space: pre-wrap; word-break: normal; overflow-wrap: anywhere; color: var(--text);\n margin: 0; max-height: 320px; overflow: auto; }\n .rec-script-card pre:empty::before { content: \"// no actions yet \u2014 start recording and interact with the device\";\n color: var(--text-muted); font-style: italic; }\n /* Syntax-highlight tokens (GitHub light theme palette). */\n .tok-kw { color: #cf222e; }\n .tok-str { color: #0a3069; }\n .tok-num { color: #0550ae; }\n .tok-cmt { color: #6e7781; font-style: italic; }\n .tok-fn { color: #8250df; }\n .tok-id { color: #1f2328; }\n .tok-pun { color: #57606a; }\n\n /* Pick-mode cursor on the screen host */\n #screen-host.pick-mode { cursor: crosshair;\n outline: 2px dashed var(--warn); outline-offset: -2px; }\n /* \u2500\u2500\u2500 Empty states \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .empty-state { padding: 40px 16px; text-align: center; color: var(--text-muted); }\n .empty-state svg { opacity: 0.4; margin-bottom: 12px; }\n /* \u2500\u2500\u2500 Loader overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .loader-overlay { position: fixed; inset: 0; z-index: 1100;\n background: rgba(255, 255, 255, 0.92);\n backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);\n display: none; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px; opacity: 0; transition: opacity 0.18s ease-out; }\n .loader-overlay.shown { display: flex; opacity: 1; }\n .loader-spinner { width: 42px; height: 42px;\n border: 3px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.85s linear infinite; }\n @keyframes loader-spin { to { transform: rotate(360deg); } }\n .loader-message { color: var(--text); font-size: 14px; font-weight: 600;\n margin-top: 4px; }\n .loader-sub { color: var(--text-dim); font-size: 12.5px;\n max-width: 380px; text-align: center; line-height: 1.45; }\n #loader-cancel { display: none; margin-top: 6px; background: var(--panel-2);\n color: var(--text); border: 1px solid var(--border); border-radius: 6px;\n padding: 7px 16px; font-size: 12.5px; font-weight: 600; cursor: pointer; }\n #loader-cancel:hover { background: var(--border); }\n #loader-cancel.shown { display: inline-block; }\n /* \u2500\u2500\u2500 Toast notifications \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #toasts { position: fixed; top: 60px; right: 16px; z-index: 1000;\n display: flex; flex-direction: column; gap: 8px;\n max-width: 420px; min-width: 280px; pointer-events: none; }\n .toast { background: var(--panel); border: 1px solid var(--border);\n border-left: 4px solid var(--accent); padding: 10px 12px 10px 14px;\n border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.10);\n display: flex; align-items: flex-start; gap: 10px;\n pointer-events: auto; animation: toast-in 0.18s ease-out;\n font-size: 12.5px; line-height: 1.45; }\n .toast.error { border-left-color: var(--danger); }\n .toast.success { border-left-color: var(--success); }\n .toast.info { border-left-color: var(--accent); }\n .toast .title { color: var(--text); font-weight: 600; margin-bottom: 2px; }\n .toast.error .title { color: var(--danger); }\n .toast.success .title { color: var(--success); }\n .toast .body { flex: 1; color: var(--text); word-break: break-word; min-width: 0; }\n .toast .body .msg { color: var(--text-dim); }\n .toast .close { background: transparent; border: none; color: var(--text-muted);\n cursor: pointer; padding: 0; font-size: 16px; line-height: 1;\n flex-shrink: 0; margin-top: 1px; }\n .toast .close:hover { color: var(--text); }\n @keyframes toast-in {\n from { transform: translateX(20px); opacity: 0; }\n to { transform: translateX(0); opacity: 1; }\n }\n .toast.fading { animation: toast-out 0.18s ease-in forwards; }\n @keyframes toast-out {\n to { transform: translateX(20px); opacity: 0; }\n }\n /* \u2500\u2500\u2500 Confirm modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #modal-overlay { position: fixed; inset: 0; z-index: 2000;\n background: rgba(27,31,36,0.45); backdrop-filter: blur(2px);\n display: none; align-items: center; justify-content: center; padding: 20px;\n animation: modal-fade 0.12s ease-out; }\n #modal-overlay.open { display: flex; }\n .modal-card { background: var(--bg); border: 1px solid var(--border);\n border-radius: 12px; box-shadow: 0 16px 48px rgba(0,0,0,0.24);\n width: 100%; max-width: 420px; overflow: hidden;\n animation: modal-pop 0.14s cubic-bezier(0.2,0.9,0.3,1.1); }\n .modal-body { padding: 22px 22px 18px; display: flex; gap: 14px; align-items: flex-start; }\n .modal-icon { flex-shrink: 0; width: 38px; height: 38px; border-radius: 50%;\n display: inline-flex; align-items: center; justify-content: center;\n font-size: 20px; line-height: 1;\n background: rgba(207,34,46,0.12); color: var(--danger); }\n .modal-text { flex: 1; min-width: 0; }\n .modal-title { font-size: 15px; font-weight: 600; color: var(--text);\n margin: 1px 0 6px; }\n .modal-msg { font-size: 13px; color: var(--text-dim); line-height: 1.5; }\n .modal-actions { display: flex; justify-content: flex-end; gap: 8px;\n padding: 0 22px 18px; }\n .modal-btn { padding: 8px 16px; border-radius: 7px; font: inherit;\n font-size: 13px; font-weight: 500; cursor: pointer;\n border: 1px solid var(--border); background: var(--panel-2);\n color: var(--text); transition: background 0.1s, border-color 0.1s; }\n .modal-btn:hover { background: var(--border); }\n .modal-btn.confirm { background: var(--danger); border-color: var(--danger);\n color: #fff; }\n .modal-btn.confirm:hover { background: #b81c28; border-color: #b81c28; }\n .modal-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n @keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }\n @keyframes modal-pop {\n from { transform: translateY(8px) scale(0.97); opacity: 0; }\n to { transform: translateY(0) scale(1); opacity: 1; }\n }\n /* \u2500\u2500\u2500 Status bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #status { position: fixed; bottom: 8px; left: 12px; background: var(--panel);\n border: 1px solid var(--border); padding: 4px 10px; border-radius: 4px;\n font-size: 11px; color: var(--text-dim); font-family: var(--mono);\n transition: opacity 0.3s; z-index: 10; }\n #status.busy { color: var(--accent); }\n /* Hide the status pill on the setup view \u2014 would overlap the action-bar\n Connect button at the bottom of the viewport. The action bar's own\n \"Connecting\u2026\" label + toasts are enough. */\n body.view-setup #status { display: none; }\n /* \u2500\u2500\u2500 Scrollbars \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n ::-webkit-scrollbar { width: 10px; height: 10px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 5px;\n border: 2px solid var(--bg); }\n ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }\n /* \u2500\u2500\u2500 Guided tour (spotlight coach-marks) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #tour-overlay { position: fixed; inset: 0; z-index: 1000; display: none; }\n #tour-overlay.show { display: block; }\n /* Click-catcher so the tour is modal \u2014 the app stays put behind the dimmer. */\n #tour-catcher { position: absolute; inset: 0; }\n #tour-spotlight { position: absolute; border-radius: 8px; pointer-events: none;\n box-shadow: 0 0 0 9999px rgba(8,12,20,0.55), 0 0 0 2px var(--accent),\n 0 0 0 6px rgba(31,111,235,0.35); transition: all 0.18s ease; }\n #tour-pop { position: absolute; width: 320px; max-width: calc(100vw - 24px);\n background: var(--panel); color: var(--text); border: 1px solid var(--border-strong);\n border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,0.35); padding: 14px 16px;\n font-size: 13px; line-height: 1.5; }\n #tour-pop h3 { margin: 0 0 6px; font-size: 14px; }\n #tour-pop .tour-body { color: var(--text-dim); }\n #tour-pop .tour-body b { color: var(--text); }\n #tour-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; }\n #tour-progress { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }\n #tour-foot .grow { flex: 1; }\n #tour-skip { position: absolute; top: 8px; right: 10px; background: none; border: none;\n color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px; }\n #tour-skip:hover { color: var(--text); }\n /* \u2500\u2500\u2500 Help reference panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #help-overlay { position: fixed; inset: 0; z-index: 1100; display: none;\n background: rgba(8,12,20,0.5); }\n #help-overlay.show { display: flex; align-items: center; justify-content: center; }\n #help-panel { width: 720px; max-width: calc(100vw - 32px); max-height: calc(100vh - 64px);\n overflow: auto; background: var(--panel); border: 1px solid var(--border-strong);\n border-radius: 12px; box-shadow: 0 18px 60px rgba(0,0,0,0.4); padding: 20px 22px; }\n #help-panel .help-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }\n #help-panel .help-head h2 { margin: 0; font-size: 17px; }\n #help-panel .help-head .grow { flex: 1; }\n #help-panel .help-lead { color: var(--text-dim); font-size: 13px; margin: 0 0 14px; }\n #help-close { background: none; border: none; color: var(--text-muted); cursor: pointer;\n font-size: 20px; line-height: 1; padding: 2px 6px; }\n #help-close:hover { color: var(--text); }\n #help-panel details { border: 1px solid var(--border); border-radius: 8px;\n margin-bottom: 8px; background: var(--panel-2); overflow: hidden; }\n #help-panel summary { cursor: pointer; padding: 10px 12px; font-weight: 600; font-size: 13px;\n list-style: none; user-select: none; }\n #help-panel summary::-webkit-details-marker { display: none; }\n #help-panel summary::before { content: \"\u25B8 \"; color: var(--text-muted); }\n #help-panel details[open] summary::before { content: \"\u25BE \"; }\n #help-panel .help-sec { padding: 0 14px 12px 26px; color: var(--text-dim); font-size: 13px;\n line-height: 1.6; }\n #help-panel .help-sec b { color: var(--text); }\n #help-panel .help-sec code { font-family: var(--mono); font-size: 12px;\n background: var(--panel); border: 1px solid var(--border); border-radius: 4px;\n padding: 0 4px; }\n #help-panel .help-sec ul,\n #help-panel .help-sec ol { margin: 4px 0; padding-left: 18px; }\n #help-panel .help-sec li { margin: 3px 0; }\n /* \u2500\u2500\u2500 Screen \"how to use\" hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .screen-help-btn { font-size: 11px; color: var(--text-dim); cursor: pointer;\n border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px;\n background: var(--panel-2); white-space: nowrap; }\n .screen-help-btn:hover { color: var(--text); border-color: var(--accent); }\n #screen-wrap { position: relative; }\n .screen-help-pop { display: none; position: absolute; top: 10px; left: 50%;\n transform: translateX(-50%); z-index: 20; width: 340px; max-width: calc(100% - 20px);\n background: var(--panel); color: var(--text); border: 1px solid var(--border-strong);\n border-radius: 10px; box-shadow: 0 10px 32px rgba(0,0,0,0.32); padding: 12px 14px;\n font-size: 12.5px; line-height: 1.5; }\n .screen-help-pop.show { display: block; }\n .screen-help-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; }\n .screen-help-pop ul { margin: 0 0 10px; padding-left: 18px; color: var(--text-dim); }\n .screen-help-pop ul b { color: var(--text); }\n .screen-help-pop li { margin: 3px 0; }\n .screen-help-x { position: absolute; top: 6px; right: 8px; background: none; border: none;\n color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px; }\n .screen-help-x:hover { color: var(--text); }\n .screen-help-ok { padding: 4px 12px; }\n /* \u2500\u2500\u2500 Demo stage (illustrated inspector for the tour) \u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #demo-stage { display: none; position: fixed; inset: 0; z-index: 900;\n background: var(--bg); flex-direction: column; padding: 12px 16px 16px; }\n #demo-stage.show { display: flex; }\n .demo-bar { display: flex; align-items: center; gap: 10px; padding: 4px 2px 12px; }\n .demo-badge { font-size: 10px; font-weight: 800; letter-spacing: 0.06em; color: white;\n background: var(--accent); border-radius: 4px; padding: 2px 6px; }\n .demo-bar-title { font-size: 13px; color: var(--text-dim); }\n .demo-bar .grow { flex: 1; }\n .demo-panes { flex: 1; display: grid; grid-template-columns: 1fr 1.1fr 1.2fr; gap: 12px;\n min-height: 0; }\n .demo-pane { display: flex; flex-direction: column; min-height: 0;\n border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--panel); }\n .demo-pane .pane-body { flex: 1; overflow: auto; padding: 8px 10px; }\n .demo-seg { display: inline-flex; gap: 2px; background: var(--panel-2); border-radius: 6px;\n padding: 2px; font-size: 11px; }\n .demo-seg span { padding: 1px 8px; border-radius: 4px; color: var(--text-dim); }\n .demo-seg span.on { background: var(--panel); color: var(--text); font-weight: 600; }\n .demo-seg.sm { font-size: 10.5px; width: 100%; }\n .demo-seg.sm .grow { flex: 1; }\n .demo-search { font-size: 11px; color: var(--text-muted); border: 1px solid var(--border);\n border-radius: 6px; padding: 2px 8px; background: var(--panel-2); }\n .demo-meta { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }\n .demo-tree { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px;\n color: var(--text-dim); }\n .demo-tree li { padding: 2px 4px; border-radius: 4px; white-space: nowrap; }\n .demo-tree li.i1 { padding-left: 16px; }\n .demo-tree li.i2 { padding-left: 30px; }\n .demo-tree li.sel { background: rgba(31,111,235,0.14); color: var(--text); }\n .demo-id { color: #6639ba; }\n .demo-q { color: var(--success); }\n .demo-screen-body { display: flex; align-items: flex-start; justify-content: center; }\n .demo-phone { width: 220px; border: 8px solid #111723; border-radius: 26px; overflow: hidden;\n background: #fbf1ee; box-shadow: 0 8px 24px rgba(0,0,0,0.25); }\n .demo-statusbar { display: flex; justify-content: space-between; font-size: 9px; color: #3a3a3a;\n padding: 4px 12px; background: #fbf1ee; }\n .demo-app { padding: 22px 18px 22px; display: flex; flex-direction: column; align-items: center;\n gap: 10px; background: #fbf1ee; min-height: 340px; }\n .demo-app-logo { width: 56px; height: 56px; border-radius: 14px; background: #f5333b;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0;\n margin-top: 6px; }\n .demo-logo-glass { font-size: 20px; line-height: 1; }\n .demo-logo-name { font-size: 9px; font-weight: 800; color: #fff; }\n .demo-app-title { font-size: 19px; font-weight: 800; color: #1b1320; margin: 2px 0 0; }\n .demo-app-sub { font-size: 11px; color: #9b8f93; margin-bottom: 6px; }\n .demo-field { width: 100%; height: 36px; border: 1px solid #e3d7d6; border-radius: 10px;\n background: #fdf8f7; display: flex; align-items: center; gap: 8px; padding: 0 10px; }\n .demo-field.sel { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,111,235,0.30); }\n .demo-field-ico { font-size: 13px; opacity: 0.7; }\n .demo-field-ph { font-size: 12px; color: #9b8f93; }\n .demo-field-eye { margin-left: auto; font-size: 12px; opacity: 0.55; }\n .demo-app-btn { width: 100%; height: 40px; border: none; border-radius: 10px; color: #fff;\n background: #a0185a; font-size: 14px; font-weight: 700; margin-top: 6px; }\n .demo-creds { width: 100%; margin-top: 8px; padding: 10px 12px; border-radius: 12px;\n background: #f6e7ea; text-align: center; font-size: 10.5px; color: #5a4a4f; line-height: 1.5; }\n .demo-creds-title { font-weight: 800; color: #a0185a; margin-bottom: 2px; font-size: 11px; }\n .demo-tabwrap { flex: 1; overflow: auto; padding: 10px 12px; }\n .demo-tabpane.hidden { display: none; }\n .demo-rec-banner { font-size: 12px; color: var(--text-dim); display: flex; align-items: center;\n gap: 6px; margin-bottom: 10px; }\n .demo-rec-banner b { color: var(--text); }\n .demo-led { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); }\n .demo-rec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }\n .demo-act { border: 1px solid var(--border); border-radius: 7px; padding: 7px 8px; font-size: 12px;\n color: var(--text-dim); background: var(--panel-2); }\n .demo-act.primary { grid-column: 1 / -1; border-color: var(--accent); color: var(--text);\n background: rgba(31,111,235,0.10); font-weight: 600; }\n .demo-subtabs { margin-top: 10px; font-size: 11px; color: var(--text-muted); }\n .demo-code { font-family: var(--mono); font-size: 11.5px; line-height: 1.5; color: var(--text);\n background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px;\n white-space: pre-wrap; margin: 8px 0 0; }\n .demo-loc-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px; font-size: 12px;\n border-bottom: 1px solid var(--border); }\n .demo-loc-row code { font-family: var(--mono); font-size: 11px; color: var(--text-dim);\n overflow-wrap: anywhere; }\n .demo-cat { font-size: 10px; font-weight: 700; border: 1px solid var(--border); border-radius: 999px;\n padding: 1px 7px; color: var(--text-dim); white-space: nowrap; }\n .demo-cat.id { color: var(--success); border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .demo-cat.uiautomator { color: #6639ba; border-color: rgba(102,57,186,0.35); background: #f3e8ff; }\n .demo-cat.xpath { color: var(--text-muted); }\n .demo-pick { margin-left: auto; font-size: 10px; color: var(--accent); font-weight: 600; }\n .demo-attrs { width: 100%; border-collapse: collapse; font-size: 11.5px; }\n .demo-attrs td { border-bottom: 1px solid var(--border); padding: 4px 6px; vertical-align: top; }\n .demo-attrs td:first-child { color: var(--text-muted); font-family: var(--mono); width: 38%; }\n</style>\n</head>\n<body class=\"view-setup\">\n<header>\n <img class=\"logo\" src=\"/static/logo.png\" alt=\"taqwright\" />\n <h1><span class=\"brand\">taqwright</span> codegen</h1>\n <span class=\"dot\">\u00B7</span>\n <span class=\"meta\" id=\"session-meta\">setup</span>\n <span class=\"spacer\"></span>\n <a class=\"header-ad\" href=\"https://www.taqwright.ai/\" target=\"_blank\" rel=\"noopener noreferrer\"\n title=\"taqwright \u2014 In-sprint mobile UI automation, on autopilot\">\n <span class=\"header-ad-text\">In-sprint mobile UI automation, on autopilot.</span>\n <span class=\"header-ad-arrow\" aria-hidden=\"true\">\u2197</span>\n </a>\n <button class=\"icon\" id=\"btn-help\" title=\"Help & guided tour\">? Help</button>\n <button class=\"icon danger inspector-only\" id=\"btn-disconnect\" title=\"End the WebDriver session and return to setup\">Disconnect</button>\n <button class=\"primary attached-only\" id=\"btn-resume\" title=\"Resume the paused test and close this inspector\" style=\"display:none\">Resume \u25B6</button>\n</header>\n\n<!-- \u2500\u2500\u2500 Setup landing view (3-step wizard) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"setup\" class=\"setup-only\">\n <!-- Stepper -->\n <div class=\"wizard-stepper\" role=\"tablist\">\n <div class=\"wizard-step-pill active\" data-step=\"1\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">1</span></span>\n <span class=\"label\">Prerequisites</span>\n </div>\n <span class=\"wizard-line\"></span>\n <div class=\"wizard-step-pill\" data-step=\"2\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">2</span></span>\n <span class=\"label\">Select device</span>\n </div>\n <span class=\"wizard-line\"></span>\n <div class=\"wizard-step-pill\" data-step=\"3\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">3</span></span>\n <span class=\"label\">Configure & connect</span>\n </div>\n </div>\n\n <div class=\"wizard-content\">\n <!-- \u2500\u2500\u2500 Step 1: connection mode + prereqs / cloud auth \u2500\u2500\u2500 -->\n <div class=\"wizard-page active\" data-page=\"1\">\n <div class=\"wizard-page-head\">\n <h2>Check prerequisites</h2>\n <p id=\"step1-intro\">Confirming the CLIs you need (adb, xcrun, Java) are installed and that the Appium server is reachable. If the Appium pill is grey, click <strong>Start Appium</strong> \u2014 <strong>Next</strong> unlocks once it turns green.</p>\n </div>\n\n <!-- Connection mode -->\n <div class=\"conn-mode-card\">\n <div class=\"conn-mode-label\">Where will the device run?</div>\n <div class=\"conn-mode-toggle\" role=\"tablist\">\n <button class=\"conn-mode-btn active\" data-conn-mode=\"local\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\uD83D\uDDA5</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">Local</span>\n <span class=\"conn-mode-sub\">Emulators & simulators on this machine</span>\n </span>\n </button>\n <button class=\"conn-mode-btn\" data-conn-mode=\"browserstack\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\u2601</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">BrowserStack</span>\n <span class=\"conn-mode-sub\">App Automate cloud devices</span>\n </span>\n </button>\n <button class=\"conn-mode-btn\" data-conn-mode=\"lambdatest\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\u2601</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">LambdaTest</span>\n <span class=\"conn-mode-sub\">Real-device cloud</span>\n </span>\n </button>\n </div>\n </div>\n\n <!-- Local prereqs (env + appium) -->\n <div id=\"step1-local-block\">\n <div class=\"prereq-progress\" id=\"prereq-progress\"></div>\n <div class=\"prereq-grid\">\n <div class=\"card card-env\">\n <div class=\"card-head\">\n <h2>Environment</h2>\n <span class=\"grow\"></span>\n </div>\n <div class=\"doctor-summary\" id=\"doctor-summary\">\n <span id=\"doctor-summary-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"doctor-summary-label\">checking\u2026</span></span>\n <span class=\"grow\"></span>\n <span class=\"twisty\" id=\"doctor-twisty\">\u25BE</span>\n </div>\n <ul class=\"doctor-list\" id=\"doctor-list\"></ul>\n </div>\n <div class=\"card card-appium\">\n <div class=\"card-head\">\n <h2>Appium server</h2>\n <span class=\"grow\"></span>\n <span id=\"appium-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"appium-pill-label\">checking\u2026</span></span>\n </div>\n <div class=\"field-tri\">\n <label for=\"appium-host\">host</label>\n <input id=\"appium-host\" />\n <label for=\"appium-port\" style=\"text-align:right\">port</label>\n <input id=\"appium-port\" />\n </div>\n <div class=\"field\">\n <label for=\"appium-path\">path</label>\n <input id=\"appium-path\" />\n </div>\n <div class=\"btn-row\" style=\"margin-top:8px\">\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"btn-appium-recheck\">Recheck</button>\n <button class=\"icon\" id=\"btn-appium-restart\">Restart Appium</button>\n <button class=\"icon\" id=\"btn-appium-start\">Start Appium</button>\n </div>\n <div id=\"appium-start-hint\" class=\"appium-hint\" style=\"display:none\">First start can take up to a minute while the UiAutomator2 / XCUITest drivers load.</div>\n </div>\n </div>\n </div>\n\n <!-- Cloud creds card (BrowserStack / LambdaTest) -->\n <div id=\"step1-cloud-block\" style=\"display:none\">\n <div class=\"card\">\n <div class=\"card-head\">\n <h2 id=\"cloud-creds-title\">Cloud credentials</h2>\n <span class=\"grow\"></span>\n <span id=\"cloud-creds-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"cloud-creds-pill-label\">awaiting\u2026</span></span>\n </div>\n <div class=\"field\">\n <label for=\"cloud-user\">Username</label>\n <input id=\"cloud-user\" placeholder=\"username\" autocomplete=\"off\" />\n </div>\n <div class=\"field\">\n <label for=\"cloud-key\">Access key</label>\n <input id=\"cloud-key\" type=\"password\" placeholder=\"access key\" autocomplete=\"off\" />\n </div>\n <div id=\"cloud-creds-hint\" class=\"info-banner\" style=\"margin-top:10px\"></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Step 2: select device \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"wizard-page\" data-page=\"2\">\n <div class=\"wizard-page-head\">\n <h2>Pick a device</h2>\n <p>Boot an emulator or simulator below, then <strong>tap a running device</strong> to select it (Android or iOS \u2014 only your last tap counts). Click <strong>Next</strong> when ready.</p>\n </div>\n <div class=\"card card-devices\">\n <div class=\"card-head\">\n <h2>Devices</h2>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"btn-devices-refresh\" type=\"button\">\u21BB Refresh</button>\n </div>\n <div id=\"devices-warn\"></div>\n <div class=\"device-tabs\" role=\"tablist\">\n <button class=\"device-tab active\" data-device-tab=\"android\" type=\"button\">\n <span>Android</span><span class=\"count\" id=\"device-count-android\">0</span>\n </button>\n <button class=\"device-tab\" data-device-tab=\"ios\" type=\"button\">\n <span>iOS</span><span class=\"count\" id=\"device-count-ios\">0</span>\n </button>\n </div>\n <div class=\"device-grid\" id=\"device-grid\"></div>\n <div class=\"device-pagination\" id=\"device-pagination\"></div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Step 3: capabilities + connect \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"wizard-page\" data-page=\"3\">\n <div class=\"wizard-page-head\">\n <h2>Configure capabilities & connect</h2>\n <p>The device you picked already filled most of these. Optionally browse for an <strong>.apk</strong>, <strong>.ipa</strong>, <strong>.app</strong>, or <strong>.app.zip</strong> to install \u2014 its package / bundle ID will populate automatically.</p>\n </div>\n <div class=\"card card-caps flex\">\n <div class=\"card-head\">\n <h2>Capabilities</h2>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"btn-caps-reset\" title=\"Reset to defaults from taqwright.config.ts\">\u21BA Reset</button>\n </div>\n <div class=\"caps-fields\">\n <div class=\"field\">\n <label for=\"cap-platform\">Platform</label>\n <select id=\"cap-platform\">\n <option value=\"Android\">Android \u00B7 UiAutomator2</option>\n <option value=\"iOS\">iOS \u00B7 XCUITest</option>\n </select>\n </div>\n <div class=\"field\">\n <label for=\"cap-device\">Device</label>\n <input id=\"cap-device\" placeholder=\"emulator-5554, Pixel 6, iPhone 15\u2026\" />\n </div>\n <div class=\"field\">\n <label for=\"cap-version\">OS version</label>\n <input id=\"cap-version\" placeholder=\"optional \u00B7 e.g. 14, 17.0\" />\n </div>\n <div class=\"app-browse-row\">\n <label for=\"cap-app\">App</label>\n <input id=\"cap-app\" placeholder=\"optional \u00B7 path to .apk / .ipa / .app / .app.zip\" />\n <button class=\"icon browse-btn\" id=\"btn-app-browse\" type=\"button\" title=\"Pick a file with the system file dialog\">Browse\u2026</button>\n </div>\n <div class=\"app-inspect-status\" id=\"app-inspect-status\"></div>\n <div class=\"field\">\n <label for=\"cap-bundle\"><span id=\"cap-bundle-label\">Package</span></label>\n <input id=\"cap-bundle\" placeholder=\"optional \u00B7 com.example.app\" />\n </div>\n <div class=\"field\">\n <label for=\"cap-udid\">UDID</label>\n <input id=\"cap-udid\" placeholder=\"optional \u00B7 device serial\" />\n </div>\n <label class=\"checkbox-row\" for=\"cap-noreset\">\n <input type=\"checkbox\" id=\"cap-noreset\" checked />\n <span class=\"label\">noReset</span>\n <span class=\"hint\">don't reinstall the app between sessions</span>\n </label>\n <div class=\"extras-head\">\n <span>Extra capabilities</span>\n <span style=\"flex:1\"></span>\n </div>\n <div class=\"extras-list\" id=\"extras-list\"></div>\n <button class=\"add-cap-btn\" id=\"btn-add-cap\" type=\"button\">\n <span class=\"plus\">+</span><span>Add capability</span>\n </button>\n </div>\n <datalist id=\"known-caps\">\n <option value=\"appium:autoGrantPermissions\">\n <option value=\"appium:autoAcceptAlerts\">\n <option value=\"appium:autoDismissAlerts\">\n <option value=\"appium:fullReset\">\n <option value=\"appium:enforceAppInstall\">\n <option value=\"appium:dontStopAppOnReset\">\n <option value=\"appium:skipServerInstallation\">\n <option value=\"appium:skipDeviceInitialization\">\n <option value=\"appium:appActivity\">\n <option value=\"appium:appWaitActivity\">\n <option value=\"appium:appWaitPackage\">\n <option value=\"appium:appWaitDuration\">\n <option value=\"appium:newCommandTimeout\">\n <option value=\"appium:orientation\">\n <option value=\"appium:language\">\n <option value=\"appium:locale\">\n <option value=\"appium:systemPort\">\n <option value=\"appium:adbPort\">\n <option value=\"appium:mjpegServerPort\">\n <option value=\"appium:mjpegScreenshotUrl\">\n <option value=\"appium:chromedriverExecutable\">\n <option value=\"appium:nativeWebScreenshot\">\n <option value=\"appium:disableWindowAnimation\">\n <option value=\"appium:wdaLocalPort\">\n <option value=\"appium:wdaLaunchTimeout\">\n <option value=\"appium:wdaConnectionTimeout\">\n <option value=\"appium:simulatorStartupTimeout\">\n <option value=\"appium:useNewWDA\">\n <option value=\"appium:usePrebuiltWDA\">\n <option value=\"appium:webDriverAgentUrl\">\n <option value=\"appium:resetOnSessionStartOnly\">\n <option value=\"appium:nativeWebTap\">\n <option value=\"appium:printPageSourceOnFindFailure\">\n <option value=\"browserName\">\n <option value=\"appium:browserName\">\n </datalist>\n </div>\n </div>\n </div>\n\n <!-- Wizard navigation footer (Back / Next or Connect) -->\n <div class=\"action-bar wizard-bar\">\n <button class=\"primary\" id=\"btn-step-back\" type=\"button\" style=\"display:none\">\u2190 Back</button>\n <div class=\"action-summary\" id=\"connect-summary\">Connect to <strong>localhost:4725</strong> \u00B7 <strong>Android</strong> \u00B7 UiAutomator2</div>\n <button class=\"primary\" id=\"btn-step-next\" type=\"button\">Next \u2192</button>\n <button class=\"primary\" id=\"btn-connect\" type=\"button\" style=\"display:none\">Connect \u2192</button>\n </div>\n</div>\n<main>\n <!-- \u2500\u2500\u2500 Tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Hierarchy</span>\n <div class=\"hier-mode-toggle\" role=\"tablist\" aria-label=\"hierarchy view\">\n <button class=\"hier-mode-btn active\" data-hier-mode=\"tree\" type=\"button\" role=\"tab\">Tree</button>\n <button class=\"hier-mode-btn\" data-hier-mode=\"xml\" type=\"button\" role=\"tab\">XML</button>\n </div>\n <span class=\"loc-spacer\"></span>\n <input class=\"tree-search\" id=\"tree-search\" placeholder=\"filter by tag, id, text\u2026\" />\n </div>\n <div class=\"pane-body tree-body\" id=\"hier-tree-body\">\n <ul class=\"tree\" id=\"tree\"></ul>\n </div>\n <div class=\"pane-body hier-xml-body\" id=\"hier-xml-body\" style=\"display:none\">\n <pre id=\"hier-xml-pre\"></pre>\n </div>\n </div>\n <!-- \u2500\u2500\u2500 Screen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Screen</span>\n <span class=\"screen-help-btn\" id=\"screen-help-btn\" role=\"button\" tabindex=\"0\"\n title=\"What can I do on the screen?\">\u24D8 How to use</span>\n <span class=\"meta\" id=\"screen-meta\" style=\"margin-left:auto\"></span>\n <select class=\"context-select hidden\" id=\"context-select\"\n title=\"Automation context \u2014 switch into a WebView to inspect the web DOM\"></select>\n <span class=\"context-hint hidden\" id=\"context-hint\" role=\"button\" tabindex=\"0\"\n title=\"No WebView context detected \u2014 click for help\">\u24D8 No WebView</span>\n </div>\n <div class=\"pane-body\" id=\"screen-wrap\">\n <div class=\"screen-help-pop\" id=\"screen-help-pop\" role=\"dialog\" aria-label=\"Using the screen\">\n <button class=\"screen-help-x\" id=\"screen-help-close\" type=\"button\" aria-label=\"Dismiss\">\u00D7</button>\n <div class=\"screen-help-title\">Working on the screen</div>\n <ul>\n <li><b>Click any element</b> on the screen to <b>select</b> it \u2014 then read its\n <b>Attributes</b> / <b>Locators</b>, or record an action on it from the <b>Record</b> tab.</li>\n <li>The blue box highlights the selected element's bounds.</li>\n <li>Hard to hit something small or overlapping? Pick it from the <b>Hierarchy</b> tree on the left.</li>\n <li>When recording a <b>tap at coordinates</b> or a <b>drag target</b>, click the exact spot on the screen.</li>\n </ul>\n <button class=\"primary screen-help-ok\" id=\"screen-help-ok2\" type=\"button\">Got it</button>\n </div>\n <div id=\"screen-host\">\n <img id=\"screen-img\" alt=\"device screen\" />\n <div id=\"highlight\" style=\"display:none\"></div>\n <div id=\"screen-action-overlay\" class=\"screen-action-overlay\" aria-hidden=\"true\">\n <div class=\"screen-action-card\">\n <span class=\"rec-sel-spinner\"></span>\n <span class=\"screen-action-check\">\u2713</span>\n <span id=\"screen-action-label\">Performing action\u2026</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n <!-- \u2500\u2500\u2500 Inspector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"tabs\" role=\"tablist\">\n <div class=\"tab active\" data-tab=\"record\" role=\"tab\">Record</div>\n <div class=\"tab\" data-tab=\"script\" role=\"tab\">Recorded script</div>\n <div class=\"tab\" data-tab=\"locators\" role=\"tab\">Locators</div>\n <div class=\"tab\" data-tab=\"attrs\" role=\"tab\">Attributes</div>\n </div>\n <div class=\"tab-content\" id=\"tab-record\">\n <!-- Recording start/stop banner -->\n <div class=\"rec-toggle\" id=\"rec-toggle\">\n <span class=\"rec-led\"></span>\n <div class=\"rec-status\" id=\"rec-status\">\n <strong>Not recording</strong> \u2014 press Start to capture actions as a script.\n </div>\n <button class=\"btn-rec-toggle\" id=\"btn-rec-toggle\" type=\"button\">\n <span class=\"rec-ico\"></span>\n <span id=\"btn-rec-toggle-label\">Start record</span>\n </button>\n </div>\n\n <!-- Pick-target banner (only shown while waiting for the user to click a point on the screen) -->\n <div class=\"rec-pickhint\" id=\"rec-pickhint\" style=\"display:none\">\n <span class=\"pulse\"></span>\n <span id=\"rec-pickhint-label\">Click a target on the screen to complete the action.</span>\n <button class=\"icon\" id=\"btn-rec-cancel\">Cancel</button>\n </div>\n\n <!-- Selected element card -->\n <div class=\"rec-selected\" id=\"rec-selected\">\n <div class=\"rec-sel-icon\" id=\"rec-sel-icon\">\u25CB</div>\n <div class=\"rec-sel-body\">\n <div class=\"rec-sel-title\" id=\"rec-sel-title\">No element selected</div>\n <div class=\"rec-sel-sub\" id=\"rec-sel-sub\">Tap an element on the screen or in the Hierarchy.</div>\n </div>\n </div>\n\n <!-- Subtab bar -->\n <div class=\"rec-subtabs\" role=\"tablist\">\n <button class=\"rec-subtab active\" data-subtab=\"actions\" type=\"button\">Actions</button>\n <button class=\"rec-subtab\" data-subtab=\"screen\" type=\"button\">Screen</button>\n <button class=\"rec-subtab\" data-subtab=\"assert\" type=\"button\">Assertions</button>\n </div>\n\n <!-- Actions pane (element-scoped) -->\n <div class=\"rec-pane\" id=\"rec-pane-actions\">\n <button class=\"rec-act primary\" data-act=\"click\" disabled style=\"width:100%\">\n <span class=\"ico\">\u25B6</span><span>Click</span>\n </button>\n <button class=\"rec-act\" data-screen=\"tap-point\" style=\"width:100%;margin-top:7px\">\n <span class=\"ico\">\u2299</span><span>Click @ coordinates</span>\n </button>\n <div class=\"rec-grid cols-2\" style=\"margin-top:7px\">\n <button class=\"rec-act\" data-act=\"doubleTap\" disabled><span class=\"ico\">\u23EF</span><span>Double tap</span></button>\n <button class=\"rec-act\" data-act=\"longPress\" disabled><span class=\"ico\">\u23F1</span><span>Long press</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Toggle</div>\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-act=\"check\" disabled><span class=\"ico\">\u2611</span><span>Check</span></button>\n <button class=\"rec-act\" data-act=\"uncheck\" disabled><span class=\"ico\">\u2610</span><span>Uncheck</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Focus</div>\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-act=\"focus\" disabled><span class=\"ico\">\u2316</span><span>Focus</span></button>\n <button class=\"rec-act\" data-act=\"blur\" disabled><span class=\"ico\">\u2298</span><span>Blur</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Type text</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-type-input\" placeholder=\"Type text into the field\u2026\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-type\" disabled><span class=\"ico\">\u2328</span><span>Type</span></button>\n <button class=\"rec-act\" id=\"btn-rec-clear\" disabled title=\"Clear the field\"><span class=\"ico\">\u232B</span><span>Clear</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Type sequentially (one char at a time)</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-seq-input\" placeholder=\"Text\u2026\" disabled />\n <input class=\"rec-input\" id=\"rec-seq-delay\" placeholder=\"delay (ms)\" inputmode=\"numeric\" style=\"max-width:90px\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-seq\" disabled><span class=\"ico\">\u2328</span><span>Type slowly</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Press key</div>\n <div class=\"rec-input-row\">\n <select class=\"rec-input\" id=\"rec-press-key\" disabled>\n <option value=\"Enter\">Enter</option>\n <option value=\"Tab\">Tab</option>\n <option value=\"Backspace\">Backspace</option>\n <option value=\"Space\">Space</option>\n <option value=\"Escape\">Escape</option>\n <option value=\"ArrowUp\">ArrowUp</option>\n <option value=\"ArrowDown\">ArrowDown</option>\n <option value=\"ArrowLeft\">ArrowLeft</option>\n <option value=\"ArrowRight\">ArrowRight</option>\n <option value=\"Delete\">Delete</option>\n <option value=\"Home\">Home</option>\n <option value=\"End\">End</option>\n <option value=\"PageUp\">PageUp</option>\n <option value=\"PageDown\">PageDown</option>\n </select>\n <button class=\"rec-act\" id=\"btn-rec-press\" disabled><span class=\"ico\">\u23CE</span><span>Press</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Select picker option</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-select-label\" placeholder=\"Option label\u2026\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-select\" disabled><span class=\"ico\">\u25BC</span><span>Select</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Swipe within element</div>\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-act=\"swipe-left\" disabled><span class=\"ico\">\u2190</span><span>Left</span></button>\n <button class=\"rec-act\" data-act=\"swipe-right\" disabled><span class=\"ico\">\u2192</span><span>Right</span></button>\n <button class=\"rec-act\" data-act=\"swipe-up\" disabled><span class=\"ico\">\u2191</span><span>Up</span></button>\n <button class=\"rec-act\" data-act=\"swipe-down\" disabled><span class=\"ico\">\u2193</span><span>Down</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Gestures</div>\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-act=\"pinch-in\" disabled><span class=\"ico\">\u2296</span><span>Pinch in</span></button>\n <button class=\"rec-act\" data-act=\"pinch-out\" disabled><span class=\"ico\">\u2295</span><span>Pinch out</span></button>\n <button class=\"rec-act\" data-act=\"scrollIntoView\" disabled><span class=\"ico\">\u2195</span><span>Scroll to</span></button>\n <button class=\"rec-act\" data-act=\"dragToPoint\" disabled title=\"Drag the selected element onto a target you pick\"><span class=\"ico\">\u26F6</span><span>Drag to target</span></button>\n </div>\n </div>\n\n <!-- Screen pane (no element selection required) -->\n <div class=\"rec-pane hidden\" id=\"rec-pane-screen\">\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-screen=\"scroll-up\"><span class=\"ico\">\u2191</span><span>Scroll up</span></button>\n <button class=\"rec-act\" data-screen=\"scroll-down\"><span class=\"ico\">\u2193</span><span>Scroll down</span></button>\n </div>\n <div class=\"rec-y-range\">\n <div class=\"rec-y-range-label\">\n <span>Custom region (% of screen, optional)</span>\n <span class=\"rec-y-range-defaults\">defaults: y 40\u201360% \u00B7 x 50%</span>\n </div>\n <div class=\"rec-y-range-fields\">\n <span class=\"rec-y-cell\">\n <span>y from</span>\n <input id=\"rec-scroll-top\" placeholder=\"40\" inputmode=\"numeric\" />\n <span>to</span>\n <input id=\"rec-scroll-bottom\" placeholder=\"60\" inputmode=\"numeric\" />\n <span>%</span>\n </span>\n <span class=\"rec-y-cell\">\n <span>x at</span>\n <input id=\"rec-scroll-x\" placeholder=\"50\" inputmode=\"numeric\" />\n <span>%</span>\n </span>\n <button class=\"icon\" id=\"btn-rec-y-clear\" type=\"button\" title=\"Clear range\">\u00D7</button>\n </div>\n </div>\n <div class=\"rec-grid\" style=\"margin-top:7px\">\n <button class=\"rec-act\" data-screen=\"drag-and-drop\" title=\"Pick a source element, then a drop target\"><span class=\"ico\">\u26F6</span><span>Drag & drop</span></button>\n </div>\n </div>\n\n <!-- Assertions pane (element-scoped) -->\n <div class=\"rec-pane hidden\" id=\"rec-pane-assert\">\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-assert=\"visible\" disabled><span class=\"ico\">\u2713</span><span>Visible</span></button>\n <button class=\"rec-act\" data-assert=\"hidden\" disabled><span class=\"ico\">\u2717</span><span>Hidden</span></button>\n <button class=\"rec-act\" data-assert=\"enabled\" disabled><span class=\"ico\">\uD83D\uDD13</span><span>Enabled</span></button>\n <button class=\"rec-act\" data-assert=\"disabled\" disabled><span class=\"ico\">\uD83D\uDD12</span><span>Disabled</span></button>\n <button class=\"rec-act\" data-assert=\"checked\" disabled><span class=\"ico\">\u2611</span><span>Checked</span></button>\n <button class=\"rec-act\" data-assert=\"unchecked\" disabled><span class=\"ico\">\u2610</span><span>Unchecked</span></button>\n <button class=\"rec-act\" data-assert=\"editable\" disabled><span class=\"ico\">\u270E</span><span>Editable</span></button>\n <button class=\"rec-act\" data-assert=\"readonly\" disabled><span class=\"ico\">\u2298</span><span>Readonly</span></button>\n <button class=\"rec-act\" data-assert=\"focused\" disabled><span class=\"ico\">\u2316</span><span>Focused</span></button>\n <button class=\"rec-act\" data-assert=\"attached\" disabled><span class=\"ico\">\u2693</span><span>Attached</span></button>\n <button class=\"rec-act\" data-assert=\"empty\" disabled><span class=\"ico\">\u2205</span><span>Empty</span></button>\n <button class=\"rec-act\" data-assert=\"inViewport\" disabled><span class=\"ico\">\uD83D\uDDBD</span><span>In viewport</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-text\" placeholder=\"text equals\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"text-exact\" disabled><span class=\"ico\">\u2261</span><span>Equals</span></button>\n <button class=\"rec-act\" data-assert=\"text-contains\" disabled><span class=\"ico\">\u2283</span><span>Contains</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-value\" placeholder=\"value equals\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"value\" disabled><span class=\"ico\">\u2261</span><span>Assert value</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-count\" placeholder=\"match count\u2026\" inputmode=\"numeric\" disabled />\n <button class=\"rec-act\" data-assert=\"count\" disabled><span class=\"ico\">#</span><span>Assert count</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-attr-name\" placeholder=\"attribute name\u2026\" disabled />\n <input id=\"rec-assert-attr-value\" placeholder=\"value\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"attribute\" disabled><span class=\"ico\">\u2261</span><span>Assert attribute</span></button>\n </div>\n </div>\n\n </div>\n <div class=\"tab-content hidden\" id=\"tab-script\">\n <div class=\"rec-group\">\n <div class=\"rec-group-title\">\n Recorded script\n <span class=\"grow\"></span>\n <span class=\"lang-seg\" id=\"script-lang\">\n <button class=\"icon active\" data-lang=\"ts\" type=\"button\">Taqwright</button>\n <button class=\"icon\" data-lang=\"python\" type=\"button\">Python</button>\n <button class=\"icon\" data-lang=\"java\" type=\"button\">Java</button>\n </span>\n <button class=\"icon\" id=\"btn-copy-script\" type=\"button\">\u2398 Copy</button>\n <button class=\"icon\" id=\"btn-export-script\" type=\"button\" title=\"Save the recorded script into your project's tests folder\">\u2193 Export</button>\n <button class=\"icon\" id=\"btn-clear-script\" type=\"button\">Clear</button>\n </div>\n <div class=\"rec-lang-note\" id=\"script-lang-note\" style=\"display:none\">Steps only \u2014 paste into your own Appium test (driver/setup not included).</div>\n <div class=\"rec-script-card\">\n <pre id=\"script\"></pre>\n </div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-locators\">\n <div class=\"empty-state\">\n <div>Select an element to see unique locator strategies.</div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-attrs\">\n <div class=\"empty-state\">\n <div>Select an element.</div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-script-OLD-UNUSED\" style=\"display:none\">\n <pre id=\"script-old\"></pre>\n </div>\n </div>\n</main>\n<div class=\"loader-overlay\" id=\"loader\" aria-live=\"polite\" aria-hidden=\"true\">\n <div class=\"loader-spinner\"></div>\n <div class=\"loader-message\" id=\"loader-msg\">Loading\u2026</div>\n <div class=\"loader-sub\" id=\"loader-sub\"></div>\n <button id=\"loader-cancel\" type=\"button\">Cancel</button>\n</div>\n<div id=\"toasts\" aria-live=\"polite\"></div>\n<div id=\"modal-overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"modal-title\">\n <div class=\"modal-card\">\n <div class=\"modal-body\">\n <span class=\"modal-icon\" id=\"modal-icon\">\u26A0\uFE0F</span>\n <div class=\"modal-text\">\n <div class=\"modal-title\" id=\"modal-title\">Are you sure?</div>\n <div class=\"modal-msg\" id=\"modal-msg\"></div>\n </div>\n </div>\n <div class=\"modal-actions\">\n <button class=\"modal-btn\" id=\"modal-cancel\">Cancel</button>\n <button class=\"modal-btn confirm\" id=\"modal-confirm\">Confirm</button>\n </div>\n </div>\n</div>\n<div id=\"status\">ready</div>\n\n<!-- \u2500\u2500\u2500 Demo stage (illustrated inspector for the Inspector tour) \u2500\u2500\u2500 -->\n<div id=\"demo-stage\" aria-hidden=\"true\">\n <div class=\"demo-bar\">\n <span class=\"demo-badge\">DEMO</span>\n <span class=\"demo-bar-title\">Inspector \u2014 example walkthrough (Taqelah demo app)</span>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"demo-disconnect\" type=\"button\" disabled>Disconnect</button>\n </div>\n <div class=\"demo-panes\">\n <!-- Hierarchy -->\n <div class=\"pane demo-pane\" id=\"demo-hier\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Hierarchy</span>\n <div class=\"demo-seg\"><span class=\"on\">Tree</span><span>XML</span></div>\n <span class=\"grow\"></span>\n <span class=\"demo-search\">filter by tag, id, text\u2026</span>\n </div>\n <div class=\"pane-body\">\n <ul class=\"demo-tree\">\n <li>\u25BE android.widget.FrameLayout</li>\n <li class=\"i1\">\u25BE android.view.View</li>\n <li class=\"i2 sel\">android.widget.EditText <span class=\"demo-q\">hint=\"Username\"</span></li>\n <li class=\"i2\">android.widget.EditText <span class=\"demo-q\">hint=\"Password\"</span></li>\n <li class=\"i2\">android.view.View <span class=\"demo-id\">desc=\"Login\"</span></li>\n </ul>\n </div>\n </div>\n <!-- Screen (demo login phone) -->\n <div class=\"pane demo-pane\" id=\"demo-screen\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Screen</span>\n <span class=\"grow\"></span>\n <span class=\"demo-meta\">1080 \u00D7 2340</span>\n </div>\n <div class=\"pane-body demo-screen-body\">\n <div class=\"demo-phone\">\n <div class=\"demo-statusbar\"><span>1:11</span><span>\u25BE \u25AE \u25B6</span></div>\n <div class=\"demo-app\">\n <div class=\"demo-app-logo\"><span class=\"demo-logo-glass\">\uD83C\uDF79</span><span class=\"demo-logo-name\">taqelah!</span></div>\n <div class=\"demo-app-title\">DemoApp</div>\n <div class=\"demo-app-sub\">Sign in to shop the latest styles</div>\n <div class=\"demo-field sel\"><span class=\"demo-field-ico\">\uD83D\uDC64</span><span class=\"demo-field-ph\">Username</span></div>\n <div class=\"demo-field\"><span class=\"demo-field-ico\">\uD83D\uDD12</span><span class=\"demo-field-ph\">Password</span><span class=\"demo-field-eye\">\uD83D\uDC41</span></div>\n <button class=\"demo-app-btn\" type=\"button\">Login</button>\n <div class=\"demo-creds\">\n <div class=\"demo-creds-title\">Demo Credentials</div>\n <div>Username: emma@demoapp.com</div>\n <div>Password: 10203040</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <!-- Inspector tabs -->\n <div class=\"pane demo-pane\" id=\"demo-tabs\">\n <div class=\"tabs\" role=\"tablist\">\n <div class=\"tab active\" data-demo-tab=\"rec\">Record</div>\n <div class=\"tab\" data-demo-tab=\"script\">Recorded script</div>\n <div class=\"tab\" data-demo-tab=\"loc\">Locators</div>\n <div class=\"tab\" data-demo-tab=\"attrs\">Attributes</div>\n </div>\n <div class=\"demo-tabwrap\">\n <div class=\"demo-tabpane\" id=\"demo-rec\">\n <div class=\"demo-rec-banner\"><span class=\"demo-led\"></span>Recording \u2014 selected: <b>Username field</b></div>\n <div class=\"demo-rec-grid\">\n <span class=\"demo-act primary\">\u25B6 Click</span>\n <span class=\"demo-act\">\u2328 Type</span>\n <span class=\"demo-act\">\u232B Clear</span>\n <span class=\"demo-act\">\u23F1 Long press</span>\n <span class=\"demo-act\">\u2195 Scroll to</span>\n <span class=\"demo-act\">\u2713 Assert visible</span>\n </div>\n <div class=\"demo-subtabs\">Actions \u00B7 Screen \u00B7 Assertions</div>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-script\">\n <div class=\"demo-seg sm\"><span class=\"on\">Taqwright</span><span>Python</span><span>Java</span>\n <span class=\"grow\"></span><span>\u2398 Copy</span><span>\u2193 Export</span></div>\n <pre class=\"demo-code\">await mobile.getByXpath(\"//*[@hint='Username']\").fill('emma@demoapp.com');\nawait mobile.getByXpath(\"//*[@hint='Password']\").fill('10203040');\nawait mobile.getByUiSelector('new UiSelector().description(\"Login\")').click();</pre>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-loc\">\n <div class=\"demo-loc-row\"><span class=\"demo-cat xpath\">xpath</span><code>//android.widget.EditText[@hint=\"Username\"]</code><span class=\"demo-pick\">recommended</span></div>\n <div class=\"demo-loc-row\"><span class=\"demo-cat uiautomator\">UIAutomator</span><code>new UiSelector().className(\"android.widget.EditText\").instance(0)</code></div>\n <div class=\"demo-loc-row\"><span class=\"demo-cat xpath\">xpath</span><code>(//android.widget.EditText)[1]</code></div>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-attrs\">\n <table class=\"demo-attrs\">\n <tr><td>class</td><td>android.widget.EditText</td></tr>\n <tr><td>hint</td><td>Username</td></tr>\n <tr><td>text</td><td></td></tr>\n <tr><td>content-desc</td><td></td></tr>\n <tr><td>resource-id</td><td></td></tr>\n <tr><td>bounds</td><td>[72,560][1008,696]</td></tr>\n </table>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Guided tour (spotlight) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"tour-overlay\" aria-hidden=\"true\">\n <div id=\"tour-catcher\"></div>\n <div id=\"tour-spotlight\"></div>\n <div id=\"tour-pop\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"tour-title\">\n <button id=\"tour-skip\" type=\"button\" aria-label=\"Skip tour\" title=\"Skip\">\u00D7</button>\n <h3 id=\"tour-title\"></h3>\n <div class=\"tour-body\" id=\"tour-text\"></div>\n <div id=\"tour-foot\">\n <span id=\"tour-progress\"></span>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"tour-back\" type=\"button\">\u2190 Back</button>\n <button class=\"primary\" id=\"tour-next\" type=\"button\">Next \u2192</button>\n </div>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Help reference panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"help-overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"help-title\">\n <div id=\"help-panel\">\n <div class=\"help-head\">\n <h2 id=\"help-title\">taqwright codegen \u2014 Help</h2>\n <span class=\"grow\"></span>\n <button class=\"primary\" id=\"help-tour-setup\" type=\"button\" title=\"Guided tour of the setup wizard\">\u25B6 Setup tour</button>\n <button class=\"icon\" id=\"help-tour-inspector\" type=\"button\" title=\"Preview the inspector / device-screen tour (no device needed)\">\u25B6 Inspector tour</button>\n <button id=\"help-close\" type=\"button\" aria-label=\"Close help\">\u00D7</button>\n </div>\n <p class=\"help-lead\">codegen lets you drive a real device, inspect its UI, record your actions\n as you go, and export a runnable test. Take the <b>Setup tour</b> or preview the\n <b>Inspector tour</b> (the device-screen view \u2014 works even before you connect), or read the\n topics below.</p>\n\n <details open>\n <summary>Quick start</summary>\n <div class=\"help-sec\">\n <ol>\n <li><b>Connect</b> a device (the 3-step setup wizard).</li>\n <li><b>Click an element</b> on the screen (or a node in the Hierarchy) to select it.</li>\n <li>Press <b>Start record</b>, then pick actions / assertions for the selected element.</li>\n <li>Open <b>Recorded script</b> and <b>Export</b> it into your project.</li>\n </ol>\n </div>\n </details>\n\n <details open>\n <summary>1 \u00B7 Connecting to a device</summary>\n <div class=\"help-sec\">\n Choose <b>Local</b> (an emulator / simulator or USB device on this machine) or <b>Cloud</b>\n (BrowserStack / LambdaTest) at the top, then walk the 3-step wizard:\n <ul>\n <li><b>Step 1 \u2014 Prerequisites:</b> the <b>Environment</b> card runs a health check\n (<code>adb</code>, JDK, Android SDK, Appium drivers \u2014 expand it for details); the\n <b>Appium server</b> card lets you <b>Start</b> / Restart / Recheck a local Appium.\n <b>Next</b> unlocks once Appium is green. Cloud mode shows a credentials card instead.</li>\n <li><b>Step 2 \u2014 Pick a device:</b> switch the <b>Android / iOS</b> tabs,\n <code>\u21BB Refresh</code> the list, and <b>Start</b> a shutdown emulator (or select a\n running one / a cloud device).</li>\n <li><b>Step 3 \u2014 App & capabilities:</b> point at the app under test with\n <b>Browse\u2026</b>, tweak or <b>+ Add</b> Appium capabilities (<b>\u21BA Reset</b> restores the\n config defaults), then <b>Connect \u2192</b>.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>2 \u00B7 The window layout</summary>\n <div class=\"help-sec\">\n Once connected you get three panes:\n <ul>\n <li><b>Hierarchy</b> (left) \u2014 the UI element tree.</li>\n <li><b>Screen</b> (center) \u2014 a live mirror of the device.</li>\n <li><b>Inspector</b> (right) \u2014 four tabs: <b>Record</b>, <b>Recorded script</b>,\n <b>Locators</b>, <b>Attributes</b>.</li>\n </ul>\n Selecting an element anywhere drives all of these at once.\n </div>\n </details>\n\n <details>\n <summary>3 \u00B7 Hierarchy \u2014 Tree & XML</summary>\n <div class=\"help-sec\">\n <ul>\n <li>Toggle <b>Tree</b> (collapsible element tree) or raw <b>XML</b> page source.</li>\n <li><b>Filter</b> with the search box \u2014 matches by tag, id, or text.</li>\n <li><b>Click a node</b> to select it: it highlights on the screen and populates the\n Locators / Attributes tabs.</li>\n <li>Use the tree to reach <b>small or overlapping</b> elements that are hard to click on\n the screen.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>4 \u00B7 Screen mirror & WebView</summary>\n <div class=\"help-sec\">\n <ul>\n <li><b>Click any element</b> on the live screen to <b>select</b> it (the blue box shows\n its bounds). The mirror is for selecting / inspecting \u2014 actions are recorded from the\n Record tab.</li>\n <li>When recording a <b>tap at coordinates</b> or a <b>drag target</b>, click the exact\n spot on the screen.</li>\n <li><b>WebView:</b> if the app has a WebView, the context dropdown above the screen lets\n you switch into it to inspect the web DOM.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>5 \u00B7 Recording \u2014 Actions</summary>\n <div class=\"help-sec\">\n Press <b>Start record</b>, select an element, then choose an action; each is appended to\n the script live.\n <ul>\n <li><b>Element:</b> Click, Double tap, Long press, Check / Uncheck, Focus / Blur, Type,\n Clear, Type slowly, Press (a key), Select (a dropdown value).</li>\n <li><b>Gestures:</b> Swipe \u2190 \u2192 \u2191 \u2193, Pinch in / out, Scroll to (scroll the element into\n view), Drag to target (drag the element onto a point you click).</li>\n </ul>\n The <b>Actions / Screen / Assertions</b> sub-tabs switch what the palette records.\n </div>\n </details>\n\n <details>\n <summary>6 \u00B7 Recording \u2014 Screen taps</summary>\n <div class=\"help-sec\">\n The <b>Screen</b> sub-tab records raw interactions <b>at coordinates</b> \u2014 no element\n selection needed. Useful for canvases, maps, games, or anything the hierarchy doesn't\n expose as a tappable element.\n </div>\n </details>\n\n <details>\n <summary>7 \u00B7 Recording \u2014 Assertions</summary>\n <div class=\"help-sec\">\n The <b>Assertions</b> sub-tab records checks that verify state on the selected element:\n <ul>\n <li><b>State:</b> Visible, Hidden, Enabled, Disabled, Checked, Unchecked, Editable,\n Readonly, Focused, Attached, Empty, In viewport.</li>\n <li><b>Text:</b> Equals (exact) or Contains.</li>\n <li><b>Value</b>, <b>Count</b> (how many match), and <b>Attribute</b> (assert a specific\n attribute value).</li>\n </ul>\n Assertions are how your exported test catches regressions.\n </div>\n </details>\n\n <details>\n <summary>8 \u00B7 Locators</summary>\n <div class=\"help-sec\">\n With an element selected, the <b>Locators</b> tab lists <b>ranked, uniqueness-verified</b>\n candidates per strategy:\n <ul>\n <li><b>id</b> and <b>accessibility id</b> (most stable).</li>\n <li><b>UIAutomator</b> (Android), <b>NSPredicate</b> / <b>Class Chain</b> (iOS).</li>\n <li><b>xpath</b> (fallback).</li>\n </ul>\n A <b>Recommended</b> pick is floated to the top. Click any candidate to copy it.\n </div>\n </details>\n\n <details>\n <summary>9 \u00B7 Attributes</summary>\n <div class=\"help-sec\">\n The <b>Attributes</b> tab shows the selected element's full attribute set (resource-id,\n text, content-desc / name, bounds, class, \u2026) plus its xpath \u2014 handy for crafting your own\n locators.\n </div>\n </details>\n\n <details>\n <summary>10 \u00B7 The recorded script & export</summary>\n <div class=\"help-sec\">\n The <b>Recorded script</b> tab renders your test in three languages:\n <ul>\n <li><b>Taqwright</b> \u2014 a complete, runnable test.</li>\n <li><b>Python</b> / <b>Java</b> \u2014 the steps only (paste into your own Appium test;\n driver / setup not included).</li>\n </ul>\n Use <code>\u2398 Copy</code>, <code>\u2193 Export</code> (saves into your project's tests folder), or\n <b>Clear</b> to start over.\n </div>\n </details>\n\n <details>\n <summary>Tips & shortcuts</summary>\n <div class=\"help-sec\">\n <ul>\n <li>Re-open this help any time with <b>? Help</b> in the header.</li>\n <li>During the tour: <b>\u2192 / \u2190</b> next / back, <b>Esc</b> to skip.</li>\n <li>The Screen pane's <b>\u24D8 How to use</b> explains on-screen interactions.</li>\n <li><b>Disconnect</b> ends the session and returns you to setup.</li>\n </ul>\n </div>\n </details>\n </div>\n</div>\n<script>\n(() => {\n 'use strict';\n const $ = (id) => document.getElementById(id);\n const status = $('status');\n const setStatus = (s, busy) => {\n status.textContent = s;\n status.classList.toggle('busy', !!busy);\n };\n\n /** Full-screen loader overlay. Use during multi-second blocking work like\n * opening a WebDriver session or downloading the first snapshot. */\n function showLoader(msg, sub, onCancel) {\n const el = $('loader');\n if (!el) return;\n $('loader-msg').textContent = msg || 'Loading\u2026';\n $('loader-sub').textContent = sub || '';\n // Optional Cancel button \u2014 shown only when the caller passes a handler\n // (e.g. a long cloud connect the user may want to abort).\n const cancel = $('loader-cancel');\n if (onCancel) {\n cancel.onclick = onCancel;\n cancel.classList.add('shown');\n } else {\n cancel.onclick = null;\n cancel.classList.remove('shown');\n }\n el.classList.add('shown');\n el.setAttribute('aria-hidden', 'false');\n }\n function hideLoader() {\n const el = $('loader');\n if (!el) return;\n const cancel = $('loader-cancel');\n cancel.onclick = null;\n cancel.classList.remove('shown');\n el.classList.remove('shown');\n el.setAttribute('aria-hidden', 'true');\n }\n\n /** Floating, layout-neutral notifications. Errors stick until dismissed; success/info auto-hide. */\n function showToast(message, type, options) {\n type = type || 'info';\n options = options || {};\n const cont = $('toasts');\n if (!cont) return () => {};\n const el = document.createElement('div');\n el.className = 'toast ' + type;\n const title = options.title || (type === 'error' ? 'Error' : type === 'success' ? 'Success' : 'Info');\n el.innerHTML =\n '<div class=\"body\">' +\n '<div class=\"title\"></div>' +\n '<div class=\"msg\"></div>' +\n '</div>' +\n '<button class=\"close\" type=\"button\" aria-label=\"dismiss\">\u00D7</button>';\n el.querySelector('.title').textContent = title;\n el.querySelector('.msg').textContent = message;\n const dismiss = () => {\n if (!el.parentNode) return;\n el.classList.add('fading');\n setTimeout(() => el.remove(), 200);\n };\n el.querySelector('.close').onclick = dismiss;\n cont.appendChild(el);\n const ttl = options.ttl != null ? options.ttl : (type === 'error' ? 0 : 3500);\n if (ttl > 0) setTimeout(dismiss, ttl);\n return dismiss;\n }\n\n /** Remove every toast on screen. Useful before retrying an action. */\n function clearToasts() {\n const cont = $('toasts');\n if (cont) cont.replaceChildren();\n }\n\n const state = {\n platform: 'android',\n project: '',\n viewport: { w: 0, h: 0 },\n sourceXml: '',\n xmlDoc: null,\n selected: null,\n nodeMap: new Map(),\n nextId: 0,\n suggestSeq: 0,\n context: 'NATIVE_APP',\n };\n\n function isWebContext() {\n return !!state.context && state.context !== 'NATIVE_APP';\n }\n\n // \u2500\u2500\u2500 Snapshot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** Identifying fingerprint for an element across snapshots. */\n function elementSignature(el) {\n if (!el) return '';\n return [\n el.tagName,\n el.getAttribute('class') || '',\n el.getAttribute('resource-id') || '',\n el.getAttribute('content-desc') || '',\n el.getAttribute('name') || '',\n el.getAttribute('text') || el.getAttribute('label') || '',\n el.getAttribute('hint') || el.getAttribute('placeholderValue') || '',\n ].join('|');\n }\n\n /**\n * Rebind state.selected to the new tree node without re-fetching locator\n * suggestions or flipping the Record-tab card to \"resolving\". Used when a\n * snapshot refresh produces an element with the SAME xpath + signature as\n * the prior selection \u2014 semantically the same element, no need to redo\n * the work.\n */\n function quietlyRebindSelection(el) {\n state.selected = el;\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n if (el.__nodeId) {\n const li = document.querySelector('li.node[data-id=\"' + el.__nodeId + '\"]');\n if (li) li.classList.add('selected');\n }\n drawHighlight(el);\n }\n\n /** Drop the current selection (used when the new snapshot doesn't contain it). */\n function clearSelection() {\n state.selected = null;\n clearLocatorState();\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n $('highlight').style.display = 'none';\n $('tab-attrs').innerHTML = '<div class=\"empty-state\">Select an element.</div>';\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">Select an element to see unique locator strategies.</div>';\n }\n\n // \u2500\u2500\u2500 Auto-refresh \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Polls /api/snapshot so the inspector mirrors the device live. Always on\n // while connected (no toggle); auto-paused during snapshots, anchor-picks,\n // and locator resolves.\n const AUTO_REFRESH_MS = 1500;\n const WEB_REFRESH_MS = 4000; // WebView snapshots are heavier \u2014 larger floor\n let autoRefreshOn = true;\n let autoRefreshTimer = null;\n let snapshotInFlight = false;\n\n function scheduleNextRefresh(delay) {\n if (autoRefreshTimer) clearTimeout(autoRefreshTimer);\n autoRefreshTimer = setTimeout(autoRefreshTick, delay);\n }\n function startAutoRefresh() {\n if (autoRefreshTimer) return;\n autoRefreshOn = true;\n scheduleNextRefresh(0);\n refreshContexts(); // populate Native + any WebView contexts on connect\n }\n function stopAutoRefresh() {\n autoRefreshOn = false;\n if (autoRefreshTimer) clearTimeout(autoRefreshTimer);\n autoRefreshTimer = null;\n // Reset the context selector back to its hidden default on disconnect.\n state.context = 'NATIVE_APP';\n const sel = document.getElementById('context-select');\n if (sel) {\n sel.classList.add('hidden');\n sel.classList.remove('web');\n }\n }\n async function autoRefreshTick() {\n autoRefreshTimer = null;\n if (!autoRefreshOn) return;\n // Busy (snapshot/verify/anchor-pick in progress) \u2014 re-check soon.\n if (snapshotInFlight || anchorPickHandler !== null || locatorState === 'resolving') {\n scheduleNextRefresh(AUTO_REFRESH_MS);\n return;\n }\n const started = performance.now();\n await fetchSnapshot();\n const elapsed = performance.now() - started;\n // Gap at least as long as the snapshot took (with a webview floor) so we\n // never pile onto a slow device.\n const base = isWebContext() ? WEB_REFRESH_MS : AUTO_REFRESH_MS;\n if (autoRefreshOn) scheduleNextRefresh(Math.max(base, elapsed));\n }\n\n async function fetchSnapshot(opts) {\n const force = opts && opts.force;\n if (snapshotInFlight) {\n if (!force) return; // Non-forced refresh: skip if a snapshot is already running\n // Forced (context switch): wait for the in-flight snapshot to finish,\n // then run ours so the tree re-renders for the new context.\n while (snapshotInFlight) await new Promise((r) => setTimeout(r, 50));\n }\n snapshotInFlight = true;\n setStatus('snapshot\u2026', true);\n try {\n const r = await fetch('/api/snapshot');\n if (!r.ok) throw new Error('HTTP ' + r.status);\n const j = await r.json();\n // Capture the selected element's xpath + identity fingerprint AFTER the\n // fetch resolves, so a selection the user made while the snapshot was in\n // flight (e.g. tapping a new element right after an action) is the one we\n // re-bind across the new tree \u2014 not the stale selection from when the\n // snapshot started. Reading these off the now-detached node is safe;\n // renderTree builds fresh nodes. The xpath+signature match below still\n // drops an unrelated element sitting at the same xpath after a navigation.\n const prevXpath = state.selected?.__xpath;\n const prevSig = elementSignature(state.selected);\n state.platform = j.platform;\n state.project = j.project;\n state.viewport = j.viewport;\n state.sourceXml = j.source;\n $('session-meta').textContent = formatSessionMeta(j.platform, j.project);\n $('screen-meta').textContent = j.viewport.w + ' \u00D7 ' + j.viewport.h;\n $('screen-img').src = 'data:image/png;base64,' + j.screenshot;\n renderTree();\n if (hierarchyMode === 'xml') refreshHierarchyXml();\n if (prevXpath && prevSig) {\n let match = null;\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === prevXpath && elementSignature(el) === prevSig) {\n match = el; break;\n }\n }\n if (match) {\n // Same xpath + identifying signature \u2192 it's semantically the same\n // element. Just rebind state.selected to the new DOM node and\n // refresh the highlight; skip the locator re-fetch and the Record-\n // tab \"resolving\u2026\" flash. This is what makes auto-refresh stop\n // blinking.\n quietlyRebindSelection(match);\n } else {\n clearSelection();\n }\n }\n setStatus('idle');\n } catch (err) {\n setStatus('error: ' + err.message);\n } finally {\n snapshotInFlight = false;\n }\n }\n\n // \u2500\u2500\u2500 Tree rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function renderTree() {\n state.nodeMap.clear();\n state.nextId = 0;\n const parser = new DOMParser();\n // In a WebView the page source is an HTML DOM (often not well-formed XML),\n // so parse it as HTML. Native sources stay XML.\n const doc = parser.parseFromString(state.sourceXml, isWebContext() ? 'text/html' : 'text/xml');\n state.xmlDoc = doc;\n const root = doc.documentElement;\n if (!root) {\n $('tree').innerHTML = '<li class=\"empty-state\">No source.</li>';\n return;\n }\n annotateXpaths(root, '/' + root.tagName);\n $('tree').innerHTML = renderNode(root, true);\n bindTreeClicks();\n applyTreeFilter($('tree-search').value);\n }\n\n function annotateXpaths(el, xp) {\n el.__xpath = xp;\n const children = Array.from(el.children);\n const counts = {};\n for (const c of children) counts[c.tagName] = (counts[c.tagName] ?? 0) + 1;\n const seen = {};\n for (const c of children) {\n seen[c.tagName] = (seen[c.tagName] ?? 0) + 1;\n const idx = counts[c.tagName] > 1 ? '[' + seen[c.tagName] + ']' : '';\n annotateXpaths(c, xp + '/' + c.tagName + idx);\n }\n }\n\n function renderNode(el, isRoot) {\n const id = ++state.nextId;\n state.nodeMap.set(id, el);\n el.__nodeId = id;\n const tag = shortTag(el.tagName);\n const ident = pickIdent(el);\n const textHint = pickTextHint(el, ident);\n const children = Array.from(el.children);\n const twisty = children.length\n ? '<span class=\"twisty\">\u25BE</span>'\n : '<span class=\"twisty empty\">\u00B7</span>';\n const identHtml = ident\n ? ' <span class=\"ident\">' + escapeHtml(truncate(ident, 50)) + '</span>'\n : '';\n const textHtml = textHint\n ? ' <span class=\"text-snippet\">\"' + escapeHtml(truncate(textHint, 50)) + '\"</span>'\n : '';\n let html = '<li class=\"node\" data-id=\"' + id + '\">';\n html += '<span class=\"label\">' + twisty;\n html += '<span class=\"tag\">' + escapeHtml(tag) + '</span>' + identHtml + textHtml;\n html += '</span>';\n if (children.length) {\n html += '<ul' + (isRoot ? '' : '') + '>' + children.map((c) => renderNode(c, false)).join('') + '</ul>';\n }\n html += '</li>';\n return html;\n }\n\n /** Trim \"android.widget.\" or \"XCUIElementType\" prefix for compactness. */\n function shortTag(tag) {\n if (tag.startsWith('XCUIElementType')) return tag.slice('XCUIElementType'.length);\n if (tag.startsWith('android.widget.')) return tag.slice('android.widget.'.length);\n if (tag.startsWith('android.view.')) return tag.slice('android.view.'.length);\n return tag;\n }\n\n function pickIdent(el) {\n const rid = el.getAttribute('resource-id');\n if (rid) {\n return rid.includes(':id/') ? rid.split(':id/')[1] : rid;\n }\n return el.getAttribute('content-desc')\n || el.getAttribute('name')\n || '';\n }\n\n function pickTextHint(el, ident) {\n const t = el.getAttribute('text') || el.getAttribute('label') || el.getAttribute('value') || '';\n if (!t || t === ident) return '';\n return t;\n }\n\n function bindTreeClicks() {\n $('tree').onclick = (ev) => {\n const li = ev.target.closest('li.node');\n if (!li) return;\n const id = Number(li.dataset.id);\n const el = state.nodeMap.get(id);\n if (el) selectElement(el);\n };\n }\n\n // \u2500\u2500\u2500 Tree filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n $('tree-search').addEventListener('input', (ev) => {\n if (hierarchyMode === 'xml') applyXmlFilter(ev.target.value);\n else applyTreeFilter(ev.target.value);\n });\n function applyTreeFilter(q) {\n q = (q || '').trim().toLowerCase();\n const items = $('tree').querySelectorAll('li.node');\n if (!q) {\n items.forEach((li) => { li.style.display = ''; li.classList.remove('match'); });\n return;\n }\n items.forEach((li) => {\n const id = Number(li.dataset.id);\n const el = state.nodeMap.get(id);\n if (!el) return;\n const hay = (el.tagName + ' ' +\n (el.getAttribute('resource-id') || '') + ' ' +\n (el.getAttribute('content-desc') || '') + ' ' +\n (el.getAttribute('name') || '') + ' ' +\n (el.getAttribute('label') || '') + ' ' +\n (el.getAttribute('text') || '')).toLowerCase();\n const hit = hay.includes(q);\n li.classList.toggle('match', hit);\n });\n }\n\n // \u2500\u2500\u2500 Selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function selectElement(el) {\n // Anchor-pick mode (relative-xpath builder): consume this click as the\n // anchor and don't actually re-select. The pick handler does its own UI.\n if (anchorPickHandler && state.selected && el !== state.selected) {\n const handler = anchorPickHandler;\n endAnchorPick();\n handler(el);\n return;\n }\n // Note: we deliberately keep stickyRelative alive across selections.\n // fetchAndRenderLocators only re-injects the relative card when the\n // newly-selected element signature matches stickyRelative.elementSig\n // \u2014 so navigating away hides it and navigating back surfaces it.\n // The Dismiss button is the only thing that wipes it permanently.\n state.selected = el;\n // Invalidate stale Record-tab locator. fetchAndRenderLocators flips this\n // to 'resolving' immediately so the user sees the in-flight state.\n markLocatorResolving();\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n if (el.__nodeId) {\n const li = document.querySelector('li.node[data-id=\"' + el.__nodeId + '\"]');\n if (li) {\n li.classList.add('selected');\n li.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n }\n drawHighlight(el);\n renderAttrs(el);\n fetchAndRenderLocators(el);\n }\n\n function selectByXpath(xp) {\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === xp) { selectElement(el); return; }\n }\n }\n\n function getBounds(el) {\n if (state.platform === 'android') {\n const b = el.getAttribute('bounds');\n if (!b) return null;\n const m = b.match(/\\[(-?\\d+),(-?\\d+)\\]\\[(-?\\d+),(-?\\d+)\\]/);\n if (!m) return null;\n const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4];\n return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };\n }\n const x = +(el.getAttribute('x') ?? 0);\n const y = +(el.getAttribute('y') ?? 0);\n const w = +(el.getAttribute('width') ?? 0);\n const h = +(el.getAttribute('height') ?? 0);\n return { x, y, w, h };\n }\n\n function drawHighlight(el) {\n const b = getBounds(el);\n if (!b || b.w <= 0 || b.h <= 0) {\n $('highlight').style.display = 'none';\n return;\n }\n const img = $('screen-img');\n // Same isotropic scale as imgToDevice (inverse direction) so the highlight\n // tracks the screenshot, not a per-axis-distorted bounds projection.\n const scale = Math.min(img.clientWidth / state.viewport.w, img.clientHeight / state.viewport.h);\n const hl = $('highlight');\n hl.style.left = (b.x * scale) + 'px';\n hl.style.top = (b.y * scale) + 'px';\n hl.style.width = (b.w * scale) + 'px';\n hl.style.height = (b.h * scale) + 'px';\n hl.style.display = 'block';\n }\n\n // \u2500\u2500\u2500 Attributes panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function renderAttrs(el) {\n const rows = [];\n for (const a of Array.from(el.attributes)) {\n rows.push(\n '<tr><td>' + escapeHtml(a.name) + '</td><td>' +\n escapeHtml(truncate(a.value, 200)) + '</td></tr>',\n );\n }\n rows.push('<tr><td>xpath</td><td>' + escapeHtml(el.__xpath ?? '') + '</td></tr>');\n $('tab-attrs').innerHTML = '<table class=\"attrs\"><tbody>' + rows.join('') + '</tbody></table>';\n }\n\n // \u2500\u2500\u2500 Relative-xpath builder (anchor pick + path computation) \u2500\u2500\u2500\n /** When set, the next selectElement(...) becomes the anchor, not the new selection. */\n let anchorPickHandler = null;\n /**\n * Sticky relative xpath bound to the current selection. Survives snapshot\n * refreshes (re-injected after fetchAndRenderLocators) and is dismissed\n * either explicitly via the card's Dismiss button or implicitly when the\n * user selects a different element. Identified by element XPATH (not just\n * signature) \u2014 featureless Views all share an empty signature so xpath\n * is what actually distinguishes them.\n */\n let stickyRelative = null; // { elementXpath, elementSig, xpath, code, anchorLabel }\n\n function isStickyMatch(el) {\n if (!stickyRelative || !el) return false;\n return el.__xpath === stickyRelative.elementXpath\n && elementSignature(el) === stickyRelative.elementSig;\n }\n\n function startRelativeAnchorPick() {\n if (!state.selected) return;\n // Snapshot the target by xpath + signature so we can re-resolve it from\n // whichever tree is current when the anchor finally gets clicked. Holding\n // a direct reference would point at a stale XMLDocument if any refresh\n // (auto-refresh polling or post-action) parses a new doc in between \u2014\n // anchor and target would then live in different docs and share no\n // common ancestor.\n const targetXpath = state.selected.__xpath;\n const targetSig = elementSignature(state.selected);\n anchorPickHandler = (anchor) => {\n let target = null;\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === targetXpath && elementSignature(el) === targetSig) {\n target = el; break;\n }\n }\n if (!target) {\n showToast(\n 'The target element is no longer in the current page source. ' +\n 'Refresh and re-select it before building the relative xpath.',\n 'error',\n { title: 'Target lost' },\n );\n return;\n }\n buildRelativeLocator(target, anchor);\n };\n $('rec-pickhint-label').textContent =\n 'Pick the anchor element (must have a unique attribute like text, id, or content-desc).';\n $('rec-pickhint').style.display = 'flex';\n $('screen-host').classList.add('pick-mode');\n }\n function endAnchorPick() {\n anchorPickHandler = null;\n $('rec-pickhint').style.display = 'none';\n $('screen-host').classList.remove('pick-mode');\n }\n\n /** Walk anchor \u2192 root and target \u2192 root, find common ancestor, build a relative xpath. */\n function buildRelativePath(anchor, target) {\n function chain(el) {\n const out = [];\n for (let n = el; n; n = n.parentElement) out.unshift(n);\n return out;\n }\n const aChain = chain(anchor);\n const tChain = chain(target);\n let i = 0;\n while (i < aChain.length && i < tChain.length && aChain[i] === tChain[i]) i++;\n if (i === 0) return null;\n const stepsUp = aChain.length - i;\n let down = '';\n for (let j = i; j < tChain.length; j++) {\n const node = tChain[j];\n const parent = tChain[j - 1];\n const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n const idx = sibs.indexOf(node) + 1;\n down += '/' + node.tagName + (sibs.length > 1 ? '[' + idx + ']' : '');\n }\n let path = '';\n for (let k = 0; k < stepsUp; k++) path += '/..';\n return path + down;\n }\n\n async function buildRelativeLocator(target, anchor) {\n if (anchor === target) {\n showToast('Pick a different element as the anchor.', 'error', { title: 'Same element' });\n return;\n }\n setStatus('building relative xpath\u2026', true);\n try {\n const anchorAttrs = {};\n for (const a of Array.from(anchor.attributes)) anchorAttrs[a.name] = a.value;\n // Ask the server for the anchor's locator candidates and find a unique xpath.\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs: anchorAttrs, xpath: anchor.__xpath ?? '' }),\n });\n const { all } = await r.json();\n const xpathCandidates = (all || [])\n .filter((s) => s.using === 'xpath' && s.unique)\n .sort((a, b) => b.priority - a.priority);\n if (xpathCandidates.length === 0) {\n showToast(\n 'The chosen anchor has no unique xpath of its own. Try an element with text, id, or content-desc.',\n 'error',\n { title: 'Anchor not unique' },\n );\n return;\n }\n const anchorXpath = xpathCandidates[0].value;\n const relPath = buildRelativePath(anchor, target);\n if (relPath === null) {\n showToast('Anchor and target are not in the same tree.', 'error',\n { title: 'No relative path' });\n return;\n }\n const combined = anchorXpath + relPath;\n // Verify uniqueness on the live device.\n const vr = await fetch('/api/verify-xpath', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ xpath: combined }),\n });\n const verify = await vr.json();\n if (!verify.unique) {\n showToast(\n 'Relative xpath matches ' + verify.count + ' elements \u2014 not unique. ' +\n 'Try a closer anchor.', 'error', { title: 'Not unique' });\n return;\n }\n const code = 'mobile.getByXpath(' + JSON.stringify(combined) + ')';\n const anchorLabel = shortTag(anchor.tagName) +\n (pickIdent(anchor) ? ' \u00B7 ' + pickIdent(anchor) : '');\n // Persist for the current selection so post-action snapshot refreshes\n // can re-inject the card and re-promote the locator.\n stickyRelative = {\n elementXpath: state.selected.__xpath,\n elementSig: elementSignature(state.selected),\n xpath: combined,\n code,\n anchorLabel,\n };\n injectRelativeCard(combined, code, anchorLabel);\n promoteRelativeLocator(combined, code);\n setStatus('relative xpath built \u2713');\n } catch (err) {\n showToast(err.message, 'error', { title: 'Failed to build relative xpath' });\n }\n }\n\n /** Promote the relative xpath as the active Record-tab locator. */\n function promoteRelativeLocator(xpath, code) {\n setBestLocator({\n category: 'xpath',\n subLabel: 'relative',\n priority: 9999,\n code,\n using: 'xpath',\n value: xpath,\n unique: true,\n count: 1,\n });\n }\n\n /** Insert (or replace) the relative-xpath card at the top of the Locators tab. */\n function injectRelativeCard(xpath, code, anchorLabel) {\n const wrapper = document.createElement('div');\n wrapper.className = 'rel-card';\n wrapper.innerHTML =\n '<div class=\"anchor-line\">\u2693 anchored to <strong>' + escapeHtml(anchorLabel) + '</strong></div>' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge xpath\">XPath</span>' +\n '<span class=\"cat-sub\">relative path</span>' +\n '<span class=\"loc-spacer\"></span>' +\n '<span class=\"badge unique\">unique</span>' +\n '</div>' +\n '<div class=\"loc-code\"></div>' +\n '<div class=\"loc-actions\">' +\n '<button class=\"icon\" data-act=\"dismiss\">Dismiss</button>' +\n '</div>' +\n '<div class=\"rel-tip\">' +\n '<span class=\"ico\">\u26A0</span>' +\n '<div>' +\n '<strong>Heads-up:</strong> relative xpaths are fragile \u2014 they break ' +\n 'when the surrounding layout changes. Ask your mobile engineer to ' +\n 'add a stable identifier to this element ' +\n '(<code>testID</code> on React Native, <code>android:id</code> / ' +\n '<code>contentDescription</code> on Android, ' +\n '<code>accessibilityIdentifier</code> on iOS). Then you can switch ' +\n 'to <code>mobile.getById(...)</code> and the locator stays robust ' +\n 'across UI changes.' +\n '</div>' +\n '</div>';\n wrapper.querySelector('.loc-code').textContent = code;\n\n const tab = $('tab-locators');\n const existing = tab.querySelector(':scope > .rel-card');\n if (existing) existing.remove();\n tab.insertBefore(wrapper, tab.firstChild);\n\n wrapper.querySelector('[data-act=\"dismiss\"]').onclick = () => {\n wrapper.remove();\n stickyRelative = null;\n setBestLocator(null);\n };\n }\n\n // \u2500\u2500\u2500 Locators panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function fetchAndRenderLocators(el) {\n const seq = ++state.suggestSeq;\n setStatus('verifying locators\u2026', true);\n markLocatorResolving();\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\"><span class=\"rec-sel-spinner\"></span>Verifying locator uniqueness\u2026</div>';\n const attrs = {};\n for (const a of Array.from(el.attributes)) attrs[a.name] = a.value;\n attrs['__tag'] = (el.tagName || '').toLowerCase();\n try {\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs, xpath: el.__xpath ?? '' }),\n });\n if (seq !== state.suggestSeq) return; // newer request landed\n const { best, recommended } = await r.json();\n renderLocatorCards(best, recommended);\n // Tell the Record tab which locator to use for element-targeted actions.\n // Prefer the cross-category robust pick over the first per-category unique.\n setBestLocator(recommended || best.find((s) => s.unique) || null);\n // If the user previously built a sticky relative xpath for THIS exact\n // element (xpath + signature match), re-inject the card and re-promote\n // it as the active locator. xpath is required because featureless\n // Views all share an empty signature.\n if (isStickyMatch(state.selected)) {\n injectRelativeCard(stickyRelative.xpath, stickyRelative.code, stickyRelative.anchorLabel);\n promoteRelativeLocator(stickyRelative.xpath, stickyRelative.code);\n }\n setStatus('idle');\n } catch (err) {\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">Suggest error: ' + escapeHtml(err.message) + '</div>';\n setBestLocator(null);\n setStatus('error');\n }\n }\n\n function isTextInput(el) {\n if (!el) return false;\n if (state.platform === 'android') {\n const cls = el.getAttribute('class') || '';\n if (cls === 'android.widget.EditText') return true;\n if (cls.endsWith('.EditText')) return true;\n if (cls === 'android.widget.AutoCompleteTextView') return true;\n if ((el.getAttribute('text-entry-key') || '') === 'true') return true;\n if ((el.getAttribute('password') || '') === 'true') return true;\n return false;\n }\n const type = el.getAttribute('type') || '';\n return type === 'XCUIElementTypeTextField'\n || type === 'XCUIElementTypeSecureTextField'\n || type === 'XCUIElementTypeSearchField'\n || type === 'XCUIElementTypeTextView';\n }\n\n function renderLocatorCards(list, recommended) {\n if (!Array.isArray(list) || list.length === 0) {\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">No locator strategies found for this element.</div>';\n return;\n }\n // Recommended pick is cross-category \u2014 it may not even be in the\n // per-category best list. Surface it, then float it to the top so it\n // reads as the answer regardless of the id/uiautomator/xpath order.\n const cards_src = list.slice();\n const recCode = recommended ? recommended.code : null;\n if (recCode && !cards_src.some((s) => s.code === recCode)) {\n cards_src.unshift(recommended);\n }\n if (recCode) {\n const ri = cards_src.findIndex((s) => s.code === recCode);\n if (ri > 0) {\n const rec = cards_src.splice(ri, 1)[0];\n cards_src.unshift(rec);\n }\n }\n const showType = isTextInput(state.selected);\n const typeTarget =\n (recommended && recommended.unique && recommended) ||\n cards_src.find((s) => s.unique) ||\n null;\n const typeHtml = (showType && typeTarget)\n ? '<div class=\"type-card\">' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge id\">Type</span>' +\n '<span class=\"cat-sub\">into this field via ' +\n escapeHtml(labelForCategory(typeTarget.category)) + '</span>' +\n '</div>' +\n '<div class=\"type-row\">' +\n '<input class=\"type-input\" id=\"type-input\" placeholder=\"text to type\u2026\" />' +\n '<button class=\"icon\" id=\"btn-type-send\">Send</button>' +\n '</div>' +\n '<div class=\"type-hint\">\u21B5 Enter to send \u00B7 clears the field first, like ' +\n '<code>.fill()</code></div>' +\n '</div>'\n : (showType\n ? '<div class=\"type-card\"><div class=\"cat-sub\">' +\n 'Text input detected, but no unique locator yet \u2014 pick one below.' +\n '</div></div>'\n : '');\n const cards = cards_src.map((s, i) => {\n // Positional = synthesized .nth(i). Unique right now but index-fragile;\n // badge it distinctly so it doesn't read as confidently as a stable\n // attribute locator. (descriptor.kind === 'nth' is the only producer.)\n const positional = !!(s.descriptor && s.descriptor.kind === 'nth');\n const isRec = !!(recCode && s.code === recCode);\n const badgeHtml = !s.unique\n ? (s.count > 1\n ? '<span class=\"badge collision\">' + s.count + ' matches</span>'\n : '<span class=\"badge empty\">no match</span>')\n : positional\n ? '<span class=\"badge positional\">positional \u00B7 fragile</span>'\n : '<span class=\"badge unique\">unique</span>';\n const recHtml = isRec\n ? '<span class=\"badge recommended\">\u2605 Recommended</span>'\n : '';\n const catLabel = labelForCategory(s.category);\n return (\n '<div class=\"loc-card' + (isRec ? ' is-rec' : '') + '\" data-i=\"' + i + '\">' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge ' + s.category + '\">' + escapeHtml(catLabel) + '</span>' +\n '<span class=\"cat-sub\">' + escapeHtml(s.subLabel) + '</span>' +\n '<span class=\"loc-spacer\"></span>' +\n recHtml + badgeHtml +\n '</div>' +\n '<div class=\"loc-code\">' + escapeHtml(s.code) + '</div>' +\n '</div>'\n );\n }).join('');\n // Affordance for building a relative xpath when the existing locators\n // aren't a fit (no unique, or user wants a more semantic anchor).\n const buildRelHtml =\n '<button class=\"build-rel-btn\" id=\"btn-build-rel\" type=\"button\">' +\n '<span class=\"ico\">\u2693</span>' +\n '<span class=\"body\">' +\n '<span class=\"title\">Build a relative xpath</span>' +\n '<span class=\"sub\">Pick another element as an anchor \u2014 taqwright will compute a path-walking xpath rooted at it.</span>' +\n '</span>' +\n '</button>';\n\n $('tab-locators').innerHTML = typeHtml + cards + buildRelHtml;\n $('btn-build-rel').onclick = startRelativeAnchorPick;\n\n if (showType && typeTarget) {\n const sendType = async () => {\n const inp = $('type-input');\n const text = inp.value;\n if (!text) { inp.focus(); return; }\n setStatus('typing\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n kind: 'fill',\n using: typeTarget.using,\n value: typeTarget.value,\n descriptor: typeTarget.descriptor,\n code: typeTarget.code,\n text,\n }),\n });\n if (!r.ok) {\n const j = await r.json().catch(() => ({}));\n throw new Error(j.error || ('HTTP ' + r.status));\n }\n inp.value = '';\n await refreshScript();\n setTimeout(fetchSnapshot, 300);\n setStatus('typed');\n } catch (err) {\n setStatus('type error: ' + err.message);\n }\n };\n $('btn-type-send').onclick = sendType;\n $('type-input').addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') { ev.preventDefault(); sendType(); }\n });\n }\n\n }\n\n function labelForCategory(c) {\n return ({\n id: 'ID',\n uiautomator: 'UIAutomator',\n predicate: 'NSPredicate',\n classChain: 'Class Chain',\n xpath: 'XPath',\n })[c] || c;\n }\n\n // \u2500\u2500\u2500 Pointer events on the screen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function imgToDevice(ev) {\n const img = $('screen-img');\n const rect = img.getBoundingClientRect();\n // One isotropic scale from the inset-free axis. The screenshot can be\n // taller/wider than the logical bounds space when it includes a system-bar\n // inset (e.g. BrowserStack Android nav bar); scaling each axis on its own\n // then distorts the off-inset axis and shifts hit-testing to a neighbour.\n // max() picks the axis with no inset (its bounds dimension isn't shrunk).\n const scale = Math.max(state.viewport.w / rect.width, state.viewport.h / rect.height);\n return {\n x: Math.round((ev.clientX - rect.left) * scale),\n y: Math.round((ev.clientY - rect.top) * scale),\n };\n }\n\n $('screen-img').addEventListener('mouseup', (ev) => {\n const pt = imgToDevice(ev);\n // Pick mode (Record tab) takes priority \u2014 consume one click then dismiss.\n if (pickHandler) {\n const handler = pickHandler;\n cancelPickMode();\n handler(pt);\n return;\n }\n // Default: clicking the screen selects the element under the cursor.\n const hit = findHit(pt.x, pt.y);\n if (hit) selectElement(hit);\n });\n\n /** Does this element have any attribute that the locator suggester can use? */\n function hasUsefulAttrs(el) {\n return !!(\n el.getAttribute('resource-id') ||\n el.getAttribute('content-desc') ||\n el.getAttribute('text') ||\n el.getAttribute('hint') ||\n el.getAttribute('name') ||\n el.getAttribute('label') ||\n el.getAttribute('value') ||\n el.getAttribute('placeholderValue')\n );\n }\n\n /** BFS the subtree under root to find the closest descendant with a useful attribute. */\n function findUsefulDescendant(root) {\n const queue = Array.from(root.children);\n while (queue.length > 0) {\n const el = queue.shift();\n if (hasUsefulAttrs(el)) return el;\n for (const c of Array.from(el.children)) queue.push(c);\n }\n return null;\n }\n\n function findHit(x, y) {\n let smallest = null;\n let smallestArea = Infinity;\n for (const [, el] of state.nodeMap) {\n const b = getBounds(el);\n if (!b || b.w <= 0 || b.h <= 0) continue;\n if (x < b.x || y < b.y || x > b.x + b.w || y > b.y + b.h) continue;\n const area = b.w * b.h;\n if (area < smallestArea) { smallestArea = area; smallest = el; }\n }\n if (!smallest) return null;\n // If the innermost hit is a featureless wrapper (common in React Native /\n // Flutter / SwiftUI views), reach into its subtree for a child with\n // identifying attributes \u2014 otherwise the Record tab actions stay disabled\n // because no unique locator can be built.\n if (hasUsefulAttrs(smallest)) return smallest;\n return findUsefulDescendant(smallest) ?? smallest;\n }\n\n\n // Tabs.\n document.querySelectorAll('.tab').forEach((t) => {\n t.onclick = () => {\n document.querySelectorAll('.tab').forEach((x) => x.classList.remove('active'));\n t.classList.add('active');\n for (const k of ['record', 'script', 'locators', 'attrs']) {\n $('tab-' + k).classList.toggle('hidden', k !== t.dataset.tab);\n }\n if (t.dataset.tab === 'script') refreshScript();\n };\n });\n\n // Record subtabs (Actions / Screen / Assertions) \u2014 independent of the\n // top-level .tab bar; panes live inside #tab-record so all the existing\n // #tab-record handlers/selectors keep matching.\n document.querySelectorAll('.rec-subtab').forEach((t) => {\n t.onclick = () => {\n document.querySelectorAll('.rec-subtab').forEach((x) => x.classList.remove('active'));\n t.classList.add('active');\n for (const k of ['actions', 'screen', 'assert']) {\n $('rec-pane-' + k).classList.toggle('hidden', k !== t.dataset.subtab);\n }\n };\n });\n\n // \u2500\u2500\u2500 Hierarchy view-mode toggle (XML / Tree) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Tree is the default \u2014 the structured view is easier to scan; XML is opt-in.\n let hierarchyMode = 'tree';\n function setHierarchyMode(mode) {\n hierarchyMode = mode;\n document.querySelectorAll('.hier-mode-btn').forEach((b) => {\n b.classList.toggle('active', b.dataset.hierMode === mode);\n });\n const treeBody = document.getElementById('hier-tree-body');\n const xmlBody = document.getElementById('hier-xml-body');\n // The filter field stays visible in both modes; re-apply it for the mode\n // we're switching into so highlights stay correct.\n if (mode === 'xml') {\n treeBody.style.display = 'none';\n xmlBody.style.display = '';\n refreshHierarchyXml();\n } else {\n treeBody.style.display = '';\n xmlBody.style.display = 'none';\n applyTreeFilter($('tree-search').value);\n }\n }\n document.querySelectorAll('.hier-mode-btn').forEach((b) => {\n b.onclick = () => setHierarchyMode(b.dataset.hierMode);\n });\n\n // \u2500\u2500\u2500 Context (Native / WebView) selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function contextLabel(ctx) {\n if (ctx === 'NATIVE_APP') return 'Native';\n // WEBVIEW_com.example \u2192 'WebView (com.example)'\n const m = ctx.match(/^WEBVIEW_?(.*)$/i);\n return m && m[1] ? 'WebView (' + m[1] + ')' : 'WebView';\n }\n\n function applyContextUi() {\n const sel = document.getElementById('context-select');\n if (!sel) return;\n sel.classList.toggle('web', isWebContext());\n }\n\n async function refreshContexts() {\n const sel = document.getElementById('context-select');\n if (!sel) return;\n try {\n const r = await fetch('/api/contexts');\n if (!r.ok) return;\n const j = await r.json();\n const contexts = Array.isArray(j.contexts) && j.contexts.length\n ? j.contexts : ['NATIVE_APP'];\n state.context = j.current || state.context || 'NATIVE_APP';\n sel.innerHTML = '';\n for (const ctx of contexts) {\n const opt = document.createElement('option');\n opt.value = ctx;\n opt.textContent = contextLabel(ctx);\n if (ctx === state.context) opt.selected = true;\n sel.appendChild(opt);\n }\n sel.classList.remove('hidden');\n // Surface a hint when the device exposes no WebView \u2014 e.g. an Android\n // WebView that isn't debuggable, so it never appears as a context.\n const hasWeb = contexts.some(function (c) { return /^WEBVIEW/i.test(c); });\n const hint = document.getElementById('context-hint');\n if (hint) hint.classList.toggle('hidden', hasWeb);\n applyContextUi();\n } catch {\n // No session / driver error \u2014 leave the selector as-is.\n }\n }\n\n {\n const sel = document.getElementById('context-select');\n if (sel) {\n // Contexts appear only after the WebView finishes loading, so refresh\n // the list lazily when the user opens the dropdown rather than polling.\n sel.addEventListener('mousedown', () => { refreshContexts(); });\n sel.addEventListener('change', async () => {\n const target = sel.value;\n if (target === state.context) return;\n setStatus('switching context\u2026', true);\n try {\n const r = await fetch('/api/context', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ context: target }),\n });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n state.context = j.current || target;\n applyContextUi();\n // A successful switch means a WebView context exists \u2014 drop the hint.\n const hint = document.getElementById('context-hint');\n if (hint) hint.classList.add('hidden');\n clearSelection();\n state.sourceXml = '';\n await fetchSnapshot({ force: true });\n showToast('Now in ' + contextLabel(state.context), 'success',\n { title: 'Context switched' });\n } catch (err) {\n showToast(err.message, 'error', { title: 'Context switch failed' });\n // Revert the dropdown to the still-active context.\n refreshContexts();\n } finally {\n setStatus('idle');\n }\n });\n }\n const hint = document.getElementById('context-hint');\n if (hint) {\n const explain = function () {\n // Re-check in case the WebView just finished loading and now appears.\n refreshContexts();\n const android =\n 'No WebView context found. The app\\'s WebView must be debuggable \u2014 ' +\n 'call WebView.setWebContentsDebuggingEnabled(true) (automatic in ' +\n 'debuggable builds). To switch into it, Appium also needs ' +\n 'chromedriver: enable appium:chromedriverAutodownload or set ' +\n 'appium:chromedriverExecutable. Note: Chrome Custom Tabs / external ' +\n 'browsers won\\'t appear as a context.';\n const ios =\n 'No WebView context found. Ensure the WebView has loaded; on iOS, ' +\n 'Safari Web Inspector / WKWebView inspection must be enabled for the ' +\n 'app or device.';\n const msg = state.platform === 'ios' ? ios : android;\n showToast(msg, 'info', { title: 'WebView not detected', ttl: 0 });\n };\n hint.addEventListener('click', explain);\n hint.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); explain(); }\n });\n }\n }\n\n function refreshHierarchyXml() {\n applyXmlFilter($('tree-search').value);\n }\n // Highlight (not hide) substring matches in the XML view \u2014 parity with the\n // tree filter. Empty query renders the plain source.\n function applyXmlFilter(q) {\n const pre = document.getElementById('hier-xml-pre');\n if (!pre) return;\n const xml = state.sourceXml || '';\n q = (q || '').trim();\n if (!q) {\n pre.textContent = xml;\n return;\n }\n const lx = xml.toLowerCase();\n const lq = q.toLowerCase();\n let html = '';\n let idx = 0;\n let pos = lx.indexOf(lq);\n while (pos !== -1) {\n html +=\n escapeHtml(xml.slice(idx, pos)) +\n '<mark class=\"xml-match\">' +\n escapeHtml(xml.slice(pos, pos + q.length)) +\n '</mark>';\n idx = pos + q.length;\n pos = lx.indexOf(lq, idx);\n }\n html += escapeHtml(xml.slice(idx));\n pre.innerHTML = html;\n }\n\n // \u2500\u2500\u2500 Record tab wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** When set, the next click on the screen completes a coordinate-targeted action. */\n let pickHandler = null;\n\n function startPickMode(label, onPick) {\n pickHandler = onPick;\n $('rec-pickhint-label').textContent = label;\n $('rec-pickhint').style.display = 'flex';\n $('screen-host').classList.add('pick-mode');\n }\n function cancelPickMode() {\n pickHandler = null;\n $('rec-pickhint').style.display = 'none';\n $('screen-host').classList.remove('pick-mode');\n }\n $('btn-rec-cancel').onclick = cancelPickMode;\n\n /** Best-unique locator for the currently selected element, if any. */\n let bestLocatorForSelected = null;\n /** 'idle' | 'resolving' | 'resolved' \u2014 what state the locator suggestion is in. */\n let locatorState = 'idle';\n\n function setBestLocator(s) {\n bestLocatorForSelected = s;\n locatorState = 'resolved';\n refreshRecordButtons();\n }\n function markLocatorResolving() {\n bestLocatorForSelected = null;\n locatorState = 'resolving';\n refreshRecordButtons();\n }\n function clearLocatorState() {\n bestLocatorForSelected = null;\n locatorState = 'idle';\n refreshRecordButtons();\n }\n\n /** Enable/disable element-action buttons based on whether we have a selection + unique locator. */\n function refreshRecordButtons() {\n const hasUnique = !!(bestLocatorForSelected && bestLocatorForSelected.unique);\n\n // Selected-element card.\n const card = $('rec-selected');\n const titleEl = $('rec-sel-title');\n const subEl = $('rec-sel-sub');\n const iconEl = $('rec-sel-icon');\n if (state.selected) {\n const tag = shortTag(state.selected.tagName);\n const ident = pickIdent(state.selected);\n titleEl.textContent = ident ? tag + ' \u00B7 ' + ident : tag;\n if (hasUnique) {\n iconEl.textContent = '\u2713';\n } else if (locatorState === 'resolving') {\n iconEl.innerHTML = '<span class=\"rec-sel-spinner\"></span>';\n } else {\n iconEl.textContent = '\u26A0';\n }\n if (hasUnique) {\n card.classList.add('has');\n subEl.textContent = bestLocatorForSelected.code;\n } else {\n card.classList.remove('has');\n if (locatorState === 'resolving') {\n subEl.innerHTML = 'Resolving locator\u2026' +\n (isCloudMode()\n ? '<span class=\"rec-resolving-hint\">Verifying candidates against the cloud device \u2014 this can take a few seconds.</span>'\n : '');\n } else {\n // No unique locator: render an inline Build-relative-xpath button\n // here so the user doesn't have to leave the Record tab.\n subEl.innerHTML =\n '<div class=\"rec-no-unique\">No unique locator for this element. Anchor it against a nearby element instead:</div>' +\n '<button class=\"build-rel-btn\" id=\"btn-build-rel-record\" type=\"button\">' +\n '<span class=\"ico\">\u2693</span>' +\n '<span class=\"body\">' +\n '<span class=\"title\">Build a relative xpath</span>' +\n '<span class=\"sub\">Pick another element as an anchor \u2014 taqwright will compute a path-walking xpath rooted at it.</span>' +\n '</span>' +\n '</button>';\n const btn = document.getElementById('btn-build-rel-record');\n if (btn) {\n btn.onclick = (e) => { e.stopPropagation(); startRelativeAnchorPick(); };\n }\n }\n }\n } else {\n card.classList.remove('has');\n iconEl.textContent = '\u25CB';\n titleEl.textContent = 'No element selected';\n subEl.textContent = 'Tap an element on the screen or in the Hierarchy.';\n }\n\n // Element action buttons.\n document.querySelectorAll('#tab-record .rec-act[data-act]').forEach((btn) => {\n btn.disabled = !hasUnique;\n });\n $('btn-rec-type').disabled = !hasUnique;\n $('btn-rec-clear').disabled = !hasUnique;\n $('rec-type-input').disabled = !hasUnique;\n $('btn-rec-seq').disabled = !hasUnique;\n $('rec-seq-input').disabled = !hasUnique;\n $('rec-seq-delay').disabled = !hasUnique;\n $('btn-rec-press').disabled = !hasUnique;\n $('rec-press-key').disabled = !hasUnique;\n $('btn-rec-select').disabled = !hasUnique;\n $('rec-select-label').disabled = !hasUnique;\n document.querySelectorAll('#tab-record .rec-act[data-assert]').forEach((btn) => {\n btn.disabled = !hasUnique;\n });\n $('rec-assert-text').disabled = !hasUnique;\n $('rec-assert-value').disabled = !hasUnique;\n $('rec-assert-count').disabled = !hasUnique;\n $('rec-assert-attr-name').disabled = !hasUnique;\n $('rec-assert-attr-value').disabled = !hasUnique;\n // Pre-fill the text/value inputs from the currently-selected element so\n // the user just confirms what's there. Read straight from the parsed\n // page source \u2014 no extra device round-trip.\n if (hasUnique && state.selected) {\n const t = state.selected.getAttribute('text') ||\n state.selected.getAttribute('label') ||\n state.selected.getAttribute('name') || '';\n const v = state.selected.getAttribute('value') || '';\n $('rec-assert-text').value = t;\n $('rec-assert-value').value = v;\n }\n }\n\n // \u2500\u2500\u2500 Action progress overlay (over the device screenshot) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Bridges the gap between clicking an action and the screen updating: a veil\n // with a per-action label while the device works, then a brief success \u2713.\n let actionInFlight = false;\n function actionLabel(kind) {\n const m = { click: 'Tapping\u2026', doubleTap: 'Double-tapping\u2026', longPress: 'Long-pressing\u2026',\n fill: 'Typing\u2026', clear: 'Clearing\u2026', swipe: 'Swiping\u2026', scrollIntoView: 'Scrolling\u2026',\n pinch: 'Pinching\u2026', check: 'Checking\u2026', uncheck: 'Unchecking\u2026', focus: 'Focusing\u2026',\n blur: 'Blurring\u2026', press: 'Pressing key\u2026', pressSequentially: 'Typing\u2026',\n selectOption: 'Selecting\u2026', dragTo: 'Dragging\u2026', scroll: 'Scrolling\u2026' };\n return m[kind] || 'Performing action\u2026';\n }\n function beginAction(label) {\n if (actionInFlight) return false; // ignore re-entrant clicks\n actionInFlight = true;\n const el = $('screen-action-overlay');\n el.classList.remove('done');\n $('screen-action-label').textContent = label;\n el.classList.add('shown');\n el.setAttribute('aria-hidden', 'false');\n setStatus('action\u2026', true);\n return true;\n }\n function endActionSuccess() {\n const el = $('screen-action-overlay');\n el.classList.add('done');\n $('screen-action-label').textContent = 'Done';\n setTimeout(() => { el.classList.remove('shown'); el.setAttribute('aria-hidden', 'true'); }, 700);\n setStatus('done');\n actionInFlight = false;\n }\n function endActionError(msg) {\n const el = $('screen-action-overlay');\n el.classList.remove('shown');\n el.setAttribute('aria-hidden', 'true');\n setStatus('action error: ' + msg);\n showToast(msg, 'error', { title: 'Action failed' });\n actionInFlight = false;\n }\n\n async function postLocatorAction(extra) {\n if (!bestLocatorForSelected) return;\n if (!beginAction(actionLabel(extra.kind))) return;\n const s = bestLocatorForSelected;\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n using: s.using, value: s.value, descriptor: s.descriptor, code: s.code, ...extra,\n }),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n await new Promise((res) => setTimeout(res, 300)); // let the device settle\n await fetchSnapshot({ force: true });\n endActionSuccess();\n } catch (err) {\n endActionError(err.message);\n }\n }\n async function postScreenAction(body) {\n if (!beginAction(actionLabel(body.kind))) return;\n try {\n const r = await fetch('/api/screen-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n await new Promise((res) => setTimeout(res, 300)); // let the device settle\n await fetchSnapshot({ force: true });\n endActionSuccess();\n } catch (err) {\n endActionError(err.message);\n }\n }\n\n // Resolve the element under a device point to its best UNIQUE locator\n // suggestion, without touching the current selection / Record-tab state.\n // Mirrors fetchAndRenderLocators' attrs/xpath/suggest pipeline. Returns the\n // suggestion ({ code, using, value, descriptor, unique }) or null when no\n // uniquely-locatable element sits there.\n async function resolveUniqueLocatorAt(pt) {\n const el = findHit(pt.x, pt.y);\n if (!el) return null;\n const attrs = {};\n for (const a of Array.from(el.attributes)) attrs[a.name] = a.value;\n attrs['__tag'] = (el.tagName || '').toLowerCase();\n try {\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs, xpath: el.__xpath ?? '' }),\n });\n if (!r.ok) return null;\n const { best, recommended } = await r.json();\n const pick = recommended || (best || []).find((s) => s.unique) || null;\n return (pick && pick.unique) ? pick : null;\n } catch {\n return null;\n }\n }\n\n // Drive + record an element-to-element drag. Both src and target are\n // locator suggestions; renders as await <src>.dragTo(<target>).\n async function postDragTo(src, target) {\n setStatus('action\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n kind: 'dragTo',\n using: src.using, value: src.value,\n descriptor: src.descriptor, code: src.code,\n target: {\n using: target.using, value: target.value,\n descriptor: target.descriptor, code: target.code,\n },\n }),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n setTimeout(fetchSnapshot, 300);\n setStatus('done');\n } catch (err) {\n setStatus('action error: ' + err.message);\n }\n }\n\n // Element-action button delegation.\n document.querySelectorAll('#tab-record .rec-act[data-act]').forEach((btn) => {\n btn.onclick = () => {\n const act = btn.dataset.act;\n if (!bestLocatorForSelected) return;\n switch (act) {\n case 'click': return postLocatorAction({ kind: 'click' });\n case 'doubleTap': return postLocatorAction({ kind: 'doubleTap' });\n case 'longPress': return postLocatorAction({ kind: 'longPress' });\n case 'check': return postLocatorAction({ kind: 'check' });\n case 'uncheck': return postLocatorAction({ kind: 'uncheck' });\n case 'focus': return postLocatorAction({ kind: 'focus' });\n case 'blur': return postLocatorAction({ kind: 'blur' });\n case 'swipe-left': return postLocatorAction({ kind: 'swipe', direction: 'left' });\n case 'swipe-right': return postLocatorAction({ kind: 'swipe', direction: 'right' });\n case 'swipe-up': return postLocatorAction({ kind: 'swipe', direction: 'up' });\n case 'swipe-down': return postLocatorAction({ kind: 'swipe', direction: 'down' });\n case 'scrollIntoView': return postLocatorAction({ kind: 'scrollIntoView' });\n case 'pinch-in': return postLocatorAction({ kind: 'pinch', direction: 'in' });\n case 'pinch-out': return postLocatorAction({ kind: 'pinch', direction: 'out' });\n case 'dragToPoint': {\n // Source is the selected element (button is gated on a unique\n // locator). Drop target must resolve to a uniquely-locatable\n // element too \u2014 on a miss, re-arm pick mode so the user retries.\n const src = bestLocatorForSelected;\n const pickTarget = () => startPickMode('Click the drop target element.', async (pt) => {\n const target = await resolveUniqueLocatorAt(pt);\n if (!target) {\n setStatus('No uniquely-locatable element there \u2014 pick another drop target');\n pickTarget();\n return;\n }\n postDragTo(src, target);\n });\n pickTarget();\n return;\n }\n }\n };\n });\n\n $('btn-rec-seq').onclick = () => {\n const text = $('rec-seq-input').value;\n if (!text) { $('rec-seq-input').focus(); return; }\n const delayStr = $('rec-seq-delay').value;\n const delay = delayStr ? parseInt(delayStr, 10) : undefined;\n const extra = (delay && delay > 0) ? { kind: 'pressSequentially', text, delay } : { kind: 'pressSequentially', text };\n postLocatorAction(extra).then(() => {\n $('rec-seq-input').value = '';\n });\n };\n\n $('btn-rec-press').onclick = () => {\n const key = $('rec-press-key').value;\n if (!key) return;\n postLocatorAction({ kind: 'press', key });\n };\n\n $('btn-rec-select').onclick = () => {\n const label = $('rec-select-label').value;\n if (!label) { $('rec-select-label').focus(); return; }\n postLocatorAction({ kind: 'selectOption', value: { label } }).then(() => {\n $('rec-select-label').value = '';\n });\n };\n\n $('btn-rec-type').onclick = () => {\n const text = $('rec-type-input').value;\n if (!text) { $('rec-type-input').focus(); return; }\n postLocatorAction({ kind: 'fill', text }).then(() => {\n $('rec-type-input').value = '';\n });\n };\n $('rec-type-input').addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') { ev.preventDefault(); $('btn-rec-type').click(); }\n });\n $('btn-rec-clear').onclick = () => {\n postLocatorAction({ kind: 'clear' }).then(() => {\n $('rec-type-input').value = '';\n });\n };\n\n // \u2500\u2500\u2500 Custom Y range for screen scroll up/down \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /**\n * Read the user's top% and bottom% inputs and map them into direction-\n * aware fromY/toY fractions. Recorded code uses from.y for the finger\n * start and to.y for the finger end \u2014 same convention as Mobile.swipe.\n * Empty inputs \u2192 no overrides.\n */\n function readScrollYRange(direction) {\n const topRaw = $('rec-scroll-top').value.trim();\n const botRaw = $('rec-scroll-bottom').value.trim();\n const xRaw = $('rec-scroll-x').value.trim();\n const out = {};\n\n // Y range: top%/bottom% \u2192 direction-aware fromY/toY (finger start/end).\n if (topRaw !== '' || botRaw !== '') {\n const topPct = topRaw === '' ? null : Math.max(0, Math.min(100, Number(topRaw)));\n const botPct = botRaw === '' ? null : Math.max(0, Math.min(100, Number(botRaw)));\n if (topPct !== null || botPct !== null) {\n const top = (topPct ?? 0) / 100;\n const bot = (botPct ?? 100) / 100;\n // For scroll('down') the finger moves UP across the region: from y=bot to y=top.\n // For scroll('up') the finger moves DOWN across the region: from y=top to y=bot.\n if (direction === 'down') { out.fromY = bot; out.toY = top; }\n else if (direction === 'up') { out.fromY = top; out.toY = bot; }\n }\n }\n\n // X anchor: single value where the vertical scroll happens horizontally.\n if (xRaw !== '') {\n const xPct = Math.max(0, Math.min(100, Number(xRaw)));\n if (!Number.isNaN(xPct)) {\n const x = xPct / 100;\n out.fromX = x;\n out.toX = x;\n }\n }\n\n return out;\n }\n $('btn-rec-y-clear').onclick = () => {\n $('rec-scroll-top').value = '';\n $('rec-scroll-bottom').value = '';\n $('rec-scroll-x').value = '';\n };\n\n // \u2500\u2500\u2500 Assert-action button delegation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /**\n * Send an assertion to the server. The server runs a short verify check\n * against the live device first; if it would fail, we surface a \"Record\n * anyway\" toast and re-post with force=true on confirmation.\n */\n async function postAssertion(opts) {\n if (!bestLocatorForSelected) return;\n const s = bestLocatorForSelected;\n const body = {\n kind: 'assertion',\n using: s.using, value: s.value, descriptor: s.descriptor, code: s.code,\n matcher: opts.matcher,\n expected: opts.expected,\n expectedCount: opts.expectedCount,\n attrName: opts.attrName,\n mode: opts.mode,\n force: !!opts.force,\n };\n setStatus('verifying assertion\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n if (j.recorded) {\n setStatus('asserted \u2713');\n await refreshScript();\n showToast('Assertion recorded', 'success', { title: 'Recorded' });\n } else if (!j.verified) {\n const got = j.actual !== undefined ? ' (got: ' + JSON.stringify(j.actual) + ')' : '';\n const dismiss = showToast(\n 'This assertion would fail right now' + got + '. Record it anyway?',\n 'error',\n { title: 'Assertion would fail', ttl: 0 },\n );\n // Patch the toast to add a \"Record anyway\" button that re-posts with force.\n const cont = $('toasts');\n const lastToast = cont.querySelector('.toast.error:last-child');\n if (lastToast) {\n const btn = document.createElement('button');\n btn.className = 'icon';\n btn.style.marginLeft = '4px';\n btn.textContent = 'Record anyway';\n btn.onclick = () => {\n dismiss();\n postAssertion({ ...opts, force: true });\n };\n const body = lastToast.querySelector('.body');\n if (body) body.appendChild(btn);\n }\n } else {\n // Verified but not recorded \u2014 recording is off.\n showToast('Recording is off \u2014 Start record first.', 'info', { title: 'Not recorded' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Assertion failed' });\n }\n }\n\n document.querySelectorAll('#tab-record .rec-act[data-assert]').forEach((btn) => {\n btn.onclick = () => {\n const which = btn.dataset.assert;\n switch (which) {\n case 'visible':\n case 'hidden':\n case 'enabled':\n case 'disabled':\n case 'checked':\n case 'unchecked':\n case 'editable':\n case 'readonly':\n case 'focused':\n case 'attached':\n case 'empty':\n case 'inViewport':\n return postAssertion({ matcher: which });\n case 'text-exact': {\n const expected = $('rec-assert-text').value;\n return postAssertion({ matcher: 'text', expected, mode: 'exact' });\n }\n case 'text-contains': {\n const expected = $('rec-assert-text').value;\n return postAssertion({ matcher: 'text', expected, mode: 'contains' });\n }\n case 'value': {\n const expected = $('rec-assert-value').value;\n return postAssertion({ matcher: 'value', expected });\n }\n case 'count': {\n const raw = $('rec-assert-count').value;\n const expectedCount = parseInt(raw, 10);\n if (Number.isNaN(expectedCount)) {\n setStatus('count assertion needs a number');\n return;\n }\n return postAssertion({ matcher: 'count', expectedCount });\n }\n case 'attribute': {\n const attrName = $('rec-assert-attr-name').value.trim();\n const expected = $('rec-assert-attr-value').value;\n if (!attrName) {\n setStatus('attribute assertion needs a name');\n return;\n }\n return postAssertion({ matcher: 'attribute', attrName, expected });\n }\n }\n };\n });\n\n // Screen-action button delegation.\n document.querySelectorAll('#tab-record .rec-act[data-screen]').forEach((btn) => {\n btn.onclick = () => {\n const act = btn.dataset.screen;\n switch (act) {\n case 'scroll-up': return postScreenAction({ kind: 'scroll', direction: 'up', ...readScrollYRange('up') });\n case 'scroll-down': return postScreenAction({ kind: 'scroll', direction: 'down', ...readScrollYRange('down') });\n case 'tap-point':\n startPickMode('Click the screen where the tap should land.', (pt) => {\n // Use existing /api/tap (already records as 'tap').\n fetch('/api/tap', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(pt),\n }).then(refreshScript).then(() => setTimeout(fetchSnapshot, 300));\n });\n return;\n case 'drag-and-drop': {\n // Both endpoints must resolve to uniquely-locatable elements;\n // re-arm the relevant pick step on a miss. Records as\n // await <src>.dragTo(<target>).\n const pickSrc = () => startPickMode('Click the element to drag.', async (p1) => {\n const src = await resolveUniqueLocatorAt(p1);\n if (!src) {\n setStatus('No uniquely-locatable element there \u2014 pick another source');\n pickSrc();\n return;\n }\n const pickTgt = () => startPickMode('Now click the drop target element.', async (p2) => {\n const target = await resolveUniqueLocatorAt(p2);\n if (!target) {\n setStatus('No uniquely-locatable element there \u2014 pick another drop target');\n pickTgt();\n return;\n }\n postDragTo(src, target);\n });\n pickTgt();\n });\n pickSrc();\n return;\n }\n }\n };\n });\n\n // \u2500\u2500\u2500 Recording start/stop toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let recording = false;\n function applyRecordingState(on) {\n recording = !!on;\n const banner = $('rec-toggle');\n const status = $('rec-status');\n const btn = $('btn-rec-toggle');\n const label = $('btn-rec-toggle-label');\n banner.classList.toggle('live', recording);\n btn.classList.toggle('stop', recording);\n if (recording) {\n status.innerHTML = '<strong>Recording</strong> \u2014 every action below is appended to the script.';\n label.textContent = 'Stop record';\n } else {\n status.innerHTML = \"<strong>Not recording</strong> \u2014 press Start to capture actions as a script.\";\n label.textContent = 'Start record';\n }\n }\n $('btn-rec-toggle').onclick = async () => {\n const next = !recording;\n const path = next ? '/api/recording/start' : '/api/recording/stop';\n try {\n const r = await fetch(path, { method: 'POST' });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n const wasRecording = recording;\n applyRecordingState(j.recording);\n await refreshScript();\n // Stop transition: surface a confirmation that the script is captured.\n if (wasRecording && !j.recording) {\n if (lastSpec) {\n // Use action-line count (everything between the test() body braces)\n // as a rough \"N actions recorded\" hint.\n const actionLines = lastSpec.split('\\n').filter((l) => /^\\s*await\\s/.test(l)).length;\n const actionLabel = actionLines + (actionLines === 1 ? ' action' : ' actions');\n showToast(\n 'Recording stopped \u2014 ' + actionLabel + ' captured. Use \u2193 Export or \u2398 Copy from the Recorded script tab.',\n 'success',\n { title: 'Script saved' },\n );\n } else {\n showToast(\n 'Recording stopped \u2014 no actions were captured.',\n 'info',\n { title: 'Nothing recorded' },\n );\n }\n } else if (!wasRecording && j.recording) {\n showToast('Recording \u2014 every action you take will append to the script.', 'info', { title: 'Recording' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Recording toggle failed' });\n }\n };\n\n /** Cache the most-recent unstyled spec so Copy doesn't paste highlighted HTML. */\n let lastSpec = '';\n // Target language for the Recorded-script tab: 'ts' (default, taqwright) |\n // 'python' (Appium-Python-Client) | 'java' (Appium java-client).\n let scriptLang = 'ts';\n function defaultScriptName() {\n return scriptLang === 'python'\n ? 'recorded_steps.py'\n : scriptLang === 'java'\n ? 'RecordedSteps.java'\n : 'recorded.spec.ts';\n }\n async function refreshScript() {\n const r = await fetch('/api/recording?lang=' + scriptLang);\n const j = await r.json();\n lastSpec = j.spec || '';\n $('script').innerHTML = lastSpec ? highlightCode(lastSpec, scriptLang) : '';\n if (typeof j.recording === 'boolean' && j.recording !== recording) {\n applyRecordingState(j.recording);\n }\n }\n document.querySelectorAll('#script-lang button').forEach((b) => {\n b.onclick = async () => {\n scriptLang = b.dataset.lang;\n document\n .querySelectorAll('#script-lang button')\n .forEach((x) => x.classList.toggle('active', x === b));\n $('script-lang-note').style.display = scriptLang === 'ts' ? 'none' : '';\n await refreshScript();\n };\n });\n $('btn-copy-script').onclick = async () => {\n try {\n await refreshScript();\n if (!lastSpec) {\n showToast('Recorded script is empty \u2014 record something first.', 'info', { title: 'Nothing to copy' });\n return;\n }\n await navigator.clipboard.writeText(lastSpec);\n showToast('Copied ' + lastSpec.length + ' chars to clipboard.', 'success', { title: 'Copied' });\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Copy failed' });\n }\n };\n $('btn-clear-script').onclick = async () => {\n try {\n await refreshScript();\n if (!lastSpec) {\n showToast('Already empty.', 'info', { title: 'Nothing to clear' });\n return;\n }\n const r = await fetch('/api/recording/clear', { method: 'POST' });\n if (!r.ok) throw new Error('HTTP ' + r.status);\n await refreshScript();\n showToast('Recorded script cleared.', 'success', { title: 'Cleared' });\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Clear failed' });\n }\n };\n $('btn-export-script').onclick = async () => {\n try {\n // Look up where this lands so the prompt + native panel can show the path.\n const infoR = await fetch('/api/export-script/info');\n const info = await infoR.json();\n if (!info.ok) {\n showToast(\n info.error || 'No taqwright.config.ts found \u2014 run the inspector from a project directory.',\n 'error',\n { title: 'Cannot export' },\n );\n return;\n }\n await refreshScript();\n if (!lastSpec) {\n showToast('Recorded script is empty \u2014 record something first.', 'info', { title: 'Nothing to export' });\n return;\n }\n\n // Preferred path: macOS native save panel \u2014 lets the user navigate\n // anywhere, pick a filename, and the OS itself handles overwrite\n // confirmation. Falls back to a plain prompt() on Linux/Windows or\n // when osascript isn't available.\n let absolutePath = '';\n try {\n const sR = await fetch('/api/file-save-picker', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n defaultName: defaultScriptName(),\n defaultLocation: info.absoluteDir,\n }),\n });\n const sJ = await sR.json();\n if (sJ.cancelled) return;\n if (sJ.ok && sJ.path) absolutePath = sJ.path;\n // sJ.error \u2192 fall through to prompt below.\n } catch { /* fall through to prompt */ }\n\n if (absolutePath) {\n // Native panel already confirmed overwrite at the OS level.\n const r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n absolutePath,\n content: lastSpec,\n overwrite: true,\n }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || 'HTTP ' + r.status);\n showToast(\n 'Saved to ' + j.path + ' (' + j.bytes + ' bytes).',\n 'success',\n { title: 'Exported' },\n );\n return;\n }\n\n // Fallback: plain prompt for filename within testDir (non-macOS).\n const filename = window.prompt(\n 'Save as (relative to ' + info.absoluteDir + '):',\n defaultScriptName(),\n );\n if (!filename) return;\n let r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ filename, content: lastSpec }),\n });\n let j = await r.json();\n if (!r.ok || !j.ok) {\n if (/already exists/i.test(j.error || '')) {\n const ok = window.confirm(j.error + '\\n\\nOverwrite?');\n if (!ok) return;\n r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ filename, content: lastSpec, overwrite: true }),\n });\n j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || 'HTTP ' + r.status);\n } else {\n throw new Error(j.error || 'HTTP ' + r.status);\n }\n }\n showToast(\n 'Saved to ' + j.path + ' (' + j.bytes + ' bytes).',\n 'success',\n { title: 'Exported' },\n );\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Export failed' });\n }\n };\n\n // Keyboard: R = refresh.\n document.addEventListener('keydown', (ev) => {\n if (\n (ev.key === 'r' || ev.key === 'R') &&\n !ev.metaKey && !ev.ctrlKey && !ev.altKey &&\n !(ev.target instanceof HTMLInputElement) &&\n !(ev.target instanceof HTMLTextAreaElement)\n ) {\n ev.preventDefault();\n fetchSnapshot();\n }\n });\n\n /** Build the header meta line \u2014 drop the project name when it duplicates the platform. */\n function formatSessionMeta(platform, project) {\n const p = String(platform || '').toLowerCase();\n const proj = String(project || '').trim();\n if (!proj || proj.toLowerCase() === p) return platform || '';\n return platform + ' \u00B7 ' + proj;\n }\n\n /**\n * Tiny JS/TS syntax highlighter for the recorded script. Single-pass\n * tokenizer (more robust than regex passes which choke when keywords\n * appear inside strings) producing colored <span> tags.\n */\n // Language-agnostic tokenizer shared by the Taqwright (TS), Python and Java\n // views. Strings / numbers / identifiers / function-calls / punctuation are\n // common; only line-comment syntax and the keyword set vary by language.\n const KW_BY_LANG = {\n ts: new Set([\n 'import', 'from', 'export', 'async', 'await', 'return',\n 'if', 'else', 'const', 'let', 'var', 'new',\n 'true', 'false', 'null', 'undefined',\n ]),\n python: new Set([\n 'import', 'from', 'as', 'def', 'class', 'return',\n 'if', 'elif', 'else', 'for', 'while', 'in', 'is', 'and', 'or', 'not',\n 'lambda', 'assert', 'with', 'try', 'except', 'None', 'True', 'False',\n ]),\n java: new Set([\n 'import', 'package', 'public', 'private', 'protected', 'static', 'final',\n 'void', 'var', 'new', 'return', 'if', 'else', 'for', 'while', 'class',\n 'this', 'throws', 'throw', 'try', 'catch', 'true', 'false', 'null', 'assert',\n ]),\n };\n function highlightCode(src, lang) {\n const KW = KW_BY_LANG[lang] || KW_BY_LANG.ts;\n const out = [];\n const n = src.length;\n let i = 0;\n while (i < n) {\n const c = src[i];\n // Line comment: // (TS/Java) or # (Python) ... newline\n if ((c === '/' && src[i + 1] === '/') || (c === '#' && lang === 'python')) {\n const end = src.indexOf('\\n', i);\n const stop = end === -1 ? n : end;\n out.push(span('cmt', src.slice(i, stop)));\n i = stop;\n continue;\n }\n // String 'foo' or \"foo\"\n if (c === \"'\" || c === '\"') {\n const quote = c;\n let j = i + 1;\n while (j < n && src[j] !== quote) {\n if (src[j] === '\\\\' && j + 1 < n) j += 2;\n else j += 1;\n }\n out.push(span('str', src.slice(i, Math.min(j + 1, n))));\n i = Math.min(j + 1, n);\n continue;\n }\n // Number\n if (c >= '0' && c <= '9') {\n let j = i;\n while (j < n && /[\\d._]/.test(src[j])) j++;\n out.push(span('num', src.slice(i, j)));\n i = j;\n continue;\n }\n // Identifier\n if (/[A-Za-z_$]/.test(c)) {\n let j = i;\n while (j < n && /[\\w$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // Skip whitespace to peek for an open-paren (function-call style).\n let k = j;\n while (k < n && (src[k] === ' ' || src[k] === '\t')) k++;\n const tag = KW.has(word) ? 'kw' : (src[k] === '(' ? 'fn' : 'id');\n out.push(span(tag, word));\n i = j;\n continue;\n }\n // Whitespace passes through verbatim (no span needed \u2014 saves bytes).\n if (c === ' ' || c === '\\t' || c === '\\n' || c === '\\r') {\n out.push(c);\n i++;\n continue;\n }\n // Punctuation / operators.\n out.push(span('pun', c));\n i++;\n }\n return out.join('');\n }\n function span(tag, text) {\n return '<span class=\"tok-' + tag + '\">' + escapeHtml(text) + '</span>';\n }\n\n function escapeHtml(s) {\n return String(s).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''',\n }[c]));\n }\n function truncate(s, n) {\n s = String(s);\n return s.length > n ? s.slice(0, n - 1) + '\u2026' : s;\n }\n\n // Promise-based confirm dialog \u2014 replaces window.confirm with an in-page modal.\n // Resolves true on confirm, false on cancel / overlay-click / Escape.\n function confirmModal(opts) {\n const o = opts || {};\n const overlay = $('modal-overlay');\n $('modal-title').textContent = o.title || 'Are you sure?';\n $('modal-msg').textContent = o.message || '';\n $('modal-icon').textContent = o.icon || '\u26A0\uFE0F';\n const confirmBtn = $('modal-confirm');\n const cancelBtn = $('modal-cancel');\n confirmBtn.textContent = o.confirmLabel || 'Confirm';\n cancelBtn.textContent = o.cancelLabel || 'Cancel';\n confirmBtn.classList.toggle('confirm', o.danger !== false);\n return new Promise((resolve) => {\n function cleanup(result) {\n overlay.classList.remove('open');\n confirmBtn.onclick = null;\n cancelBtn.onclick = null;\n overlay.onclick = null;\n document.removeEventListener('keydown', onKey);\n resolve(result);\n }\n function onKey(e) {\n if (e.key === 'Escape') cleanup(false);\n else if (e.key === 'Enter') cleanup(true);\n }\n confirmBtn.onclick = () => cleanup(true);\n cancelBtn.onclick = () => cleanup(false);\n overlay.onclick = (e) => { if (e.target === overlay) cleanup(false); };\n document.addEventListener('keydown', onKey);\n overlay.classList.add('open');\n confirmBtn.focus();\n });\n }\n\n // \u2500\u2500\u2500 Setup / landing logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function showView(name) {\n document.body.classList.toggle('view-setup', name === 'setup');\n document.body.classList.toggle('view-inspector', name === 'inspector');\n }\n\n async function bootstrap() {\n setStatus('checking session\u2026', true);\n try {\n const r = await fetch('/api/status');\n const j = await r.json();\n if (j.connected) {\n // Attached mode: the inspector is borrowing a driver from a paused\n // test (mobile.pause()). Surface \"Resume\" instead of Disconnect.\n if (j.attached) {\n $('btn-disconnect').style.display = 'none';\n $('btn-resume').style.display = '';\n $('session-meta').textContent = 'paused \u2014 ' + formatSessionMeta(j.platform, j.project);\n } else {\n $('session-meta').textContent = formatSessionMeta(j.platform, j.project);\n }\n applyRecordingState(j.recording);\n showLoader('Loading device screen\u2026',\n 'Reconnecting to the active session and pulling the latest snapshot.');\n showView('inspector');\n await fetchSnapshot();\n startAutoRefresh();\n hideLoader();\n onInspectorReady();\n } else {\n showView('setup');\n await initSetup(j);\n maybeStartSetupTour();\n }\n setStatus('idle');\n } catch (err) {\n setStatus('bootstrap error: ' + err.message);\n }\n }\n\n $('btn-resume').onclick = async () => {\n $('btn-resume').disabled = true;\n $('btn-resume').textContent = 'Resuming\u2026';\n try {\n await fetch('/api/resume', { method: 'POST' });\n autoRefreshOn = false;\n document.body.innerHTML =\n '<div style=\"display:flex;align-items:center;justify-content:center;' +\n 'height:100vh;font:14px -apple-system,sans-serif;color:#888;text-align:center;\">' +\n '<div><div style=\"font-size:32px;margin-bottom:12px\">\u25B6</div>' +\n 'Test resumed. You can close this tab.</div></div>';\n } catch (err) {\n $('btn-resume').disabled = false;\n $('btn-resume').textContent = 'Resume \u25B6';\n setStatus('resume error: ' + err.message);\n }\n };\n\n // Keys we map to dedicated form fields. Anything else lives in the\n // advanced JSON editor and is merged on top at connect time.\n const KNOWN_CAP_KEYS = new Set([\n 'platformName',\n 'appium:automationName',\n 'appium:deviceName',\n 'appium:platformVersion',\n 'appium:app',\n 'appium:bundleId',\n 'appium:appPackage',\n 'appium:udid',\n 'appium:noReset',\n ]);\n\n /** Split a flat caps object into form fields + an ordered array of extra rows. */\n function splitCaps(caps) {\n const c = caps || {};\n const platform = c.platformName === 'iOS' ? 'iOS' : 'Android';\n const form = {\n platform,\n device: c['appium:deviceName'] || '',\n version: c['appium:platformVersion'] || '',\n app: c['appium:app'] || '',\n bundle: c['appium:bundleId'] || c['appium:appPackage'] || '',\n udid: c['appium:udid'] || '',\n noReset: c['appium:noReset'] !== false,\n };\n const extras = [];\n for (const [k, v] of Object.entries(c)) {\n if (KNOWN_CAP_KEYS.has(k)) continue;\n extras.push({ key: k, value: stringifyCapValue(v) });\n }\n return { form, extras };\n }\n\n /** Build a flat caps object from form + extras. Extras override on key collision. */\n function buildCaps(form, extras) {\n const caps = {\n platformName: form.platform,\n 'appium:automationName': form.platform === 'iOS' ? 'XCUITest' : 'UiAutomator2',\n };\n if (form.device) caps['appium:deviceName'] = form.device;\n if (form.version) caps['appium:platformVersion'] = form.version;\n if (form.app) caps['appium:app'] = form.app;\n if (form.bundle) {\n if (form.platform === 'iOS') caps['appium:bundleId'] = form.bundle;\n else caps['appium:appPackage'] = form.bundle;\n }\n if (form.udid) caps['appium:udid'] = form.udid;\n if (form.noReset) caps['appium:noReset'] = true;\n for (const row of extras || []) {\n const key = String(row.key || '').trim();\n if (!key) continue;\n caps[key] = parseCapValue(row.value);\n }\n return caps;\n }\n\n /** Coerce a string value into the most specific JSON type \u2014 bool, number, JSON, else string. */\n function parseCapValue(v) {\n if (typeof v !== 'string') return v;\n const s = v.trim();\n if (s === '') return '';\n if (s === 'true') return true;\n if (s === 'false') return false;\n if (s === 'null') return null;\n if (/^-?\\d+$/.test(s)) return parseInt(s, 10);\n if (/^-?\\d+\\.\\d+$/.test(s)) return parseFloat(s);\n if (s[0] === '{' || s[0] === '[' || s[0] === '\"') {\n try { return JSON.parse(s); } catch { /* fall through */ }\n }\n return s;\n }\n\n function stringifyCapValue(v) {\n if (typeof v === 'string') return v;\n if (typeof v === 'boolean' || typeof v === 'number') return String(v);\n if (v == null) return '';\n return JSON.stringify(v);\n }\n\n async function initSetup(initial) {\n // Appium fields.\n $('appium-host').value = initial.appium.host;\n $('appium-port').value = String(initial.appium.port);\n $('appium-path').value = initial.appium.path;\n\n // Capability fields.\n applyCapsToForm(initial.defaults.capabilities);\n\n // Re-initialization after disconnect must clear the previous device choice too,\n // otherwise the stale tile shows selected while the (now-empty) cap-device gate\n // keeps Next disabled. Mirrors the reset in setConnectionMode().\n selectedDeviceKey = null;\n selectedCloudDevice = null;\n\n // Reset wizard state (bootstrap re-runs after disconnect).\n prereqsDoctorDone = false;\n prereqsAppiumDone = false;\n const progressEl = document.getElementById('prereq-progress');\n if (progressEl) progressEl.classList.remove('done');\n $('app-inspect-status').textContent = '';\n $('app-inspect-status').className = 'app-inspect-status';\n\n // Doctor + appium probes + device list.\n await loadDoctor();\n await refreshAppiumPill();\n await loadDevices();\n\n // Wire interactions.\n $('btn-appium-recheck').onclick = refreshAppiumPill;\n $('btn-appium-restart').onclick = restartAppium;\n $('btn-appium-start').onclick = startAppium;\n $('btn-caps-reset').onclick = () => applyCapsToForm(initial.defaults.capabilities);\n $('btn-connect').onclick = doConnect;\n $('btn-add-cap').onclick = () => addExtraRow({ key: '', value: '' }, true);\n $('btn-devices-refresh').onclick = loadDevices;\n $('btn-app-browse').onclick = pickAppFile;\n $('btn-step-back').onclick = () => goToStep(wizardStep - 1);\n $('btn-step-next').onclick = () => goToStep(wizardStep + 1);\n\n // Connection-mode picker (Local / BrowserStack / LambdaTest).\n document.querySelectorAll('.conn-mode-btn').forEach((b) => {\n b.onclick = () => setConnectionMode(b.dataset.connMode);\n });\n // Cloud creds inputs \u2014 refresh pill + summary on every keystroke.\n for (const id of ['cloud-user', 'cloud-key']) {\n $(id).addEventListener('input', refreshCloudCredsPill);\n $(id).addEventListener('change', refreshCloudCredsPill);\n }\n for (const id of ['appium-host', 'appium-port', 'appium-path']) {\n $(id).addEventListener('change', () => { refreshAppiumPill(); updateConnectSummary(); });\n $(id).addEventListener('input', updateConnectSummary);\n }\n for (const id of ['cap-platform', 'cap-device', 'cap-version', 'cap-app', 'cap-bundle', 'cap-udid', 'cap-noreset']) {\n $(id).addEventListener('input', updateConnectSummary);\n $(id).addEventListener('change', updateConnectSummary);\n }\n $('cap-platform').addEventListener('change', () => {\n clearAppIfPlatformMismatch($('cap-platform').value);\n updateBundleLabel();\n });\n $('cap-app').addEventListener('change', () => inspectAppPath());\n $('doctor-summary').addEventListener('click', toggleDoctorList);\n\n // Stepper pills: clicking a completed pill jumps back to it.\n document.querySelectorAll('.wizard-step-pill').forEach((pill) => {\n pill.addEventListener('click', () => {\n const target = Number(pill.getAttribute('data-step'));\n if (target && target < wizardStep) goToStep(target);\n });\n });\n\n updateBundleLabel();\n updateConnectSummary();\n goToStep(1);\n }\n\n // \u2500\u2500\u2500 Wizard state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let wizardStep = 1;\n let prereqsDoctorDone = false;\n let prereqsAppiumDone = false;\n // Connection mode: 'local' (existing emulator/sim flow), 'browserstack',\n // or 'lambdatest'. Cloud modes skip the local Appium card and use the\n // cloud's own hub.\n let connectionMode = 'local';\n let cloudCredsValid = false;\n\n function isCloudMode() {\n return connectionMode === 'browserstack' || connectionMode === 'lambdatest';\n }\n\n function setConnectionMode(mode) {\n // Snapshot current cloud creds before swapping \u2014 keeps each provider's\n // values isolated so the user can flip back and forth without losing\n // what they typed for either one.\n snapshotCloudCreds();\n connectionMode = mode;\n document.querySelectorAll('.conn-mode-btn').forEach((b) => {\n b.classList.toggle('active', b.dataset.connMode === mode);\n });\n const local = document.getElementById('step1-local-block');\n const cloud = document.getElementById('step1-cloud-block');\n const intro = document.getElementById('step1-intro');\n if (mode === 'local') {\n if (local) local.style.display = '';\n if (cloud) cloud.style.display = 'none';\n if (intro) intro.innerHTML = 'Confirming the CLIs you need (adb, xcrun, Java) are installed and that the Appium server is reachable. If the Appium pill is grey, click <strong>Start Appium</strong> \u2014 <strong>Next</strong> unlocks once it turns green.';\n } else {\n if (local) local.style.display = 'none';\n if (cloud) cloud.style.display = '';\n const provLabel = mode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n if (intro) intro.innerHTML = 'Connecting to <strong>' + provLabel + '</strong> cloud devices. Enter your credentials below \u2014 <strong>Next</strong> unlocks once they are filled in.';\n const titleEl = document.getElementById('cloud-creds-title');\n if (titleEl) titleEl.textContent = provLabel + ' credentials';\n // Restore the new provider's creds: in-memory cache first, env vars\n // as fallback. Always overwrites \u2014 no leakage from the previous one.\n loadCloudCredsForMode(mode);\n }\n applyModeToStep3();\n // Selecting a different mode invalidates the previous device choice.\n selectedDeviceKey = null;\n selectedCloudDevice = null;\n $('cap-device').value = '';\n $('cap-version').value = '';\n $('cap-udid').value = '';\n // Drop the previous source's catalog so step 2 doesn't flash stale tiles\n // before the new source's loadDevices() resolves.\n lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n devicePage = { android: 0, ios: 0 };\n updateConnectSummary();\n }\n\n /** Re-skin the Capabilities form for the current connection mode. */\n function applyModeToStep3() {\n const cloud = isCloudMode();\n // App field placeholder + hint.\n const appInput = document.getElementById('cap-app');\n if (appInput) {\n appInput.placeholder = cloud\n ? (connectionMode === 'browserstack'\n ? 'bs://\u2026 (uploaded via BrowserStack app-upload)'\n : 'lt://\u2026 (uploaded via LambdaTest app-upload)')\n : 'optional \u00B7 path to .apk / .ipa / .app';\n }\n // Browse button is meaningless for cloud \u2014 no native picker uploads to cloud yet.\n const browseBtn = document.getElementById('btn-app-browse');\n if (browseBtn) browseBtn.style.display = cloud ? 'none' : '';\n // UDID is local-only.\n const udidRow = document.getElementById('cap-udid');\n if (udidRow) {\n const field = udidRow.closest('.field');\n if (field) field.style.display = cloud ? 'none' : '';\n }\n }\n\n /** Server-side env-var snapshot, fetched once. */\n let cloudEnvCache = null;\n async function loadCloudEnvOnce() {\n if (cloudEnvCache) return cloudEnvCache;\n try {\n const r = await fetch('/api/cloud/env');\n cloudEnvCache = await r.json();\n } catch {\n cloudEnvCache = { browserstack: { user: '', key: '' }, lambdatest: { user: '', key: '' } };\n }\n return cloudEnvCache;\n }\n\n // Per-provider in-memory cache of what the user has typed. Lets the\n // user toggle BrowserStack \u2194 LambdaTest without losing the creds for\n // either one.\n const cloudCredsByProvider = { browserstack: null, lambdatest: null };\n\n // Save the currently-displayed cloud creds into the cache for the\n // current cloud mode (no-op when local).\n function snapshotCloudCreds() {\n if (!isCloudMode()) return;\n const userEl = document.getElementById('cloud-user');\n const keyEl = document.getElementById('cloud-key');\n if (!userEl || !keyEl) return;\n cloudCredsByProvider[connectionMode] = {\n user: (userEl.value || '').trim(),\n key: (keyEl.value || '').trim(),\n };\n }\n\n // Populate the cloud-user / cloud-key inputs for the given mode: cached\n // value if the user has typed something for it, else env-var default.\n // Always overwrites \u2014 never leaves stale values from another provider.\n async function loadCloudCredsForMode(mode) {\n const userEl = $('cloud-user');\n const keyEl = $('cloud-key');\n let user = '';\n let key = '';\n let fromCache = false;\n const cached = cloudCredsByProvider[mode];\n if (cached && (cached.user || cached.key)) {\n user = cached.user;\n key = cached.key;\n fromCache = true;\n } else {\n const env = await loadCloudEnvOnce();\n const slot = env[mode] || { user: '', key: '' };\n user = slot.user || '';\n key = slot.key || '';\n }\n if (userEl) userEl.value = user;\n if (keyEl) keyEl.value = key;\n const hint = $('cloud-creds-hint');\n if (hint) {\n const envName = mode === 'browserstack'\n ? 'BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY'\n : 'LAMBDATEST_USERNAME / LAMBDATEST_ACCESS_KEY';\n hint.innerHTML = fromCache\n ? '\u2713 Restored from this session.'\n : ((user || key)\n ? '\u2713 Prefilled from <code>' + envName + '</code>. Override here for this session.'\n : 'No env vars detected (<code>' + envName + '</code>). Paste credentials above or set the env vars before launching the inspector.');\n }\n refreshCloudCredsPill();\n }\n\n function refreshCloudCredsPill() {\n const pill = document.getElementById('cloud-creds-pill');\n const label = document.getElementById('cloud-creds-pill-label');\n if (!pill || !label) return;\n const u = ($('cloud-user').value || '').trim();\n const k = ($('cloud-key').value || '').trim();\n if (u && k) {\n pill.className = 'pill live';\n label.textContent = 'creds detected';\n cloudCredsValid = true;\n } else {\n pill.className = 'pill down';\n label.textContent = 'awaiting\u2026';\n cloudCredsValid = false;\n }\n updateConnectSummary();\n }\n\n function goToStep(n) {\n if (n < 1 || n > 3) return;\n wizardStep = n;\n document.querySelectorAll('.wizard-page').forEach((p) => {\n p.classList.toggle('active', Number(p.getAttribute('data-page')) === n);\n });\n document.querySelectorAll('.wizard-step-pill').forEach((p) => {\n const ps = Number(p.getAttribute('data-step'));\n p.classList.toggle('active', ps === n);\n p.classList.toggle('done', ps < n);\n });\n document.querySelectorAll('.wizard-line').forEach((line, i) => {\n line.classList.toggle('done', i < n - 1);\n });\n $('btn-step-back').style.display = n > 1 ? '' : 'none';\n $('btn-step-next').style.display = n < 3 ? '' : 'none';\n $('btn-connect').style.display = n === 3 ? '' : 'none';\n updateConnectSummary();\n // Reload the catalog whenever step 2 is entered so the list always\n // reflects the currently selected source (loadDevices branches on mode).\n if (n === 2) loadDevices();\n }\n\n function maybeHidePrereqProgress() {\n if (prereqsDoctorDone && prereqsAppiumDone) {\n const el = document.getElementById('prereq-progress');\n if (el) el.classList.add('done');\n }\n }\n\n // \u2500\u2500\u2500 App-file inspection (step 3) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function pickAppFile() {\n try {\n const r = await fetch('/api/file-picker', { method: 'POST' });\n const j = await r.json();\n if (j.ok && j.path) {\n $('cap-app').value = j.path;\n updateConnectSummary();\n inspectAppPath();\n } else if (j.cancelled) {\n // Silent cancel.\n } else if (j.error) {\n showToast(j.error, 'error', { title: 'Browse failed' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Browse failed' });\n }\n }\n\n let inspectAppToken = 0;\n async function inspectAppPath() {\n const status = $('app-inspect-status');\n const path = $('cap-app').value.trim();\n if (!path) { status.textContent = ''; status.className = 'app-inspect-status'; return; }\n // Cloud / remote URLs aren't on the local filesystem \u2014 the cloud\n // session resolves them on its own; we skip parsing aapt/plutil\n // and just acknowledge the URL so the user sees positive feedback.\n if (/^(bs|lt|https?):\\/\\//i.test(path)) {\n const kind = path.toLowerCase().startsWith('bs://') ? 'BrowserStack URL'\n : path.toLowerCase().startsWith('lt://') ? 'LambdaTest URL'\n : 'remote URL';\n status.textContent = '\u2713 ' + kind + ' \u2014 bundle id will come from the cloud session.';\n status.className = 'app-inspect-status ok';\n return;\n }\n const token = ++inspectAppToken;\n status.innerHTML = '<span class=\"spinner\"></span>Inspecting ' + escapeHtml(path) + '\u2026';\n status.className = 'app-inspect-status busy';\n try {\n const r = await fetch('/api/inspect-app', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ path }),\n });\n if (token !== inspectAppToken) return;\n const j = await r.json();\n if (!r.ok || !j.ok) {\n status.textContent = '\u26A0 ' + (j.error || ('HTTP ' + r.status));\n status.className = 'app-inspect-status err';\n return;\n }\n // Auto-fill the bundle/package field.\n $('cap-bundle').value = j.bundleId;\n // For Android, also set platform (in case user pointed to .apk after picking iOS device).\n if (j.kind === 'apk') $('cap-platform').value = 'Android';\n else if (j.kind === 'ipa' || j.kind === 'app' || j.kind === 'app.zip') $('cap-platform').value = 'iOS';\n updateBundleLabel();\n // For Android with a launchable activity, set appium:appActivity as an extra.\n if (j.appActivity) {\n let foundRow = null;\n document.querySelectorAll('#extras-list .extra-cap').forEach((row) => {\n const k = row.querySelector('.extra-key').value.trim();\n if (k === 'appium:appActivity') foundRow = row;\n });\n if (foundRow) {\n foundRow.querySelector('.extra-val').value = j.appActivity;\n } else {\n addExtraRow({ key: 'appium:appActivity', value: j.appActivity }, false);\n }\n }\n const detail = j.bundleId + (j.appActivity ? ' \u00B7 ' + j.appActivity : '');\n status.textContent = '\u2713 ' + j.kind.toUpperCase() + ' \u00B7 ' + detail;\n status.className = 'app-inspect-status ok';\n updateConnectSummary();\n } catch (err) {\n if (token !== inspectAppToken) return;\n status.textContent = '\u26A0 ' + err.message;\n status.className = 'app-inspect-status err';\n }\n }\n\n function applyCapsToForm(caps) {\n const { form, extras } = splitCaps(caps);\n $('cap-platform').value = form.platform;\n $('cap-device').value = form.device;\n $('cap-version').value = form.version;\n $('cap-app').value = form.app;\n $('cap-bundle').value = form.bundle;\n $('cap-udid').value = form.udid;\n $('cap-noreset').checked = form.noReset;\n $('extras-list').innerHTML = '';\n for (const row of extras) addExtraRow(row, false);\n updateBundleLabel();\n updateConnectSummary();\n }\n\n /** Append a new key/value row to the extras list. */\n function addExtraRow(row, focus) {\n const list = $('extras-list');\n const div = document.createElement('div');\n div.className = 'extra-cap';\n div.innerHTML =\n '<input class=\"extra-key\" list=\"known-caps\" placeholder=\"key (e.g. appium:autoGrantPermissions)\" />' +\n '<input class=\"extra-val\" placeholder=\"value\" />' +\n '<button class=\"x-btn\" type=\"button\" title=\"Remove\">\u00D7</button>';\n const keyInp = div.querySelector('.extra-key');\n const valInp = div.querySelector('.extra-val');\n const rmBtn = div.querySelector('.x-btn');\n keyInp.value = row.key || '';\n valInp.value = row.value || '';\n keyInp.addEventListener('input', updateConnectSummary);\n valInp.addEventListener('input', updateConnectSummary);\n rmBtn.addEventListener('click', () => { div.remove(); updateConnectSummary(); });\n list.appendChild(div);\n if (focus) keyInp.focus();\n updateConnectSummary();\n }\n\n /** Remove local-emulator-only cap rows (appium:avd, \u2026) \u2014 wrong for cloud. */\n function stripLocalOnlyExtras() {\n var localOnly = ['appium:avd', 'appium:avdLaunchTimeout', 'appium:avdReadyTimeout'];\n var rows = document.querySelectorAll('#extras-list .extra-cap');\n rows.forEach(function (div) {\n var k = String(div.querySelector('.extra-key').value || '').trim();\n if (localOnly.indexOf(k) !== -1) div.remove();\n });\n updateConnectSummary();\n }\n\n /** Read all extras rows into an array of {key, value} (skips empty keys). */\n function readExtras() {\n const out = [];\n const rows = document.querySelectorAll('#extras-list .extra-cap');\n rows.forEach((div) => {\n const k = div.querySelector('.extra-key').value;\n const v = div.querySelector('.extra-val').value;\n if (String(k).trim()) out.push({ key: k.trim(), value: v });\n });\n return out;\n }\n\n function readFormCaps() {\n return {\n platform: $('cap-platform').value || 'Android',\n device: $('cap-device').value.trim(),\n version: $('cap-version').value.trim(),\n app: $('cap-app').value.trim(),\n bundle: $('cap-bundle').value.trim(),\n udid: $('cap-udid').value.trim(),\n noReset: $('cap-noreset').checked,\n };\n }\n\n // Infer the platform a local app path implies. 'Android' for .apk,\n // 'iOS' for .app/.ipa, or null for unknown / remote (bs:// lt:// http)\n // URLs \u2014 null means \"don't infer, don't clear\".\n function appPlatformFromPath(path) {\n const p = (path || '').trim().toLowerCase();\n if (p.endsWith('.apk')) return 'Android';\n if (p.endsWith('.app') || p.endsWith('.ipa')) return 'iOS';\n return null;\n }\n\n // When the chosen platform no longer matches the local app already in\n // the form, that app/bundle can't install or launch on the new\n // platform (the .apk-on-iOS \"returned nil\" crash). Clear them so the\n // user picks the right app (Browse re-detects the bundle id). Only\n // fires on a KNOWN-extension mismatch \u2014 a valid same-platform app or a\n // remote/cloud URL is left untouched.\n function clearAppIfPlatformMismatch(newPlatform) {\n const ap = appPlatformFromPath($('cap-app').value);\n if (!ap || ap === newPlatform) return;\n $('cap-app').value = '';\n $('cap-bundle').value = '';\n const s = $('app-inspect-status');\n s.textContent = '';\n s.className = 'app-inspect-status';\n // Leaving Android: drop the Android-only appium:appActivity extra\n // that inspectAppPath auto-adds (meaningless off Android, would be a\n // bogus iOS cap). Accepted edge: if the user manually emptied cap-app\n // first, the early-return above leaves that extra \u2014 a benign unknown\n // cap, far less harmful than a wrong app path.\n if (ap === 'Android') {\n document.querySelectorAll('#extras-list .extra-cap').forEach((row) => {\n const k = row.querySelector('.extra-key');\n if (k && k.value.trim() === 'appium:appActivity') row.remove();\n });\n }\n updateConnectSummary();\n }\n\n function updateBundleLabel() {\n const platform = $('cap-platform').value;\n $('cap-bundle-label').textContent = platform === 'iOS' ? 'Bundle ID' : 'Package';\n }\n\n /**\n * Step-aware footer: tells the user what they need to do next, and gates\n * the \"Next \u2192\" button when prerequisites for the current step aren't met.\n */\n function updateConnectSummary() {\n const summary = $('connect-summary');\n const nextBtn = $('btn-step-next');\n if (wizardStep === 1) {\n if (isCloudMode()) {\n const provLabel = connectionMode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n if (cloudCredsValid) {\n summary.innerHTML = '<strong>' + provLabel + ' creds set</strong> \u2014 continue to pick a device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML = 'Enter your <strong>' + provLabel + '</strong> username + access key to continue.';\n nextBtn.disabled = true;\n }\n } else {\n const reachable = $('appium-pill').classList.contains('live');\n if (reachable) {\n summary.innerHTML = '<strong>Appium reachable</strong> \u2014 continue to pick a device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML =\n 'Start the Appium server before continuing. Use <strong>Start Appium</strong> above.';\n nextBtn.disabled = true;\n }\n }\n return;\n }\n if (wizardStep === 2) {\n const sel = $('cap-device').value.trim();\n if (sel) {\n summary.innerHTML =\n 'Selected <strong>' + escapeHtml(sel) + '</strong> \u2014 click <strong>Next</strong> or pick another device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML = isCloudMode()\n ? 'Pick a cloud device by tapping its tile to continue.'\n : 'Pick a booted device by tapping its tile to continue.';\n nextBtn.disabled = true;\n }\n return;\n }\n // Step 3 \u2014 full connect summary, drives the Connect button label.\n const f = readFormCaps();\n const auto = f.platform === 'iOS' ? 'XCUITest' : 'UiAutomator2';\n const dev = f.device ? ' \u00B7 <strong>' + escapeHtml(f.device) + '</strong>' : '';\n if (isCloudMode()) {\n const provLabel = connectionMode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n summary.innerHTML =\n 'Connect to <strong>' + provLabel + '</strong> \u00B7 <strong>' + f.platform + '</strong> \u00B7 ' + auto + dev;\n } else {\n const a = readAppiumForm();\n summary.innerHTML =\n 'Connect to <strong>' + escapeHtml(a.host) + ':' + a.port + '</strong>' +\n ' \u00B7 <strong>' + f.platform + '</strong> \u00B7 ' + auto + dev;\n }\n }\n\n function toggleDoctorList() {\n const list = $('doctor-list');\n const open = list.classList.toggle('expanded');\n $('doctor-twisty').textContent = open ? '\u25B4' : '\u25BE';\n }\n\n async function loadDoctor() {\n try {\n const r = await fetch('/api/doctor');\n const { checks } = await r.json();\n const total = checks.length;\n const oks = checks.filter((c) => c.status === 'ok').length;\n const errs = checks.filter((c) => c.status === 'error').length;\n const warns = checks.filter((c) => c.status === 'warn').length;\n const pill = $('doctor-summary-pill');\n const label = $('doctor-summary-label');\n if (errs === 0 && warns === 0) {\n pill.className = 'pill live';\n label.textContent = 'all ' + total + ' checks passed';\n } else if (errs === 0) {\n pill.className = 'pill down';\n label.textContent = warns + ' warning' + (warns === 1 ? '' : 's') + ' \u00B7 ' + oks + '/' + total + ' ok';\n } else {\n pill.className = 'pill down';\n label.textContent = errs + ' error' + (errs === 1 ? '' : 's') + ' \u00B7 ' + oks + '/' + total + ' ok';\n }\n $('doctor-list').innerHTML = checks.map((c) => {\n const sym = c.status === 'ok' ? '\u2713' : c.status === 'warn' ? '!' : '\u2717';\n // OK rows show their short value inline-right; warn/error details (often\n // long paths/commands) drop to a full-width wrapping line below the name.\n const inline = c.status === 'ok' && c.detail\n ? '<span class=\"detail\">' + escapeHtml(c.detail) + '</span>' : '';\n const block = c.status !== 'ok' && c.detail\n ? '<div class=\"detail-block\">' + escapeHtml(c.detail) + '</div>' : '';\n return '<li>' +\n '<div class=\"doctor-row\">' +\n '<span class=\"ico ' + c.status + '\">' + sym + '</span>' +\n '<span class=\"name\">' + escapeHtml(c.name) + '</span>' +\n inline +\n '</div>' + block +\n '</li>';\n }).join('');\n // Auto-expand if anything failed.\n if (errs > 0 || warns > 0) {\n $('doctor-list').classList.add('expanded');\n $('doctor-twisty').textContent = '\u25B4';\n }\n } catch (err) {\n $('doctor-summary-label').textContent = 'doctor failed: ' + err.message;\n } finally {\n prereqsDoctorDone = true;\n maybeHidePrereqProgress();\n }\n }\n\n function readAppiumForm() {\n return {\n host: $('appium-host').value.trim() || 'localhost',\n port: Number($('appium-port').value) || 4723,\n path: $('appium-path').value.trim() || '/',\n };\n }\n\n // \u2500\u2500\u2500 Devices card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const DEVICE_PAGE_SIZE = 8;\n let deviceTab = 'android'; // active tab\n let devicePage = { android: 0, ios: 0 }; // 0-based page per tab\n let lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n\n /** Pull the current device list from the server and re-render. */\n async function loadDevices() {\n const refreshBtn = $('btn-devices-refresh');\n refreshBtn.disabled = true;\n // Show a loading placeholder synchronously so switching device source (or a\n // slow cloud fetch) never flashes the previously rendered device list.\n $('devices-warn').innerHTML = '';\n $('device-pagination').innerHTML = '';\n $('device-count-android').textContent = '\u2026';\n $('device-count-ios').textContent = '\u2026';\n $('device-grid').innerHTML =\n '<div class=\"device-empty\"><span class=\"rec-sel-spinner\"></span>Loading devices\u2026</div>';\n try {\n if (isCloudMode()) {\n const u = ($('cloud-user').value || '').trim();\n const k = ($('cloud-key').value || '').trim();\n if (!u || !k) {\n lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n $('devices-warn').innerHTML =\n '<div class=\"device-warn\">Cloud creds missing \u2014 go back to step 1.</div>';\n renderDevices();\n return;\n }\n const r = await fetch('/api/cloud/devices', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ provider: connectionMode, user: u, key: k }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n // Convert cloud catalog \u2192 same shape as local devices, state='booted'\n // so the existing tile UI treats them as ready.\n const android = [];\n const ios = [];\n for (const d of j.devices) {\n const synthUdid = connectionMode + ':' + d.platform + ':' + d.deviceName + ':' + d.osVersion;\n const dev = {\n type: d.platform,\n udid: synthUdid,\n name: d.deviceName,\n osVersion: d.osVersion,\n state: 'booted',\n cloud: { provider: connectionMode, realDevice: !!d.realDevice },\n };\n (d.platform === 'ios' ? ios : android).push(dev);\n }\n lastDeviceData = { android, ios, toolsMissing: {} };\n if (android.length === 0 && ios.length > 0) deviceTab = 'ios';\n renderDevices();\n } else {\n const r = await fetch('/api/devices');\n const data = await r.json();\n lastDeviceData = data;\n if (data.android.length === 0 && data.ios.length > 0) deviceTab = 'ios';\n renderDevices();\n }\n } catch (err) {\n $('device-grid').innerHTML = '';\n $('devices-warn').innerHTML =\n '<div class=\"device-warn\">Failed to load devices: ' + escapeHtml(err.message) + '</div>';\n } finally {\n refreshBtn.disabled = false;\n }\n }\n\n function renderDevices() {\n const data = lastDeviceData;\n\n // Tool-missing warnings.\n const warns = [];\n if (data.toolsMissing?.adb) warns.push(\"adb not on PATH \u2014 Android emulators won't show.\");\n if (data.toolsMissing?.emulator) warns.push(\"emulator not on PATH \u2014 Android AVDs won't show (install Android command-line tools).\");\n if (data.toolsMissing?.xcrun) warns.push(\"xcrun not on PATH \u2014 iOS simulators won't show (Xcode required).\");\n const warnHtml = warns.map((w) => '<div class=\"device-warn\">' + escapeHtml(w) + '</div>').join('');\n // AVDs whose system image is installed in no SDK are shown but flagged\n // unbootable per-tile (see renderTile) rather than hidden here.\n $('devices-warn').innerHTML = warnHtml;\n\n // Update tab counts and active class.\n $('device-count-android').textContent = String(data.android.length);\n $('device-count-ios').textContent = String(data.ios.length);\n document.querySelectorAll('.device-tab').forEach((t) => {\n t.classList.toggle('active', t.dataset.deviceTab === deviceTab);\n });\n // On platforms without xcrun the iOS tab is meaningless \u2014 hide it entirely.\n document.querySelectorAll('.device-tab[data-device-tab=\"ios\"]').forEach((t) => {\n t.style.display = data.toolsMissing?.xcrun ? 'none' : '';\n });\n\n // Render the active tab's slice.\n const list = deviceTab === 'android' ? data.android : data.ios;\n const totalPages = Math.max(1, Math.ceil(list.length / DEVICE_PAGE_SIZE));\n if (devicePage[deviceTab] >= totalPages) devicePage[deviceTab] = totalPages - 1;\n const page = devicePage[deviceTab];\n const slice = list.slice(page * DEVICE_PAGE_SIZE, (page + 1) * DEVICE_PAGE_SIZE);\n\n if (list.length === 0) {\n const what = deviceTab === 'android' ? 'Android emulators' : 'iOS simulators';\n $('device-grid').innerHTML = '<div class=\"device-empty\">No ' + what + ' found.</div>';\n } else {\n $('device-grid').innerHTML = slice.map((dev, i) => renderTile(dev, page * DEVICE_PAGE_SIZE + i)).join('');\n }\n\n // Pagination controls (only shown when there's more than one page).\n if (list.length > DEVICE_PAGE_SIZE) {\n $('device-pagination').innerHTML =\n '<button class=\"icon\" id=\"btn-dev-prev\"' + (page === 0 ? ' disabled' : '') + ' type=\"button\">\u2190 Prev</button>' +\n '<span class=\"info\">Page ' + (page + 1) + ' of ' + totalPages + ' \u00B7 ' + list.length + ' total</span>' +\n '<button class=\"icon\" id=\"btn-dev-next\"' + (page === totalPages - 1 ? ' disabled' : '') + ' type=\"button\">Next \u2192</button>';\n const prev = document.getElementById('btn-dev-prev');\n const next = document.getElementById('btn-dev-next');\n if (prev) prev.onclick = () => { devicePage[deviceTab] = Math.max(0, page - 1); renderDevices(); };\n if (next) next.onclick = () => { devicePage[deviceTab] = Math.min(totalPages - 1, page + 1); renderDevices(); };\n } else {\n $('device-pagination').innerHTML = '';\n }\n\n // Wire per-tile buttons + click-to-select (only for the visible slice).\n document.querySelectorAll('#device-grid .device-tile').forEach((tile) => {\n const idx = Number(tile.dataset.idx);\n const dev = list[idx];\n if (!dev) return;\n const startBtn = tile.querySelector('[data-act=\"start\"]');\n const stopBtn = tile.querySelector('[data-act=\"stop\"]');\n // Action buttons stop event bubbling so a Stop click doesn't\n // re-select the device tile underneath.\n if (startBtn) {\n startBtn.onclick = (e) => { e.stopPropagation(); startDevice(dev); };\n }\n if (stopBtn) {\n stopBtn.onclick = (e) => { e.stopPropagation(); stopDevice(dev); };\n }\n // The tile itself selects when booted. Hover affordance + cursor\n // come from the .selectable class added in renderTile.\n if (dev.state === 'booted') {\n tile.onclick = () => selectDevice(dev);\n }\n });\n }\n\n // Tab switching.\n document.querySelectorAll('.device-tab').forEach((t) => {\n t.onclick = () => {\n deviceTab = t.dataset.deviceTab;\n renderDevices();\n };\n });\n\n // Devices we have asked to boot but haven't yet seen 'booted' for. Keyed\n // by AVD name (Android) or UDID (iOS) since the serial of an Android\n // emulator only exists once it comes online.\n const bootingDevices = new Set();\n function bootingKey(dev) {\n return dev.type === 'android'\n ? 'android:' + (dev.avdName || dev.name)\n : 'ios:' + dev.udid;\n }\n\n // The single device the user has tapped to drive the session. Cross-tab \u2014\n // selecting an iOS sim clears any prior Android selection and vice versa.\n let selectedDeviceKey = null;\n function isSelected(dev) {\n return selectedDeviceKey === bootingKey(dev);\n }\n\n function renderTile(dev, idx) {\n const isCloud = !!dev.cloud;\n const isBooting = bootingDevices.has(bootingKey(dev)) || dev.state === 'booting';\n const isBooted = dev.state === 'booted';\n // Shutdown AVD whose system image is in no SDK \u2014 cannot boot (Start disabled).\n const unbootable = !isCloud && dev.bootable === false && !isBooted && !isBooting;\n const selected = isBooted && isSelected(dev);\n const stateLabel = isCloud\n ? (dev.cloud.realDevice ? 'cloud \u00B7 real' : 'cloud \u00B7 sim')\n : isBooting ? 'booting\u2026'\n : isBooted ? 'live'\n : unbootable ? 'image missing'\n : 'shutdown';\n const stateClass = isCloud\n ? 'pill live'\n : isBooting ? 'pill booting'\n : isBooted ? 'pill live'\n : 'pill down';\n const stateIcon = isBooting\n ? '<span class=\"spinner\"></span>'\n : '<span class=\"led\"></span>';\n const baseMeta = isCloud\n ? (dev.osVersion + (dev.type === 'ios' ? ' \u00B7 iOS' : ' \u00B7 Android'))\n : ([dev.osVersion, dev.avdName].filter(Boolean).join(' \u00B7 ') || '\u2014');\n const meta = unbootable && dev.bootHint ? baseMeta + ' \u00B7 ' + dev.bootHint : baseMeta;\n const showUdid = !isCloud && isBooted;\n let actions = '';\n if (isCloud) {\n // No start/stop on cloud \u2014 the device is always available, the\n // session opens on Connect (step 3). The whole tile is the Use button.\n } else if (isBooting) {\n actions += '<button class=\"icon\" disabled>booting\u2026</button>';\n } else if (unbootable) {\n actions += '<button class=\"icon\" disabled title=\"' + escapeHtml(dev.bootHint || '') +\n '\">image missing</button>';\n } else if (dev.state === 'shutdown') {\n actions += '<button class=\"icon\" data-act=\"start\">\u25B6 Start</button>';\n } else if (isBooted) {\n actions += '<button class=\"icon\" data-act=\"stop\">\u25A0 Stop</button>';\n } else {\n actions += '<button class=\"icon\" disabled>' + escapeHtml(stateLabel) + '</button>';\n }\n const tileClasses = ['device-tile'];\n if (isBooting) tileClasses.push('booting');\n else if (isBooted) tileClasses.push('booted', 'selectable');\n if (selected) tileClasses.push('selected');\n return (\n '<div class=\"' + tileClasses.join(' ') + '\" ' +\n 'data-idx=\"' + idx + '\" data-kind=\"' + dev.type + '\">' +\n '<div class=\"check\">\u2713</div>' +\n '<div class=\"top\">' +\n '<span class=\"icon\">\uD83D\uDCF1</span>' +\n '<span class=\"name\">' + escapeHtml(dev.name) + '</span>' +\n '<span class=\"' + stateClass + '\">' + stateIcon + '<span>' +\n escapeHtml(stateLabel) + '</span></span>' +\n '</div>' +\n '<div class=\"meta\">' + escapeHtml(meta) + '</div>' +\n (showUdid ? '<div class=\"udid\">' + escapeHtml(dev.udid) + '</div>' : '') +\n (actions ? '<div class=\"actions\">' + actions + '</div>' : '') +\n '</div>'\n );\n }\n\n async function startDevice(dev) {\n const key = bootingKey(dev);\n setStatus('booting ' + dev.name + '\u2026', true);\n bootingDevices.add(key);\n renderDevices();\n try {\n const body = dev.type === 'android'\n ? { type: 'android', avdName: dev.avdName ?? dev.name }\n : { type: 'ios', udid: dev.udid };\n const r = await fetch('/api/devices/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n showToast(\"Booting \" + dev.name + \". This usually takes 20\u201360 s.\",\n 'info', { title: 'Device starting' });\n pollUntilBooted(dev);\n } catch (err) {\n bootingDevices.delete(key);\n renderDevices();\n showToast(err.message, 'error', { title: 'Failed to start' });\n } finally {\n setStatus('idle');\n }\n }\n\n /**\n * Refresh /api/devices on a 3 s cadence until the named device shows up\n * as 'booted' (or we hit the 90 s deadline). Keeps the spinner on the\n * tile up to date the whole way through.\n */\n function pollUntilBooted(dev) {\n const key = bootingKey(dev);\n const deadline = Date.now() + 90_000;\n const tick = async () => {\n if (!bootingDevices.has(key)) return;\n if (Date.now() > deadline) {\n bootingDevices.delete(key);\n renderDevices();\n showToast(\n dev.name + \" didn't finish booting within 90 s \u2014 click Refresh to recheck.\",\n 'error', { title: 'Boot timeout' });\n return;\n }\n try {\n const r = await fetch('/api/devices');\n const data = await r.json();\n lastDeviceData = data;\n const list = dev.type === 'android' ? data.android : data.ios;\n const found = list.find((d) => bootingKey(d) === key);\n if (found && found.state === 'booted') {\n bootingDevices.delete(key);\n renderDevices();\n showToast(dev.name + ' is up and ready.', 'success', { title: 'Device booted' });\n return;\n }\n renderDevices();\n } catch { /* network blip \u2014 try again */ }\n setTimeout(tick, 3000);\n };\n setTimeout(tick, 3000);\n }\n\n async function stopDevice(dev) {\n setStatus('stopping ' + dev.name + '\u2026', true);\n try {\n const r = await fetch('/api/devices/stop', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ type: dev.type, udid: dev.udid }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n showToast(dev.name + ' is shutting down.', 'success', { title: 'Stopped' });\n setTimeout(loadDevices, 2000);\n } catch (err) {\n showToast(err.message, 'error', { title: 'Failed to stop' });\n } finally {\n setStatus('idle');\n }\n }\n\n /**\n * Tap on a tile = select that device. Cross-tab single-selection: tapping\n * an iOS sim clears any prior Android selection and vice versa. The user\n * then clicks Next to advance to step 3 (no auto-advance \u2014 they get to\n * see the checkmark first and re-select if needed).\n */\n // Captured when a cloud device tile is selected \u2014 used at connect time\n // to build the right cloud capabilities. null when the active selection\n // is a local emulator/sim.\n let selectedCloudDevice = null;\n\n function selectDevice(dev) {\n if (dev.state !== 'booted') return;\n selectedDeviceKey = bootingKey(dev);\n $('cap-platform').value = dev.type === 'ios' ? 'iOS' : 'Android';\n $('cap-device').value = dev.name;\n $('cap-version').value = dev.osVersion ?? '';\n if (dev.cloud) {\n selectedCloudDevice = {\n provider: dev.cloud.provider,\n platform: dev.type,\n deviceName: dev.name,\n osVersion: dev.osVersion ?? '',\n };\n $('cap-udid').value = '';\n // Cloud picks the device by name + version \u2014 drop any local-emulator-only\n // caps (appium:avd, \u2026) that were seeded from the local config.\n stripLocalOnlyExtras();\n } else {\n selectedCloudDevice = null;\n $('cap-udid').value = dev.udid;\n }\n clearAppIfPlatformMismatch($('cap-platform').value);\n updateBundleLabel();\n updateConnectSummary();\n renderDevices();\n }\n\n // While a (blocking) start/restart request is in flight, show a spinner pill\n // with a live elapsed-seconds counter so a slow boot doesn't look frozen.\n let appiumStartTimer = null;\n function setAppiumStarting(label) {\n $('appium-pill').className = 'pill booting';\n $('appium-pill-label').textContent = label + '\u2026 0s';\n $('appium-start-hint').style.display = '';\n $('btn-appium-recheck').disabled = true;\n $('btn-appium-restart').disabled = true;\n $('btn-appium-start').disabled = true;\n let secs = 0;\n if (appiumStartTimer) clearInterval(appiumStartTimer);\n appiumStartTimer = setInterval(() => {\n secs += 1;\n $('appium-pill-label').textContent = label + '\u2026 ' + secs + 's';\n }, 1000);\n }\n function clearAppiumStarting() {\n if (appiumStartTimer) { clearInterval(appiumStartTimer); appiumStartTimer = null; }\n $('appium-start-hint').style.display = 'none';\n $('btn-appium-recheck').disabled = false;\n $('btn-appium-restart').disabled = false;\n // btn-appium-start re-enable is decided by refreshAppiumPill (reachable?)\n }\n\n async function refreshAppiumPill() {\n const opts = readAppiumForm();\n const pill = $('appium-pill');\n const label = $('appium-pill-label');\n pill.className = 'pill down';\n label.textContent = 'checking\u2026';\n try {\n const r = await fetch('/api/status');\n const j = await r.json();\n // /api/status reports the server-side default; compare against the\n // form values to decide whether to trust it.\n const reachable = j.appiumReachable && j.appium.host === opts.host && j.appium.port === opts.port;\n if (reachable) {\n pill.className = 'pill live';\n label.textContent = 'reachable on ' + opts.host + ':' + opts.port +\n (j.appiumOurs ? ' (started by inspector)' : '');\n $('btn-appium-start').disabled = true;\n $('btn-appium-start').textContent = 'Already running';\n } else {\n // Probe directly via /api/appium/start with no spawn? Server doesn't\n // expose a probe-only endpoint. Best-effort: tell the server which\n // host:port we want, then re-query status. We do that via a Recheck\n // pre-step that sends the form values to the server.\n const probeR = await fetch('/api/appium/probe?host=' + encodeURIComponent(opts.host) +\n '&port=' + opts.port + '&path=' + encodeURIComponent(opts.path));\n if (probeR.ok) {\n const pj = await probeR.json();\n if (pj.reachable) {\n pill.className = 'pill live';\n label.textContent = 'reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = true;\n $('btn-appium-start').textContent = 'Already running';\n return;\n }\n }\n pill.className = 'pill down';\n label.textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n }\n } catch (err) {\n pill.className = 'pill down';\n label.textContent = 'check failed';\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n } finally {\n prereqsAppiumDone = true;\n maybeHidePrereqProgress();\n updateConnectSummary();\n }\n }\n\n async function startAppium() {\n const opts = readAppiumForm();\n $('btn-appium-start').textContent = 'Starting\u2026';\n setAppiumStarting('starting Appium');\n try {\n const r = await fetch('/api/appium/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(opts),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearAppiumStarting();\n await refreshAppiumPill();\n showToast('Appium server is running on ' + opts.host + ':' + opts.port, 'success', { title: 'Appium started' });\n } catch (e) {\n clearAppiumStarting();\n $('appium-pill').className = 'pill down';\n $('appium-pill-label').textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n showToast(e.message, 'error', { title: 'Failed to start Appium' });\n }\n }\n\n async function restartAppium() {\n const opts = readAppiumForm();\n const btn = $('btn-appium-restart');\n btn.textContent = 'Restarting\u2026';\n setAppiumStarting('restarting Appium');\n try {\n const r = await fetch('/api/appium/restart', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(opts),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearAppiumStarting();\n await refreshAppiumPill();\n showToast('Appium restarted on ' + opts.host + ':' + opts.port, 'success',\n { title: 'Appium restarted' });\n } catch (e) {\n clearAppiumStarting();\n $('appium-pill').className = 'pill down';\n $('appium-pill-label').textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n showToast(e.message, 'error', { title: 'Failed to restart Appium' });\n } finally {\n btn.textContent = 'Restart Appium';\n }\n }\n\n\n async function doConnect() {\n const form = readFormCaps();\n const extras = readExtras();\n let body;\n if (isCloudMode()) {\n // Cloud: hand off the typed shape; server reuses the same provider\n // class the test runner does (see src/providers/index.ts).\n const extraCaps = {};\n for (const row of extras || []) {\n const k = String(row.key || '').trim();\n if (k) extraCaps[k] = row.value;\n }\n body = {\n cloud: {\n provider: connectionMode,\n user: ($('cloud-user').value || '').trim(),\n key: ($('cloud-key').value || '').trim(),\n platform: form.platform === 'iOS' ? 'ios' : 'android',\n deviceName: form.device,\n osVersion: form.version,\n appUrl: form.app,\n appBundleId: form.bundle,\n capabilities: extraCaps,\n projectName: 'taqwright-inspector',\n },\n };\n } else {\n body = { appium: readAppiumForm(), capabilities: buildCaps(form, extras) };\n }\n\n $('btn-connect').disabled = true;\n $('btn-connect').textContent = 'Connecting\u2026';\n const targetLabel = isCloudMode()\n ? (connectionMode === 'browserstack' ? 'BrowserStack hub' : 'LambdaTest hub')\n : (body.appium.host + ':' + body.appium.port);\n // Let the user abort a slow connect. Aborting the fetch stops the client\n // waiting; the /api/connect/cancel POST tells the server to tear down any\n // session that still materializes (so it doesn't leak as \"Running\").\n const controller = new AbortController();\n let cancelled = false;\n showLoader(\n 'Connecting to ' + targetLabel,\n 'Opening a WebDriver session. Cloud sessions can take 30\u201390 s while the device is provisioned.',\n () => {\n cancelled = true;\n controller.abort();\n fetch('/api/connect/cancel', { method: 'POST' }).catch(() => {});\n },\n );\n try {\n const r = await fetch('/api/connect', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body), signal: controller.signal,\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearToasts();\n showLoader('Loading device screen\u2026',\n 'Capturing the screenshot and UI hierarchy from the device.');\n showView('inspector');\n await fetchSnapshot();\n startAutoRefresh();\n hideLoader();\n onInspectorReady();\n } catch (e) {\n hideLoader();\n // User-initiated cancel \u2014 return to setup quietly, no error toast.\n if (!cancelled && e.name !== 'AbortError') {\n showToast(e.message, 'error', { title: 'Connect failed' });\n }\n } finally {\n $('btn-connect').disabled = false;\n $('btn-connect').textContent = 'Connect \u2192';\n }\n }\n\n // Disconnect handler (shown only when connected).\n $('btn-disconnect').onclick = async () => {\n const ok = await confirmModal({\n title: 'Disconnect session?',\n message: 'This ends the current device session and returns you to setup.',\n confirmLabel: 'Disconnect',\n icon: '\uD83D\uDD0C',\n danger: true,\n });\n if (!ok) return;\n stopAutoRefresh();\n applyRecordingState(false);\n stickyRelative = null;\n setStatus('disconnecting\u2026', true);\n try {\n await fetch('/api/disconnect', { method: 'POST' });\n } catch {}\n state.selected = null;\n state.nodeMap.clear();\n showView('setup');\n await bootstrap();\n };\n\n // \u2500\u2500\u2500 Guided tour + Help panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Spotlight coach-marks over the real controls + a static Help reference.\n // (No backticks anywhere in this block \u2014 the whole file is a template literal.)\n // Click a real inspector tab (used by the live inspector tour).\n function tourClickTab(name) {\n const t = document.querySelector('.tab[data-tab=\"' + name + '\"]');\n if (t) t.click();\n }\n // Switch the demo stage's mock right-hand tab (Record / Script / Locators / Attributes).\n function showDemoTab(name) {\n ['rec', 'script', 'loc', 'attrs'].forEach((k) => {\n const pane = $('demo-' + k);\n if (pane) pane.classList.toggle('hidden', k !== name);\n });\n document.querySelectorAll('#demo-tabs .tab').forEach((t) => {\n t.classList.toggle('active', t.getAttribute('data-demo-tab') === name);\n });\n }\n const SETUP_TOUR = [\n { sel: null, title: 'Welcome to codegen',\n body: 'This quick tour shows how to <b>connect a device</b>, <b>record</b> your actions, and <b>export</b> a runnable test.<br>Use Next / Back or the \u2190 \u2192 keys; press Esc to skip.' },\n { sel: '.conn-mode-toggle', title: 'Local or cloud',\n body: 'Choose <b>Local</b> for an emulator / simulator or USB device on this machine, or <b>Cloud</b> for BrowserStack / LambdaTest.' },\n { sel: '.card-env', before: function () { goToStep(1); }, title: 'Step 1 \u2014 Prerequisites',\n body: 'The <b>Environment</b> card runs a health check (adb, JDK, Android SDK, Appium drivers). Expand it to see any warnings.' },\n { sel: '.card-appium', before: function () { goToStep(1); }, title: 'Appium server',\n body: 'codegen talks to a local <b>Appium</b> server. If the pill is grey, click <b>Start Appium</b>; Next unlocks once it is green. (Cloud mode shows credentials here instead.)' },\n { sel: '#btn-devices-refresh', before: function () { goToStep(2); }, title: 'Step 2 \u2014 Pick a device',\n body: 'Switch the <b>Android / iOS</b> tabs and <b>\u21BB Refresh</b> the list. <b>Start</b> a shutdown emulator, or pick a running one / a cloud device.' },\n { sel: '#btn-app-browse', before: function () { goToStep(3); }, title: 'Step 3 \u2014 App & capabilities',\n body: 'Point at the app under test with <b>Browse\u2026</b>, then tweak or <b>+ Add</b> Appium capabilities (<b>\u21BA Reset</b> restores config defaults).' },\n { sel: '#btn-connect', before: function () { goToStep(3); }, title: 'Connect',\n body: 'Hit <b>Connect \u2192</b> to open the session and enter the inspector.' },\n { sel: null, title: 'You are set',\n body: 'Connect to start inspecting and recording. You can reopen this tour any time with <b>? Help</b> in the header.' },\n ];\n // LIVE inspector tour \u2014 spotlights the REAL panes (used when connected).\n const INSPECTOR_TOUR_LIVE = [\n { sel: null, title: 'The inspector',\n body: 'You are connected. This is where you inspect the UI, drive the device, and record a test.' },\n { sel: '.hier-mode-toggle', title: 'Hierarchy',\n body: 'Browse the UI tree as <b>Tree</b> or raw <b>XML</b>, and filter with the search box. Clicking a node selects it and highlights it on the screen \u2014 handy for small or overlapping elements.' },\n { sel: '#screen-host', title: 'Live screen',\n body: 'A live mirror of the device. <b>Click any element</b> to <b>select</b> it \u2014 then inspect its Attributes / Locators or record an action on it. (See the <b>\u24D8 How to use</b> button above for more.)' },\n { sel: '.tabs', title: 'The four panels',\n body: '<b>Record</b> (capture actions), <b>Recorded script</b> (your test), <b>Locators</b> (ranked selectors), and <b>Attributes</b> for the selected element.' },\n { sel: '#btn-rec-toggle', before: function () { tourClickTab('record'); }, title: 'Record',\n body: 'Press <b>Start record</b>, select an element, then choose an action \u2014 Click, Type, Clear, gestures\u2026 The <b>Actions / Screen / Assertions</b> sub-tabs switch what you capture. Each step is appended live.' },\n { sel: '#tab-script', before: function () { tourClickTab('script'); }, title: 'Recorded script',\n body: 'Your test in <b>Taqwright</b> (runnable), or <b>Python</b> / <b>Java</b> (steps only). Use <b>\u2398 Copy</b>, <b>\u2193 Export</b> (saves into your tests folder), or Clear.' },\n { sel: '#tab-locators', before: function () { tourClickTab('locators'); }, title: 'Locators',\n body: 'Ranked, uniqueness-verified selectors for the selected element \u2014 id, accessibility id, UIAutomator / NSPredicate / Class Chain, xpath. The <b>recommended</b> pick is on top; click any to copy.' },\n { sel: '#tab-attrs', before: function () { tourClickTab('attrs'); }, title: 'Attributes',\n body: 'The selected element\\'s full attribute set (resource-id, class, text, content-desc, bounds\u2026) plus its xpath.' },\n { sel: '#btn-disconnect', before: function () { tourClickTab('record'); }, title: 'Done',\n body: 'When finished, <b>Disconnect</b> ends the session and returns to setup. Reopen this tour any time with <b>? Help</b>.' },\n ];\n // DEMO inspector tour \u2014 targets the mock #demo-stage (a Taqelah-demo login\n // screen) so the walkthrough has a realistic device to point at when NOT connected.\n const INSPECTOR_TOUR_DEMO = [\n { sel: null, title: 'The inspector (example)',\n body: 'This is a <b>demo</b> of the inspector using the Taqelah sample login screen \u2014 so you can see the layout before connecting a real device.' },\n { sel: '#demo-hier', before: function () { showDemoTab('rec'); }, title: 'Hierarchy',\n body: 'The UI element tree (this is a Jetpack Compose app, so nodes are <b>EditText</b> / <b>android.view.View</b>). Toggle <b>Tree</b> / raw <b>XML</b> and filter with the search box. Clicking a node selects it and highlights it on the screen.' },\n { sel: '#demo-screen', title: 'Live screen',\n body: 'A live mirror of the device \u2014 the Taqelah demo login. <b>Click any element</b> (here the <b>Username</b> field) to <b>select</b> it, then inspect its Attributes / Locators or record an action on it.' },\n { sel: '#demo-tabs', title: 'The four panels',\n body: '<b>Record</b> (capture actions), <b>Recorded script</b> (your test), <b>Locators</b> (ranked selectors), and <b>Attributes</b> for the selected element.' },\n { sel: '#demo-rec', before: function () { showDemoTab('rec'); }, title: 'Record',\n body: 'Press Start record, select an element, then choose an action \u2014 Click, Type, Clear, Long press, Scroll to, gestures\u2026 The <b>Actions / Screen / Assertions</b> sub-tabs switch what you capture. Each step is appended live.' },\n { sel: '#demo-script', before: function () { showDemoTab('script'); }, title: 'Recorded script',\n body: 'Your test in <b>Taqwright</b> (runnable), or <b>Python</b> / <b>Java</b> (steps only). Use <b>\u2398 Copy</b>, <b>\u2193 Export</b> (saves into your tests folder), or Clear.' },\n { sel: '#demo-loc', before: function () { showDemoTab('loc'); }, title: 'Locators',\n body: 'Ranked, uniqueness-verified selectors for the selected element. This field has <b>no id</b>, so taqwright recommends a <b>hint-based xpath</b> \u2014 others (UIAutomator, plain xpath) are offered too. Click any to copy.' },\n { sel: '#demo-attrs', before: function () { showDemoTab('attrs'); }, title: 'Attributes',\n body: 'The selected element\\'s full attribute set (resource-id, class, text, content-desc, bounds\u2026) plus its xpath.' },\n { sel: '#demo-disconnect', before: function () { showDemoTab('rec'); }, title: 'Done',\n body: 'On a real session, <b>Disconnect</b> ends it and returns to setup. Reopen this walkthrough any time with <b>? Help \u2192 Inspector tour</b>.' },\n ];\n\n let tourSteps = [];\n let tourIdx = 0;\n let tourActive = false;\n let tourOnDone = null;\n\n function tourSeen(key) {\n try {\n return !!localStorage.getItem(key);\n } catch {\n return true; // no storage \u2192 behave as already-seen (never nag)\n }\n }\n function markTourSeen(key) {\n try {\n localStorage.setItem(key, '1');\n } catch {\n /* ignore */\n }\n }\n\n function tourTarget(sel) {\n if (!sel) return null;\n const el = document.querySelector(sel);\n if (!el) return null;\n const r = el.getBoundingClientRect();\n if (r.width === 0 && r.height === 0) return null; // hidden / not laid out\n return el;\n }\n\n function startTour(steps, onDone) {\n if (tourActive || !steps || !steps.length) return;\n tourSteps = steps;\n tourOnDone = onDone || null;\n tourIdx = 0;\n tourActive = true;\n $('tour-overlay').classList.add('show');\n document.addEventListener('keydown', tourKey, true);\n window.addEventListener('resize', tourReposition);\n window.addEventListener('scroll', tourReposition, true);\n renderTourStep();\n }\n\n function endTour() {\n if (!tourActive) return;\n tourActive = false;\n $('tour-overlay').classList.remove('show');\n document.removeEventListener('keydown', tourKey, true);\n window.removeEventListener('resize', tourReposition);\n window.removeEventListener('scroll', tourReposition, true);\n const cb = tourOnDone;\n tourOnDone = null;\n if (cb) cb();\n }\n\n function renderTourStep() {\n const step = tourSteps[tourIdx];\n if (step.before) {\n try {\n step.before();\n } catch {\n /* navigation hook is best-effort */\n }\n }\n $('tour-title').textContent = step.title;\n $('tour-text').innerHTML = step.body;\n $('tour-progress').textContent = tourIdx + 1 + ' / ' + tourSteps.length;\n $('tour-back').disabled = tourIdx === 0;\n $('tour-next').textContent = tourIdx === tourSteps.length - 1 ? 'Done \u2713' : 'Next \u2192';\n const el = tourTarget(step.sel);\n if (el) el.scrollIntoView({ block: 'center', inline: 'center' });\n positionTour();\n }\n\n function positionTour() {\n const step = tourSteps[tourIdx];\n const spot = $('tour-spotlight');\n const pop = $('tour-pop');\n const el = tourTarget(step.sel);\n if (!el) {\n // No (visible) target \u2014 show the popover centered, no spotlight.\n spot.style.display = 'none';\n pop.style.transform = 'translate(-50%, -50%)';\n pop.style.left = '50%';\n pop.style.top = '50%';\n return;\n }\n const r = el.getBoundingClientRect();\n const pad = 6;\n spot.style.display = 'block';\n spot.style.left = r.left - pad + 'px';\n spot.style.top = r.top - pad + 'px';\n spot.style.width = r.width + pad * 2 + 'px';\n spot.style.height = r.height + pad * 2 + 'px';\n pop.style.transform = 'none';\n const popW = pop.offsetWidth || 320;\n const popH = pop.offsetHeight || 170;\n const gap = 14;\n let top = r.bottom + gap;\n if (top + popH > window.innerHeight - 8) top = Math.max(8, r.top - gap - popH);\n let left = r.left + r.width / 2 - popW / 2;\n left = Math.max(8, Math.min(left, window.innerWidth - popW - 8));\n pop.style.left = left + 'px';\n pop.style.top = top + 'px';\n }\n\n function tourReposition() {\n if (tourActive) positionTour();\n }\n function tourNext() {\n if (tourIdx >= tourSteps.length - 1) {\n endTour();\n return;\n }\n tourIdx++;\n renderTourStep();\n }\n function tourBack() {\n if (tourIdx > 0) {\n tourIdx--;\n renderTourStep();\n }\n }\n function tourKey(e) {\n if (!tourActive) return;\n if (e.key === 'Escape') {\n e.preventDefault();\n endTour();\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n tourNext();\n } else if (e.key === 'ArrowLeft') {\n e.preventDefault();\n tourBack();\n }\n }\n\n function openHelp() {\n $('help-overlay').classList.add('show');\n document.addEventListener('keydown', helpKey, true);\n }\n function closeHelp() {\n $('help-overlay').classList.remove('show');\n document.removeEventListener('keydown', helpKey, true);\n }\n function helpKey(e) {\n if (e.key === 'Escape') {\n e.preventDefault();\n closeHelp();\n }\n }\n\n // First-run auto-start \u2014 called once the relevant page has finished loading\n // (so the spotlight never lands on a blank/loading area), not on a fixed timer.\n function maybeStartSetupTour() {\n if (tourActive || tourSeen('tw_tour_setup_seen')) return;\n if (!document.body.classList.contains('view-setup')) return;\n startTour(SETUP_TOUR, () => markTourSeen('tw_tour_setup_seen'));\n }\n // Called after the first device snapshot + tree have loaded (loader hidden).\n // First-timers get the inspector tour; everyone else gets a one-time screen\n // hint \u2014 never both at once.\n function onInspectorReady() {\n if (tourActive) return;\n if (!document.body.classList.contains('view-inspector')) return;\n if (!tourSeen('tw_tour_inspector_seen')) {\n startInspectorTour(() => markTourSeen('tw_tour_inspector_seen'));\n } else if (!tourSeen('tw_screen_hint_seen')) {\n openScreenHelp();\n markTourSeen('tw_screen_hint_seen');\n }\n }\n\n // The inspector tour runs against the mock #demo-stage (a Taqelah-demo login\n // screen) so it always has a realistic device to spotlight, connected or not.\n function showDemoStage() {\n const el = $('demo-stage');\n if (el) el.classList.add('show');\n }\n function hideDemoStage() {\n const el = $('demo-stage');\n if (el) el.classList.remove('show');\n }\n function startInspectorTour(onDone) {\n if (tourActive) return;\n // Connected \u2192 spotlight the REAL panes. Not connected \u2192 illustrate with the\n // mock demo device so there's still something to point at.\n if (document.body.classList.contains('view-inspector')) {\n startTour(INSPECTOR_TOUR_LIVE, onDone);\n return;\n }\n showDemoTab('rec');\n showDemoStage();\n startTour(INSPECTOR_TOUR_DEMO, function () {\n hideDemoStage();\n if (onDone) onDone();\n });\n }\n\n // \u2500\u2500\u2500 Screen \"how to use\" hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function openScreenHelp() {\n const el = $('screen-help-pop');\n if (el) el.classList.add('show');\n }\n function closeScreenHelp() {\n const el = $('screen-help-pop');\n if (el) el.classList.remove('show');\n }\n\n function initTutorial() {\n $('btn-help').onclick = openHelp;\n $('help-close').onclick = closeHelp;\n $('help-overlay').onclick = (e) => {\n if (e.target === $('help-overlay')) closeHelp();\n };\n $('help-tour-setup').onclick = () => {\n closeHelp();\n startTour(SETUP_TOUR);\n };\n $('help-tour-inspector').onclick = () => {\n closeHelp();\n startInspectorTour();\n };\n $('tour-next').onclick = tourNext;\n $('tour-back').onclick = tourBack;\n $('tour-skip').onclick = endTour;\n // Screen-pane help affordance.\n const shBtn = $('screen-help-btn');\n if (shBtn)\n shBtn.onclick = () => {\n const el = $('screen-help-pop');\n if (el && el.classList.contains('show')) closeScreenHelp();\n else openScreenHelp();\n };\n const shClose = $('screen-help-close');\n if (shClose) shClose.onclick = closeScreenHelp;\n const shOk = $('screen-help-ok2');\n if (shOk) shOk.onclick = closeScreenHelp;\n }\n\n initTutorial();\n bootstrap();\n})();\n</script>\n</body>\n</html>\n";
|
|
1
|
+
export declare const INSPECTOR_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>taqwright codegen</title>\n<link rel=\"icon\" type=\"image/png\" href=\"/static/logo.png\" />\n<style>\n :root {\n color-scheme: light;\n --bg: #ffffff;\n --panel: #f6f8fa;\n --panel-2: #eaeef2;\n --border: #d0d7de;\n --border-strong: #afb8c1;\n --text: #1f2328;\n --text-dim: #656d76;\n --text-muted: #8b949e;\n --accent: #0969da;\n --accent-hover: #0550ae;\n --success: #1a7f37;\n --warn: #9a6700;\n --danger: #cf222e;\n --code-bg: #f6f8fa;\n --hl: rgba(9, 105, 218, 0.10);\n --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;\n }\n * { box-sizing: border-box; }\n html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);\n font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, system-ui, sans-serif;\n -webkit-font-smoothing: antialiased; }\n /* \u2500\u2500\u2500 View switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n body.view-setup main { display: none; }\n body.view-setup .inspector-only { display: none !important; }\n body.view-inspector #setup { display: none; }\n body.view-inspector .setup-only { display: none !important; }\n /* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n header { display: flex; align-items: center; gap: 10px; padding: 8px 16px;\n background: var(--panel); border-bottom: 1px solid var(--border); height: 52px; }\n header .logo { height: 32px; width: auto; object-fit: contain; border-radius: 6px;\n flex-shrink: 0; }\n header h1 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -0.01em;\n color: var(--text); }\n header h1 .brand { color: var(--accent); font-weight: 700; }\n header .dot { color: var(--text-muted); margin: 0 4px; }\n header .meta { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }\n header .spacer { flex: 1; }\n header .header-ad { display: inline-flex; align-items: center; gap: 5px;\n text-decoration: none; font-size: 11.5px; color: var(--text-dim);\n background: var(--panel-2); border: 1px solid var(--border);\n padding: 4px 10px; border-radius: 999px; white-space: nowrap;\n transition: color 0.1s, border-color 0.1s, background 0.1s; }\n header .header-ad:hover { color: var(--accent); border-color: var(--accent);\n background: var(--bg); }\n header .header-ad-arrow { font-size: 11px; opacity: 0.8; }\n @media (max-width: 720px) { header .header-ad-text { display: none; } }\n button.icon { background: var(--panel-2); border: 1px solid var(--border);\n color: var(--text-dim); padding: 6px 10px; border-radius: 6px; font: inherit;\n cursor: pointer; white-space: nowrap; transition: background 0.1s, color 0.1s; }\n button.icon:hover { background: var(--border); color: var(--text); }\n button.icon.active { background: var(--accent); color: #fff; border-color: var(--accent); }\n button.icon.danger { background: var(--danger); color: #fff; border-color: var(--danger); }\n button.icon.danger:hover { background: #b81c28; border-color: #b81c28; color: #fff; }\n button.icon:disabled { opacity: 0.5; cursor: not-allowed; }\n /* \u2500\u2500\u2500 Setup landing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #setup { padding: 16px 20px; max-width: 1100px; margin: 0 auto;\n height: calc(100vh - 52px); display: flex; flex-direction: column;\n gap: 12px; box-sizing: border-box; }\n /* \u2500\u2500\u2500 Wizard (3-step setup flow) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .wizard-stepper { display: flex; align-items: center; gap: 0;\n padding: 4px 4px 8px; flex-shrink: 0; }\n .wizard-step-pill { display: inline-flex; align-items: center; gap: 9px;\n padding: 5px 14px 5px 5px; border-radius: 999px;\n background: var(--panel); border: 1px solid var(--border);\n color: var(--text-dim); font-size: 12.5px; font-weight: 500;\n user-select: none; transition: all 0.15s; }\n .wizard-step-pill .num { display: inline-flex; align-items: center;\n justify-content: center; width: 22px; height: 22px; border-radius: 50%;\n font-weight: 700; font-size: 11.5px; background: var(--panel-2);\n color: var(--text-muted); border: 1px solid var(--border);\n font-family: var(--mono); flex-shrink: 0; }\n .wizard-step-pill.active { color: var(--text); border-color: var(--accent);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.10); }\n .wizard-step-pill.active .num { background: var(--accent); color: white;\n border-color: var(--accent); }\n .wizard-step-pill.done { color: var(--success);\n border-color: rgba(26,127,55,0.35); cursor: pointer; }\n .wizard-step-pill.done:hover { background: #dafbe1; }\n .wizard-step-pill.done .num { background: var(--success); color: white;\n border-color: var(--success); }\n .wizard-step-pill.done .num .digit { display: none; }\n .wizard-step-pill.done .num::before { content: \"\u2713\"; }\n .wizard-line { flex: 1; height: 2px; background: var(--border); margin: 0 6px;\n border-radius: 1px; transition: background 0.25s; min-width: 24px;\n max-width: 80px; }\n .wizard-line.done { background: var(--success); }\n .wizard-content { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 0 2px; }\n .wizard-page { display: none; }\n .wizard-page.active { display: block; animation: wizardIn 0.22s ease-out; }\n @keyframes wizardIn {\n from { opacity: 0; transform: translateY(6px); }\n to { opacity: 1; transform: translateY(0); }\n }\n .wizard-page-head { margin: 0 0 14px; padding: 0 2px; }\n .wizard-page-head h2 { font-size: 17px; font-weight: 600; margin: 0 0 4px;\n letter-spacing: -0.01em; color: var(--text); }\n .wizard-page-head p { font-size: 12.5px; color: var(--text-dim); margin: 0;\n line-height: 1.5; }\n /* Step 1: connection-mode picker */\n .conn-mode-card { margin: 0 0 14px; }\n .conn-mode-label { font-size: 12px; font-weight: 600; color: var(--text-dim);\n text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;\n padding-left: 2px; }\n .conn-mode-toggle { display: grid; grid-template-columns: repeat(3, 1fr);\n gap: 10px; }\n @media (max-width: 800px) { .conn-mode-toggle { grid-template-columns: 1fr; } }\n .conn-mode-btn { display: flex; align-items: center; gap: 12px;\n padding: 12px 14px; background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; cursor: pointer; text-align: left;\n transition: border-color 0.1s, background 0.1s, box-shadow 0.1s;\n font: inherit; color: var(--text); min-width: 0; }\n .conn-mode-btn:hover { background: var(--panel-2);\n border-color: var(--border-strong); }\n .conn-mode-btn.active { border-color: var(--accent);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.10); }\n .conn-mode-ico { font-size: 22px; line-height: 1; flex-shrink: 0;\n width: 32px; height: 32px; border-radius: 8px;\n background: var(--panel-2); display: inline-flex;\n align-items: center; justify-content: center;\n border: 1px solid var(--border); }\n .conn-mode-btn.active .conn-mode-ico { background: var(--accent); color: white;\n border-color: var(--accent); }\n .conn-mode-body { display: flex; flex-direction: column; gap: 2px;\n min-width: 0; }\n .conn-mode-title { font-weight: 600; font-size: 13.5px; color: var(--text); }\n .conn-mode-sub { font-size: 11.5px; color: var(--text-dim); }\n /* Step 1: prereqs grid */\n .prereq-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;\n align-content: start; }\n @media (max-width: 800px) { .prereq-grid { grid-template-columns: 1fr; } }\n /* Indeterminate \"checking\" progress bar above prereqs */\n .prereq-progress { height: 3px; background: var(--panel-2); border-radius: 2px;\n overflow: hidden; margin: 0 2px 14px; opacity: 1; position: relative;\n transition: opacity 0.35s; }\n .prereq-progress.done { opacity: 0; pointer-events: none; }\n .prereq-progress::before { content: \"\"; position: absolute; top: 0; bottom: 0;\n background: linear-gradient(90deg, transparent 0%, var(--accent) 50%, transparent 100%);\n width: 35%; left: 0;\n animation: prereqSlide 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite; }\n @keyframes prereqSlide {\n from { transform: translateX(-100%); }\n to { transform: translateX(380%); }\n }\n /* Step 3: app browse row */\n .app-browse-row { display: grid; grid-template-columns: 90px 1fr auto;\n align-items: center; gap: 8px; margin-bottom: 4px; }\n .app-browse-row label { font-size: 12px; color: var(--text-dim); }\n .app-browse-row input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; min-width: 0; width: 100%; }\n .app-browse-row input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .app-browse-row .browse-btn { padding: 5px 12px; flex-shrink: 0; }\n .app-inspect-status { font-size: 11.5px; color: var(--text-dim);\n margin: 0 0 8px 98px; min-height: 14px; font-family: var(--mono); }\n .app-inspect-status.ok { color: var(--success); }\n .app-inspect-status.err { color: var(--danger); }\n .app-inspect-status.busy { color: var(--accent); }\n .app-inspect-status .spinner { display: inline-block; width: 10px; height: 10px;\n border: 2px solid rgba(9,105,218,0.25); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite;\n margin-right: 6px; vertical-align: -2px; }\n /* Wizard footer reuses .action-bar styling. Back button alignment. */\n .action-bar.wizard-bar { justify-content: flex-start; }\n .action-bar.wizard-bar .grow { flex: 1; }\n /* Devices card */\n .device-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border);\n margin-bottom: 12px; padding-bottom: 0; }\n .device-tab { background: transparent; border: none; color: var(--text-dim);\n font: 12.5px inherit; padding: 8px 14px; cursor: pointer;\n border-bottom: 2px solid transparent; margin-bottom: -1px;\n display: inline-flex; align-items: center; gap: 6px; }\n .device-tab:hover { color: var(--text); }\n .device-tab.active { color: var(--text); border-bottom-color: var(--accent);\n font-weight: 600; }\n .device-tab .count { font-size: 10.5px; color: var(--text-muted);\n background: var(--panel-2); padding: 1px 7px; border-radius: 999px;\n border: 1px solid var(--border); font-family: var(--mono); font-weight: 500; }\n .device-tab.active .count { color: var(--accent); border-color: rgba(9,105,218,0.3);\n background: #ddf4ff; }\n .device-grid { display: grid; gap: 10px;\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }\n .device-pagination { display: flex; align-items: center; justify-content: center;\n gap: 12px; margin-top: 12px; padding-top: 8px; }\n .device-pagination .info { font-size: 11.5px; color: var(--text-dim);\n font-family: var(--mono); }\n .device-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }\n .device-tile { display: flex; flex-direction: column; gap: 4px;\n padding: 12px 12px 10px; border-radius: 8px; background: var(--panel-2);\n border: 1px solid var(--border); position: relative;\n transition: border-color 0.1s, background 0.1s, box-shadow 0.1s; }\n .device-tile.selectable { cursor: pointer; }\n .device-tile.selectable:hover { border-color: rgba(9,105,218,0.4);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%); }\n .device-tile.selected { border-color: var(--accent); border-width: 2px;\n padding: 11px 11px 9px;\n background: linear-gradient(180deg, #ddf4ff 0%, #f1f8ff 100%);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .device-tile .check { position: absolute; bottom: 8px; right: 8px;\n width: 22px; height: 22px; border-radius: 50%; background: var(--accent);\n color: white; display: none; align-items: center; justify-content: center;\n font-size: 12px; font-weight: 700; line-height: 1;\n box-shadow: 0 2px 6px rgba(9,105,218,0.35); }\n .device-tile.selected .check { display: inline-flex; }\n .device-tile.booted { background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n border-color: rgba(9,105,218,0.3); }\n .device-tile.booting { background: linear-gradient(180deg, #fff8c5 0%, var(--panel) 100%);\n border-color: rgba(154,103,0,0.35); }\n .device-tile .pill.booting { color: var(--warn);\n border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .device-tile .pill.booting .led { display: none; }\n .device-tile .pill.booting .spinner { display: inline-block; width: 9px; height: 9px;\n border: 1.5px solid rgba(154,103,0,0.25); border-top-color: var(--warn);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .device-tile .top { display: flex; align-items: center; gap: 8px; }\n .device-tile .icon { font-size: 22px; line-height: 1; }\n .device-tile .name { flex: 1; font-weight: 600; font-size: 13px; color: var(--text);\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .device-tile .meta { font-size: 11.5px; color: var(--text-dim); font-family: var(--mono);\n margin-left: 30px; }\n .device-tile .udid { font-size: 10.5px; color: var(--text-muted); font-family: var(--mono);\n margin-left: 30px; word-break: break-all; }\n .device-tile .pill { padding: 1px 7px; font-size: 10px; }\n .device-tile .actions { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }\n .device-tile .actions .icon { padding: 4px 9px; font-size: 11.5px; }\n .device-tile .actions .icon.use { background: var(--accent); color: white;\n border-color: var(--accent); }\n .device-tile .actions .icon.use:hover { background: var(--accent-hover);\n border-color: var(--accent-hover); }\n .device-empty { padding: 12px 0; color: var(--text-muted); font-size: 12px; font-style: italic; }\n .device-empty .rec-sel-spinner { width: 13px; height: 13px; border-width: 1.5px;\n vertical-align: -2px; margin-right: 6px; font-style: normal; }\n .device-warn { padding: 8px 10px; margin-bottom: 10px; font-size: 12px;\n background: #fff8c5; border: 1px solid rgba(154,103,0,0.35);\n color: var(--warn); border-radius: 5px; font-family: var(--mono); }\n .card { background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; padding: 12px 14px; }\n .card.flex { display: flex; flex-direction: column; min-height: 0; }\n .card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }\n .card-head h2 { font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); margin: 0; }\n .card-head .grow { flex: 1; }\n /* Doctor */\n .doctor-summary { display: flex; align-items: center; gap: 8px;\n padding: 6px 8px; border-radius: 5px; font-size: 12.5px;\n background: var(--panel-2); cursor: pointer; user-select: none; }\n .doctor-summary:hover { background: var(--border); }\n .doctor-summary .twisty { color: var(--text-muted); margin-left: auto; font-size: 10px; }\n .doctor-summary .pill { padding: 1px 7px; font-size: 10px; }\n .doctor-list { list-style: none; margin: 8px 0 0; padding: 0; display: none; }\n .doctor-list.expanded { display: block;\n max-height: clamp(140px, calc(100vh - 430px), 360px); overflow-y: auto; }\n .doctor-list li { display: block; padding: 3px 8px; font-size: 12px; }\n .doctor-row { display: flex; align-items: center; gap: 8px; min-width: 0; }\n .doctor-list .ico { width: 14px; flex-shrink: 0; text-align: center; font-weight: 700;\n font-family: var(--mono); font-size: 11px; }\n .doctor-list .ico.ok { color: var(--success); }\n .doctor-list .ico.warn { color: var(--warn); }\n .doctor-list .ico.error { color: var(--danger); }\n .doctor-list .name { color: var(--text); min-width: 0; overflow-wrap: anywhere; }\n .doctor-list .detail { color: var(--text-dim); font-family: var(--mono);\n font-size: 11px; margin-left: auto; text-align: right;\n min-width: 0; overflow-wrap: anywhere; }\n .doctor-list .detail-block { margin: 2px 0 4px 22px; color: var(--text-dim);\n font-family: var(--mono); font-size: 11px; line-height: 1.45;\n overflow-wrap: anywhere; word-break: break-word; }\n /* Inputs */\n .field { display: grid; grid-template-columns: 90px 1fr; align-items: center;\n gap: 8px; margin-bottom: 6px; }\n .field label { font-size: 12px; color: var(--text-dim); }\n .field input, .field select { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; width: 100%; }\n .field select { font-family: inherit; cursor: pointer; }\n .field input:focus, .field select:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .field-tri { display: grid; grid-template-columns: 90px 1fr 60px 90px;\n align-items: center; gap: 8px; margin-bottom: 8px; }\n .field-tri label { font-size: 12px; color: var(--text-dim); }\n .field-tri input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 13px var(--mono); outline: none; min-width: 0; width: 100%; }\n .field-tri input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .checkbox { display: flex; align-items: center; gap: 6px; padding: 4px 0;\n font-size: 12px; color: var(--text); cursor: pointer; }\n /* Standalone checkbox row \u2014 used for noReset etc. (no left-gutter) */\n .checkbox-row { display: flex; align-items: center; gap: 8px;\n padding: 8px 10px; margin: 4px 0; border-radius: 5px;\n background: var(--panel-2); border: 1px solid var(--border);\n cursor: pointer; user-select: none; }\n .checkbox-row:hover { background: var(--border); }\n .checkbox-row input { margin: 0; flex-shrink: 0; }\n .checkbox-row .label { color: var(--text); font-size: 13px; font-weight: 500; }\n .checkbox-row .hint { color: var(--text-dim); font-size: 12px; margin-left: auto; }\n /* Pills */\n .pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;\n border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.04em;\n text-transform: uppercase; border: 1px solid var(--border); }\n .pill .led { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }\n .pill.live { color: var(--success); border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .pill.live .led { background: var(--success); box-shadow: 0 0 6px rgba(26,127,55,0.5); }\n .pill.down { color: var(--warn); border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .pill.down .led { background: var(--warn); }\n .pill.booting { color: var(--warn); border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .pill.booting .led { width: 9px; height: 9px; background: transparent; box-shadow: none;\n border: 1.5px solid rgba(154,103,0,0.3); border-top-color: var(--warn);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .appium-hint { font-size: 11px; color: var(--text-dim); line-height: 1.45; margin-top: 6px; }\n /* Capabilities */\n .caps-fields { flex: 1; min-height: 0; overflow: auto; padding-right: 4px; }\n .extras-head { display: flex; align-items: center; gap: 8px;\n color: var(--text-dim); font-size: 11px; font-weight: 700;\n text-transform: uppercase; letter-spacing: 0.08em;\n border-top: 1px solid var(--border); margin-top: 10px; padding: 12px 0 6px; }\n .extras-list { display: flex; flex-direction: column; gap: 6px; }\n .extras-list .empty-row { color: var(--text-muted); font-size: 12px;\n font-style: italic; padding: 6px 0; }\n .extra-cap { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(0,1fr) 28px;\n gap: 6px; align-items: center; }\n .extra-cap input { background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: 12.5px var(--mono); outline: none; width: 100%; min-width: 0; }\n .extra-cap input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .x-btn { background: transparent; border: 1px solid var(--border);\n color: var(--text-muted); width: 28px; height: 28px; border-radius: 5px;\n font-size: 16px; line-height: 1; cursor: pointer; padding: 0;\n display: inline-flex; align-items: center; justify-content: center; }\n .x-btn:hover { color: var(--danger); border-color: rgba(207,34,46,0.4);\n background: #ffebe9; }\n .add-cap-btn { display: inline-flex; align-items: center; gap: 6px;\n padding: 6px 12px; background: transparent; color: var(--accent);\n border: 1px dashed var(--border); border-radius: 5px;\n font: 12.5px inherit; cursor: pointer; margin-top: 8px; }\n .add-cap-btn:hover { background: var(--panel-2); border-color: var(--accent); }\n .add-cap-btn .plus { font-weight: 700; font-size: 14px; line-height: 1; }\n /* Sticky action bar */\n .action-bar { flex-shrink: 0; display: flex; align-items: center; gap: 12px;\n padding: 12px 16px; background: var(--panel); border-radius: 8px;\n border: 1px solid var(--border); }\n .action-summary { color: var(--text-dim); font-size: 12px; font-family: var(--mono);\n flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .action-summary strong { color: var(--text); font-weight: 600; }\n button.primary { background: var(--accent); color: white; border: none;\n padding: 8px 22px; border-radius: 6px; font: 600 13px inherit; cursor: pointer;\n transition: background 0.1s; }\n button.primary:hover { background: var(--accent-hover); }\n button.primary:disabled { opacity: 0.5; cursor: not-allowed; }\n .err-banner { color: var(--danger); font-size: 12px; padding: 6px 10px;\n background: #ffebe9; border: 1px solid rgba(207,34,46,0.3);\n border-radius: 5px; margin-top: 8px; font-family: var(--mono); display: none; }\n .err-banner.shown { display: block; }\n .info-banner { color: var(--text-dim); font-size: 12px; padding: 8px 10px;\n background: #ddf4ff; border: 1px solid rgba(9,105,218,0.3);\n border-radius: 5px; margin-top: 10px; }\n .btn-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }\n .btn-row .grow { flex: 1; }\n /* \u2500\u2500\u2500 Layout (inspector view) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n main { display: grid; grid-template-columns: minmax(280px, 30%) 1fr minmax(360px, 36%);\n height: calc(100vh - 52px); }\n .pane { overflow: hidden; display: flex; flex-direction: column;\n border-right: 1px solid var(--border); background: var(--bg); min-width: 0; }\n .pane:last-child { border-right: none; }\n .pane-head { padding: 10px 14px; border-bottom: 1px solid var(--border);\n display: flex; align-items: center; gap: 8px; background: var(--panel);\n flex-shrink: 0; height: 40px; }\n .pane-title { font-size: 11px; font-weight: 600; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); }\n .pane-body { flex: 1; overflow: auto; min-height: 0; }\n /* \u2500\u2500\u2500 Tree pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* Hierarchy view-mode toggle (Tree / XML) */\n .hier-mode-toggle { display: inline-flex; gap: 0; flex-shrink: 0;\n border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }\n .hier-mode-btn { background: var(--panel-2); border: none; color: var(--text-dim);\n padding: 3px 9px; font: 11px inherit; cursor: pointer;\n border-right: 1px solid var(--border); transition: background 0.1s, color 0.1s; }\n .hier-mode-btn:last-child { border-right: none; }\n .hier-mode-btn:hover { color: var(--text); }\n .hier-mode-btn.active { background: var(--accent); color: white; font-weight: 600; }\n .context-select { flex-shrink: 0; background: var(--panel-2); color: var(--text);\n border: 1px solid var(--border); border-radius: 5px; padding: 3px 7px;\n font: 11px inherit; cursor: pointer; max-width: 220px; }\n .context-select.web { border-color: var(--accent); color: var(--accent); font-weight: 600; }\n .context-hint { flex-shrink: 0; color: var(--muted); font: 11px inherit;\n cursor: pointer; padding: 3px 6px; border-radius: 5px; }\n .context-hint:hover { color: var(--text); background: var(--panel-2); }\n /* Hierarchy XML view */\n .hier-xml-body { padding: 0; background: var(--code-bg); }\n #hier-xml-pre { font-family: var(--mono); font-size: 11.5px;\n line-height: 1.45; white-space: pre; color: var(--text);\n margin: 0; padding: 8px 12px; }\n .tree-search { width: 100%; background: var(--panel-2); color: var(--text);\n border: 1px solid var(--border); padding: 5px 9px; border-radius: 5px;\n font: inherit; outline: none; }\n .tree-search:focus { border-color: var(--accent); }\n .hier-xml-body mark.xml-match { background: #fff3b0; color: inherit; border-radius: 2px; }\n .tree-body { padding: 6px 6px 12px; }\n ul.tree, ul.tree ul { list-style: none; padding-left: 14px; margin: 0; }\n ul.tree { padding-left: 4px; }\n li.node { white-space: nowrap; }\n li.node > .label { display: inline-flex; align-items: center; gap: 4px;\n padding: 3px 6px; cursor: pointer; border-radius: 4px; user-select: none;\n max-width: 100%; }\n li.node > .label:hover { background: rgba(0,0,0,0.04); }\n li.node.selected > .label { background: var(--hl); color: var(--text);\n box-shadow: inset 2px 0 0 var(--accent); }\n li.node.match > .label { outline: 1px solid var(--warn); outline-offset: -1px; }\n .twisty { display: inline-block; width: 12px; color: var(--text-muted);\n font-size: 9px; text-align: center; }\n .twisty.empty { visibility: hidden; }\n .tag { color: var(--accent); font-family: var(--mono); font-size: 12px; }\n .ident { color: var(--warn); font-family: var(--mono); font-size: 12px; }\n .text-snippet { color: var(--success); font-family: var(--mono); font-size: 12px; }\n /* \u2500\u2500\u2500 Screen pane \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #screen-wrap { display: flex; justify-content: center; align-items: flex-start;\n padding: 16px; }\n #screen-host { position: relative; display: inline-block; max-width: 100%;\n border: 1px solid var(--border); border-radius: 6px; overflow: hidden;\n background: #000; box-shadow: 0 6px 22px rgba(0,0,0,0.10); }\n #screen-img { display: block; max-width: 100%; max-height: calc(100vh - 100px);\n user-select: none; -webkit-user-drag: none; }\n /* Graceful fallback when a snapshot fails / returns no screenshot \u2014 shown\n instead of the browser's broken-image glyph. */\n .screen-unavailable-msg { display: none; box-sizing: border-box; width: 300px;\n max-width: 100%; min-height: 480px; max-height: calc(100vh - 100px);\n flex-direction: column; align-items: center; justify-content: center; gap: 8px;\n padding: 24px; text-align: center; color: var(--text-dim); }\n .screen-unavailable-title { font-size: 14px; font-weight: 600; color: var(--text); }\n .screen-unavailable-sub { font-size: 12.5px; }\n #screen-host.screen-unavailable #screen-img { display: none; }\n #screen-host.screen-unavailable .screen-unavailable-msg { display: flex; }\n #highlight { position: absolute; border: 2px solid var(--accent);\n background: rgba(9,105,218,0.12); box-shadow: 0 0 0 9999px rgba(0,0,0,0.40) inset;\n pointer-events: none; transition: all 0.12s ease-out; }\n .screen-action-overlay { position: absolute; inset: 0; display: none; z-index: 5;\n align-items: center; justify-content: center; background: rgba(0,0,0,0.32); }\n .screen-action-overlay.shown { display: flex; }\n .screen-action-card { display: flex; align-items: center; gap: 10px;\n background: var(--panel); color: var(--text); border: 1px solid var(--border);\n border-radius: 8px; padding: 10px 14px; font-size: 12.5px; font-weight: 600;\n box-shadow: 0 6px 22px rgba(0,0,0,0.25); }\n .screen-action-check { display: none; color: var(--success); font-size: 16px; font-weight: 700; }\n .screen-action-overlay.done .rec-sel-spinner { display: none; }\n .screen-action-overlay.done .screen-action-check { display: inline; }\n .screen-action-overlay.done .screen-action-card { color: var(--success); }\n /* \u2500\u2500\u2500 Right pane (tabs) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .tabs { display: flex; background: var(--panel); border-bottom: 1px solid var(--border);\n flex-shrink: 0; }\n .tab { padding: 10px 16px; cursor: pointer; color: var(--text-dim);\n font-size: 12px; font-weight: 500; border-bottom: 2px solid transparent;\n transition: color 0.1s, border-color 0.1s; }\n .tab:hover { color: var(--text); }\n .tab.active { color: var(--text); border-bottom-color: var(--accent); }\n .tab-content { padding: 14px 16px; overflow: auto; flex: 1; min-height: 0; }\n .tab-content.hidden { display: none; }\n /* \u2500\u2500\u2500 Attributes \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n table.attrs { width: 100%; border-collapse: collapse; font-size: 12px; }\n table.attrs td { padding: 5px 8px; vertical-align: top; }\n table.attrs tr:nth-child(even) { background: rgba(0,0,0,0.025); }\n table.attrs td:first-child { color: var(--text-dim); white-space: nowrap;\n width: 130px; font-family: var(--mono); }\n table.attrs td:last-child { color: var(--text); word-break: break-all; font-family: var(--mono); }\n /* \u2500\u2500\u2500 Type-into-field card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .type-card { background: var(--panel); border: 1px solid var(--accent);\n border-radius: 8px; padding: 12px; margin-bottom: 14px;\n box-shadow: 0 0 0 1px rgba(9,105,218,0.15); }\n .type-row { display: flex; gap: 6px; margin-top: 8px; }\n .type-input { flex: 1; background: var(--code-bg); color: var(--text);\n border: 1px solid var(--border); padding: 7px 11px; border-radius: 5px;\n font: 13px var(--mono); outline: none; }\n .type-input:focus { border-color: var(--accent); }\n .type-hint { font-size: 11px; color: var(--text-dim); margin-top: 6px;\n font-family: var(--mono); }\n .type-hint code { background: var(--code-bg); padding: 1px 5px; border-radius: 3px;\n border: 1px solid var(--border); }\n /* \"Build relative xpath\" affordance + result card */\n .build-rel-btn { display: flex; align-items: center; gap: 10px;\n width: 100%; padding: 10px 12px; margin-top: 10px;\n background: var(--panel); color: var(--text);\n border: 1px dashed var(--border); border-radius: 8px;\n font: 13px inherit; cursor: pointer; text-align: left;\n transition: border-color 0.1s, background 0.1s; }\n .build-rel-btn:hover { background: var(--panel-2); border-color: var(--accent); }\n .build-rel-btn .ico { font-size: 16px; line-height: 1; }\n .build-rel-btn .body { flex: 1; min-width: 0;\n display: flex; flex-direction: column; gap: 2px; }\n .build-rel-btn .title { display: block; font-weight: 600; font-size: 13px; }\n .build-rel-btn .sub { display: block; font-size: 11.5px; color: var(--text-dim); }\n .rel-card { background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%);\n border: 1px solid rgba(9,105,218,0.35); border-radius: 8px;\n padding: 12px; margin-bottom: 12px; }\n .rel-card .anchor-line { font-size: 11.5px; color: var(--text-dim);\n margin-bottom: 8px; }\n .rel-card .anchor-line strong { color: var(--accent); font-family: var(--mono); }\n .rel-card .rel-tip { display: flex; gap: 8px; align-items: flex-start;\n margin-top: 10px; padding: 9px 11px; border-radius: 6px;\n background: #fff8c5; border: 1px solid rgba(154,103,0,0.35);\n color: #4d3800; font-size: 11.5px; line-height: 1.45; }\n .rel-card .rel-tip .ico { flex-shrink: 0; font-size: 14px; line-height: 1.2; }\n .rel-card .rel-tip code { background: rgba(154,103,0,0.12); padding: 1px 5px;\n border-radius: 3px; font-family: var(--mono); font-size: 11px; }\n /* \u2500\u2500\u2500 Locator cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .loc-card { background: var(--panel); border: 1px solid var(--border);\n border-radius: 8px; padding: 12px; margin-bottom: 12px; }\n .loc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }\n .cat-badge { font-size: 10px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.06em; padding: 3px 8px; border-radius: 4px;\n background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); }\n .cat-badge.id { color: #0969da; border-color: rgba(9,105,218,0.35); background: #ddf4ff; }\n .cat-badge.uiautomator,\n .cat-badge.predicate { color: #6639ba; border-color: rgba(102,57,186,0.35); background: #f3e8ff; }\n .cat-badge.classChain { color: #9a6700; border-color: rgba(154,103,0,0.35); background: #fff8c5; }\n .cat-badge.xpath { color: #1a7f37; border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .cat-sub { font-size: 11px; color: var(--text-dim); }\n .badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; font-weight: 600;\n text-transform: uppercase; letter-spacing: 0.04em; }\n .badge.unique { background: #dafbe1; color: var(--success);\n border: 1px solid rgba(26,127,55,0.35); }\n .badge.collision { background: #ffebe9; color: var(--danger);\n border: 1px solid rgba(207,34,46,0.35); }\n .badge.empty { background: var(--panel); color: var(--text-muted);\n border: 1px solid var(--border); }\n .badge.positional { background: #fff4e0; color: #9a6700;\n border: 1px solid rgba(154,103,0,0.40); }\n .badge.recommended { background: #fff8c5; color: #7a5c00;\n border: 1px solid rgba(154,103,0,0.45); font-weight: 700; }\n .loc-card.is-rec { border-color: rgba(154,103,0,0.55);\n box-shadow: 0 0 0 1px rgba(154,103,0,0.25) inset; }\n .loc-spacer { flex: 1; }\n .loc-code { font-family: var(--mono); font-size: 12.5px; background: var(--code-bg);\n padding: 9px 11px; border-radius: 5px; word-break: break-all; line-height: 1.5;\n color: var(--text); border: 1px solid var(--border); }\n .loc-actions { display: flex; gap: 6px; margin-top: 9px; }\n .loc-actions button { flex-shrink: 0; }\n /* \u2500\u2500\u2500 Record tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* Recording toggle banner \u2014 top of the Record tab. */\n .rec-toggle { display: flex; align-items: center; gap: 12px;\n padding: 10px 14px; border-radius: 10px; margin-bottom: 14px;\n border: 1px solid var(--border); background: var(--panel);\n transition: background 0.15s, border-color 0.15s; }\n .rec-toggle.live { border-color: rgba(207,34,46,0.4);\n background: linear-gradient(180deg, #fff5f5 0%, var(--panel) 100%); }\n .rec-toggle .rec-led { width: 10px; height: 10px; border-radius: 50%;\n background: var(--text-muted); flex-shrink: 0; }\n .rec-toggle.live .rec-led { background: var(--danger);\n animation: rec-led-pulse 1.4s ease-in-out infinite; }\n @keyframes rec-led-pulse {\n 0%, 100% { box-shadow: 0 0 0 0 rgba(207,34,46,0.55); transform: scale(1); }\n 70% { box-shadow: 0 0 0 8px rgba(207,34,46,0); transform: scale(0.92); }\n }\n .rec-toggle .rec-status { flex: 1; font-size: 12.5px; color: var(--text-dim);\n line-height: 1.4; min-width: 0; }\n .rec-toggle .rec-status strong { color: var(--text); font-weight: 600; }\n .rec-toggle.live .rec-status strong { color: var(--danger); }\n .btn-rec-toggle { background: var(--danger); color: white; border: none;\n padding: 9px 16px; border-radius: 6px; font: 600 13px inherit;\n cursor: pointer; display: inline-flex; align-items: center; gap: 8px;\n transition: background 0.1s, transform 0.05s; flex-shrink: 0; }\n .btn-rec-toggle:hover { background: #a40e1c; }\n .btn-rec-toggle:active { transform: translateY(0.5px); }\n .btn-rec-toggle.stop { background: #1f2328; }\n .btn-rec-toggle.stop:hover { background: #0d1117; }\n .btn-rec-toggle .rec-ico { width: 12px; height: 12px; background: white;\n border-radius: 50%; flex-shrink: 0; }\n .btn-rec-toggle.stop .rec-ico { border-radius: 2px; }\n /* Selected element card \u2014 sticky context block at the top of the tab. */\n .rec-selected { display: flex; gap: 12px; align-items: flex-start;\n padding: 12px 14px; border-radius: 10px; margin-bottom: 16px;\n border: 1px solid var(--border); background: var(--panel);\n transition: background 0.15s, border-color 0.15s; }\n .rec-selected.has { border-color: rgba(9,105,218,0.35);\n background: linear-gradient(180deg, #f1f8ff 0%, var(--panel) 100%); }\n .rec-sel-icon { width: 32px; height: 32px; flex-shrink: 0; border-radius: 8px;\n background: var(--panel-2); color: var(--text-muted); font-size: 16px;\n display: flex; align-items: center; justify-content: center;\n border: 1px solid var(--border); }\n .rec-selected.has .rec-sel-icon { background: var(--accent); color: white;\n border-color: var(--accent); }\n .rec-sel-body { flex: 1; min-width: 0; }\n .rec-sel-title { font-weight: 600; font-size: 13.5px; color: var(--text);\n line-height: 1.3; }\n .rec-sel-sub { font-size: 11.5px; color: var(--text-dim); margin-top: 4px;\n font-family: var(--mono); word-break: break-all;\n background: var(--code-bg); padding: 4px 8px; border-radius: 4px;\n border: 1px solid var(--border); display: inline-block; max-width: 100%; }\n .rec-selected:not(.has) .rec-sel-sub { background: transparent; border: none;\n padding: 0; font-family: inherit; color: var(--text-muted);\n display: block; width: 100%; }\n .rec-no-unique { color: var(--warn); font-size: 12px; line-height: 1.45;\n margin-bottom: 8px; }\n .rec-sel-spinner { display: inline-block; width: 16px; height: 16px;\n border: 2px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.7s linear infinite; }\n .rec-resolving-hint { display: block; margin-top: 4px; font-size: 11px;\n color: var(--text-muted); line-height: 1.4; }\n .empty-state .rec-sel-spinner { width: 13px; height: 13px; border-width: 1.5px;\n vertical-align: -2px; margin-right: 6px; }\n\n /* Action groups */\n .rec-group { margin-bottom: 18px; }\n .rec-group-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;\n font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }\n .rec-group-title .grow { flex: 1; }\n .rec-subtitle { font-size: 11px; color: var(--text-muted); margin: 14px 0 8px;\n font-weight: 500; letter-spacing: 0.02em; }\n .rec-subtitle:first-child { margin-top: 0; }\n\n /* Action buttons */\n .rec-grid { display: grid; gap: 7px;\n grid-template-columns: repeat(auto-fit, minmax(115px, 1fr)); }\n .rec-grid.cols-2 { grid-template-columns: repeat(2, 1fr); }\n .rec-act { background: var(--panel); color: var(--text);\n border: 1px solid var(--border); padding: 9px 11px; border-radius: 6px;\n font: 13px inherit; cursor: pointer;\n transition: background 0.1s, border-color 0.1s, transform 0.05s, box-shadow 0.1s;\n display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n white-space: nowrap; min-width: 0; }\n .rec-act .ico { font-size: 14px; line-height: 1; }\n .rec-act:hover:not(:disabled) { background: var(--panel-2);\n border-color: var(--border-strong);\n box-shadow: 0 1px 3px rgba(0,0,0,0.04); }\n .rec-act:active:not(:disabled) { transform: translateY(0.5px); box-shadow: none; }\n .rec-act:disabled { opacity: 0.4; cursor: not-allowed; }\n .rec-act.primary { background: var(--accent); color: white;\n border-color: var(--accent); font-weight: 600; padding: 11px 14px; }\n .rec-act.primary:hover:not(:disabled) { background: var(--accent-hover);\n border-color: var(--accent-hover); }\n .rec-act.primary:disabled { background: var(--accent); opacity: 0.35; }\n\n /* Y/X-range row for custom screen-scroll */\n .rec-y-range { display: flex; flex-direction: column; gap: 8px;\n margin-top: 8px; padding: 8px 10px; border-radius: 5px;\n border: 1px dashed var(--border); background: var(--panel-2); }\n .rec-y-range-label { display: flex; gap: 8px; align-items: baseline;\n font-size: 11px; color: var(--text-dim); flex-wrap: wrap; }\n .rec-y-range-defaults { color: var(--text-muted); font-size: 10.5px;\n font-family: var(--mono); }\n .rec-y-range-fields { display: flex; align-items: center; gap: 12px;\n flex-wrap: wrap; }\n .rec-y-cell { display: inline-flex; align-items: center; gap: 4px;\n color: var(--text-dim); font-size: 12px; }\n .rec-y-cell input { width: 48px; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 4px 6px; border-radius: 4px;\n font: 12px var(--mono); outline: none; text-align: right; }\n .rec-y-cell input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n /* Text-input row */\n .rec-input-row { display: flex; gap: 6px; }\n .rec-input { flex: 1; min-width: 0; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 8px 12px; border-radius: 6px;\n font: 13px var(--mono); outline: none; }\n .rec-input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .rec-input:disabled { background: var(--panel); color: var(--text-muted); }\n .rec-input-row .rec-act { padding: 7px 12px; flex-shrink: 0; }\n\n /* Assertion row inputs (text/value) */\n .rec-assert-row { display: flex; gap: 6px; margin-top: 6px; align-items: center; }\n .rec-assert-row input { flex: 1; min-width: 0; background: #fff; color: var(--text);\n border: 1px solid var(--border); padding: 7px 11px; border-radius: 6px;\n font: 12.5px var(--mono); outline: none; }\n .rec-assert-row input:focus { border-color: var(--accent);\n box-shadow: 0 0 0 3px rgba(9,105,218,0.15); }\n .rec-assert-row input:disabled { background: var(--panel); color: var(--text-muted); }\n /* Record subtabs (Actions / Screen / Assertions) */\n .rec-subtabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px;\n padding: 3px; margin-bottom: 14px; background: var(--panel-2);\n border: 1px solid var(--border); border-radius: 8px; }\n .rec-subtab { border: none; background: transparent; color: var(--text-dim);\n font: 12px inherit; font-weight: 600; padding: 7px 10px; border-radius: 6px;\n cursor: pointer; }\n .rec-subtab:hover { color: var(--text); }\n .rec-subtab.active { background: var(--panel); color: var(--text);\n box-shadow: 0 1px 2px rgba(0,0,0,0.06); }\n .rec-pane.hidden { display: none; }\n /* Pick-target hint banner */\n .rec-pickhint { display: flex; align-items: center; gap: 8px;\n background: #fff8c5; color: var(--warn); border: 1px solid rgba(154,103,0,0.35);\n padding: 9px 13px; border-radius: 6px; font-size: 12.5px; margin-bottom: 14px; }\n .rec-pickhint .pulse { width: 8px; height: 8px; border-radius: 50%;\n background: var(--warn); flex-shrink: 0;\n animation: rec-pulse 1.2s ease-in-out infinite; }\n @keyframes rec-pulse {\n 0%, 100% { opacity: 1; transform: scale(1); }\n 50% { opacity: 0.55; transform: scale(0.8); }\n }\n .rec-pickhint button { margin-left: auto; }\n\n /* Recorded script */\n .lang-seg { display: inline-flex; gap: 2px; margin-right: 6px; }\n .lang-seg button { padding: 3px 8px; font-size: 11px; }\n .lang-seg button.active { background: var(--accent); color: #fff; border-color: var(--accent); }\n .rec-lang-note { font-size: 11px; color: var(--text-dim); margin: 2px 2px 8px; }\n .rec-script-card { background: var(--code-bg); border: 1px solid var(--border);\n border-radius: 8px; padding: 0; overflow: hidden; }\n .rec-script-card pre { background: transparent; padding: 12px 14px;\n font-family: var(--mono); font-size: 12.5px; line-height: 1.6;\n white-space: pre-wrap; word-break: normal; overflow-wrap: anywhere; color: var(--text);\n margin: 0; max-height: 320px; overflow: auto; }\n .rec-script-card pre:empty::before { content: \"// no actions yet \u2014 start recording and interact with the device\";\n color: var(--text-muted); font-style: italic; }\n /* Syntax-highlight tokens (GitHub light theme palette). */\n .tok-kw { color: #cf222e; }\n .tok-str { color: #0a3069; }\n .tok-num { color: #0550ae; }\n .tok-cmt { color: #6e7781; font-style: italic; }\n .tok-fn { color: #8250df; }\n .tok-id { color: #1f2328; }\n .tok-pun { color: #57606a; }\n\n /* Pick-mode cursor on the screen host */\n #screen-host.pick-mode { cursor: crosshair;\n outline: 2px dashed var(--warn); outline-offset: -2px; }\n /* \u2500\u2500\u2500 Empty states \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .empty-state { padding: 40px 16px; text-align: center; color: var(--text-muted); }\n .empty-state svg { opacity: 0.4; margin-bottom: 12px; }\n /* \u2500\u2500\u2500 Loader overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .loader-overlay { position: fixed; inset: 0; z-index: 1100;\n background: rgba(255, 255, 255, 0.92);\n backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);\n display: none; flex-direction: column; align-items: center; justify-content: center;\n gap: 16px; opacity: 0; transition: opacity 0.18s ease-out; }\n .loader-overlay.shown { display: flex; opacity: 1; }\n .loader-spinner { width: 42px; height: 42px;\n border: 3px solid var(--border); border-top-color: var(--accent);\n border-radius: 50%; animation: loader-spin 0.85s linear infinite; }\n @keyframes loader-spin { to { transform: rotate(360deg); } }\n .loader-message { color: var(--text); font-size: 14px; font-weight: 600;\n margin-top: 4px; }\n .loader-sub { color: var(--text-dim); font-size: 12.5px;\n max-width: 380px; text-align: center; line-height: 1.45; }\n #loader-cancel { display: none; margin-top: 6px; background: var(--panel-2);\n color: var(--text); border: 1px solid var(--border); border-radius: 6px;\n padding: 7px 16px; font-size: 12.5px; font-weight: 600; cursor: pointer; }\n #loader-cancel:hover { background: var(--border); }\n #loader-cancel.shown { display: inline-block; }\n /* \u2500\u2500\u2500 Toast notifications \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #toasts { position: fixed; top: 60px; right: 16px; z-index: 1000;\n display: flex; flex-direction: column; gap: 8px;\n max-width: 420px; min-width: 280px; pointer-events: none; }\n .toast { background: var(--panel); border: 1px solid var(--border);\n border-left: 4px solid var(--accent); padding: 10px 12px 10px 14px;\n border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.10);\n display: flex; align-items: flex-start; gap: 10px;\n pointer-events: auto; animation: toast-in 0.18s ease-out;\n font-size: 12.5px; line-height: 1.45; }\n .toast.error { border-left-color: var(--danger); }\n .toast.success { border-left-color: var(--success); }\n .toast.info { border-left-color: var(--accent); }\n .toast .title { color: var(--text); font-weight: 600; margin-bottom: 2px; }\n .toast.error .title { color: var(--danger); }\n .toast.success .title { color: var(--success); }\n .toast .body { flex: 1; color: var(--text); word-break: break-word; min-width: 0; }\n .toast .body .msg { color: var(--text-dim); }\n .toast .close { background: transparent; border: none; color: var(--text-muted);\n cursor: pointer; padding: 0; font-size: 16px; line-height: 1;\n flex-shrink: 0; margin-top: 1px; }\n .toast .close:hover { color: var(--text); }\n @keyframes toast-in {\n from { transform: translateX(20px); opacity: 0; }\n to { transform: translateX(0); opacity: 1; }\n }\n .toast.fading { animation: toast-out 0.18s ease-in forwards; }\n @keyframes toast-out {\n to { transform: translateX(20px); opacity: 0; }\n }\n /* \u2500\u2500\u2500 Confirm modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #modal-overlay { position: fixed; inset: 0; z-index: 2000;\n background: rgba(27,31,36,0.45); backdrop-filter: blur(2px);\n display: none; align-items: center; justify-content: center; padding: 20px;\n animation: modal-fade 0.12s ease-out; }\n #modal-overlay.open { display: flex; }\n .modal-card { background: var(--bg); border: 1px solid var(--border);\n border-radius: 12px; box-shadow: 0 16px 48px rgba(0,0,0,0.24);\n width: 100%; max-width: 420px; overflow: hidden;\n animation: modal-pop 0.14s cubic-bezier(0.2,0.9,0.3,1.1); }\n .modal-body { padding: 22px 22px 18px; display: flex; gap: 14px; align-items: flex-start; }\n .modal-icon { flex-shrink: 0; width: 38px; height: 38px; border-radius: 50%;\n display: inline-flex; align-items: center; justify-content: center;\n font-size: 20px; line-height: 1;\n background: rgba(207,34,46,0.12); color: var(--danger); }\n .modal-text { flex: 1; min-width: 0; }\n .modal-title { font-size: 15px; font-weight: 600; color: var(--text);\n margin: 1px 0 6px; }\n .modal-msg { font-size: 13px; color: var(--text-dim); line-height: 1.5; }\n .modal-actions { display: flex; justify-content: flex-end; gap: 8px;\n padding: 0 22px 18px; }\n .modal-btn { padding: 8px 16px; border-radius: 7px; font: inherit;\n font-size: 13px; font-weight: 500; cursor: pointer;\n border: 1px solid var(--border); background: var(--panel-2);\n color: var(--text); transition: background 0.1s, border-color 0.1s; }\n .modal-btn:hover { background: var(--border); }\n .modal-btn.confirm { background: var(--danger); border-color: var(--danger);\n color: #fff; }\n .modal-btn.confirm:hover { background: #b81c28; border-color: #b81c28; }\n .modal-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n @keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }\n @keyframes modal-pop {\n from { transform: translateY(8px) scale(0.97); opacity: 0; }\n to { transform: translateY(0) scale(1); opacity: 1; }\n }\n /* \u2500\u2500\u2500 Status bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #status { position: fixed; bottom: 8px; left: 12px; background: var(--panel);\n border: 1px solid var(--border); padding: 4px 10px; border-radius: 4px;\n font-size: 11px; color: var(--text-dim); font-family: var(--mono);\n transition: opacity 0.3s; z-index: 10; }\n #status.busy { color: var(--accent); }\n /* Hide the status pill on the setup view \u2014 would overlap the action-bar\n Connect button at the bottom of the viewport. The action bar's own\n \"Connecting\u2026\" label + toasts are enough. */\n body.view-setup #status { display: none; }\n /* \u2500\u2500\u2500 Scrollbars \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n ::-webkit-scrollbar { width: 10px; height: 10px; }\n ::-webkit-scrollbar-track { background: transparent; }\n ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 5px;\n border: 2px solid var(--bg); }\n ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }\n /* \u2500\u2500\u2500 Guided tour (spotlight coach-marks) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #tour-overlay { position: fixed; inset: 0; z-index: 1000; display: none; }\n #tour-overlay.show { display: block; }\n /* Click-catcher so the tour is modal \u2014 the app stays put behind the dimmer. */\n #tour-catcher { position: absolute; inset: 0; }\n #tour-spotlight { position: absolute; border-radius: 8px; pointer-events: none;\n box-shadow: 0 0 0 9999px rgba(8,12,20,0.55), 0 0 0 2px var(--accent),\n 0 0 0 6px rgba(31,111,235,0.35); transition: all 0.18s ease; }\n #tour-pop { position: absolute; width: 320px; max-width: calc(100vw - 24px);\n background: var(--panel); color: var(--text); border: 1px solid var(--border-strong);\n border-radius: 10px; box-shadow: 0 12px 40px rgba(0,0,0,0.35); padding: 14px 16px;\n font-size: 13px; line-height: 1.5; }\n #tour-pop h3 { margin: 0 0 6px; font-size: 14px; }\n #tour-pop .tour-body { color: var(--text-dim); }\n #tour-pop .tour-body b { color: var(--text); }\n #tour-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; }\n #tour-progress { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }\n #tour-foot .grow { flex: 1; }\n #tour-skip { position: absolute; top: 8px; right: 10px; background: none; border: none;\n color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px; }\n #tour-skip:hover { color: var(--text); }\n /* \u2500\u2500\u2500 Help reference panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #help-overlay { position: fixed; inset: 0; z-index: 1100; display: none;\n background: rgba(8,12,20,0.5); }\n #help-overlay.show { display: flex; align-items: center; justify-content: center; }\n #help-panel { width: 720px; max-width: calc(100vw - 32px); max-height: calc(100vh - 64px);\n overflow: auto; background: var(--panel); border: 1px solid var(--border-strong);\n border-radius: 12px; box-shadow: 0 18px 60px rgba(0,0,0,0.4); padding: 20px 22px; }\n #help-panel .help-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }\n #help-panel .help-head h2 { margin: 0; font-size: 17px; }\n #help-panel .help-head .grow { flex: 1; }\n #help-panel .help-lead { color: var(--text-dim); font-size: 13px; margin: 0 0 14px; }\n #help-close { background: none; border: none; color: var(--text-muted); cursor: pointer;\n font-size: 20px; line-height: 1; padding: 2px 6px; }\n #help-close:hover { color: var(--text); }\n #help-panel details { border: 1px solid var(--border); border-radius: 8px;\n margin-bottom: 8px; background: var(--panel-2); overflow: hidden; }\n #help-panel summary { cursor: pointer; padding: 10px 12px; font-weight: 600; font-size: 13px;\n list-style: none; user-select: none; }\n #help-panel summary::-webkit-details-marker { display: none; }\n #help-panel summary::before { content: \"\u25B8 \"; color: var(--text-muted); }\n #help-panel details[open] summary::before { content: \"\u25BE \"; }\n #help-panel .help-sec { padding: 0 14px 12px 26px; color: var(--text-dim); font-size: 13px;\n line-height: 1.6; }\n #help-panel .help-sec b { color: var(--text); }\n #help-panel .help-sec code { font-family: var(--mono); font-size: 12px;\n background: var(--panel); border: 1px solid var(--border); border-radius: 4px;\n padding: 0 4px; }\n #help-panel .help-sec ul,\n #help-panel .help-sec ol { margin: 4px 0; padding-left: 18px; }\n #help-panel .help-sec li { margin: 3px 0; }\n /* \u2500\u2500\u2500 Screen \"how to use\" hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n .screen-help-btn { font-size: 11px; color: var(--text-dim); cursor: pointer;\n border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px;\n background: var(--panel-2); white-space: nowrap; }\n .screen-help-btn:hover { color: var(--text); border-color: var(--accent); }\n #screen-wrap { position: relative; }\n .screen-help-pop { display: none; position: absolute; top: 10px; left: 50%;\n transform: translateX(-50%); z-index: 20; width: 340px; max-width: calc(100% - 20px);\n background: var(--panel); color: var(--text); border: 1px solid var(--border-strong);\n border-radius: 10px; box-shadow: 0 10px 32px rgba(0,0,0,0.32); padding: 12px 14px;\n font-size: 12.5px; line-height: 1.5; }\n .screen-help-pop.show { display: block; }\n .screen-help-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; }\n .screen-help-pop ul { margin: 0 0 10px; padding-left: 18px; color: var(--text-dim); }\n .screen-help-pop ul b { color: var(--text); }\n .screen-help-pop li { margin: 3px 0; }\n .screen-help-x { position: absolute; top: 6px; right: 8px; background: none; border: none;\n color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 2px; }\n .screen-help-x:hover { color: var(--text); }\n .screen-help-ok { padding: 4px 12px; }\n /* \u2500\u2500\u2500 Demo stage (illustrated inspector for the tour) \u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n #demo-stage { display: none; position: fixed; inset: 0; z-index: 900;\n background: var(--bg); flex-direction: column; padding: 12px 16px 16px; }\n #demo-stage.show { display: flex; }\n .demo-bar { display: flex; align-items: center; gap: 10px; padding: 4px 2px 12px; }\n .demo-badge { font-size: 10px; font-weight: 800; letter-spacing: 0.06em; color: white;\n background: var(--accent); border-radius: 4px; padding: 2px 6px; }\n .demo-bar-title { font-size: 13px; color: var(--text-dim); }\n .demo-bar .grow { flex: 1; }\n .demo-panes { flex: 1; display: grid; grid-template-columns: 1fr 1.1fr 1.2fr; gap: 12px;\n min-height: 0; }\n .demo-pane { display: flex; flex-direction: column; min-height: 0;\n border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--panel); }\n .demo-pane .pane-body { flex: 1; overflow: auto; padding: 8px 10px; }\n .demo-seg { display: inline-flex; gap: 2px; background: var(--panel-2); border-radius: 6px;\n padding: 2px; font-size: 11px; }\n .demo-seg span { padding: 1px 8px; border-radius: 4px; color: var(--text-dim); }\n .demo-seg span.on { background: var(--panel); color: var(--text); font-weight: 600; }\n .demo-seg.sm { font-size: 10.5px; width: 100%; }\n .demo-seg.sm .grow { flex: 1; }\n .demo-search { font-size: 11px; color: var(--text-muted); border: 1px solid var(--border);\n border-radius: 6px; padding: 2px 8px; background: var(--panel-2); }\n .demo-meta { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }\n .demo-tree { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px;\n color: var(--text-dim); }\n .demo-tree li { padding: 2px 4px; border-radius: 4px; white-space: nowrap; }\n .demo-tree li.i1 { padding-left: 16px; }\n .demo-tree li.i2 { padding-left: 30px; }\n .demo-tree li.sel { background: rgba(31,111,235,0.14); color: var(--text); }\n .demo-id { color: #6639ba; }\n .demo-q { color: var(--success); }\n .demo-screen-body { display: flex; align-items: flex-start; justify-content: center; }\n .demo-phone { width: 220px; border: 8px solid #111723; border-radius: 26px; overflow: hidden;\n background: #fbf1ee; box-shadow: 0 8px 24px rgba(0,0,0,0.25); }\n .demo-statusbar { display: flex; justify-content: space-between; font-size: 9px; color: #3a3a3a;\n padding: 4px 12px; background: #fbf1ee; }\n .demo-app { padding: 22px 18px 22px; display: flex; flex-direction: column; align-items: center;\n gap: 10px; background: #fbf1ee; min-height: 340px; }\n .demo-app-logo { width: 56px; height: 56px; border-radius: 14px; background: #f5333b;\n display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0;\n margin-top: 6px; }\n .demo-logo-glass { font-size: 20px; line-height: 1; }\n .demo-logo-name { font-size: 9px; font-weight: 800; color: #fff; }\n .demo-app-title { font-size: 19px; font-weight: 800; color: #1b1320; margin: 2px 0 0; }\n .demo-app-sub { font-size: 11px; color: #9b8f93; margin-bottom: 6px; }\n .demo-field { width: 100%; height: 36px; border: 1px solid #e3d7d6; border-radius: 10px;\n background: #fdf8f7; display: flex; align-items: center; gap: 8px; padding: 0 10px; }\n .demo-field.sel { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(31,111,235,0.30); }\n .demo-field-ico { font-size: 13px; opacity: 0.7; }\n .demo-field-ph { font-size: 12px; color: #9b8f93; }\n .demo-field-eye { margin-left: auto; font-size: 12px; opacity: 0.55; }\n .demo-app-btn { width: 100%; height: 40px; border: none; border-radius: 10px; color: #fff;\n background: #a0185a; font-size: 14px; font-weight: 700; margin-top: 6px; }\n .demo-creds { width: 100%; margin-top: 8px; padding: 10px 12px; border-radius: 12px;\n background: #f6e7ea; text-align: center; font-size: 10.5px; color: #5a4a4f; line-height: 1.5; }\n .demo-creds-title { font-weight: 800; color: #a0185a; margin-bottom: 2px; font-size: 11px; }\n .demo-tabwrap { flex: 1; overflow: auto; padding: 10px 12px; }\n .demo-tabpane.hidden { display: none; }\n .demo-rec-banner { font-size: 12px; color: var(--text-dim); display: flex; align-items: center;\n gap: 6px; margin-bottom: 10px; }\n .demo-rec-banner b { color: var(--text); }\n .demo-led { width: 8px; height: 8px; border-radius: 50%; background: var(--danger); }\n .demo-rec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }\n .demo-act { border: 1px solid var(--border); border-radius: 7px; padding: 7px 8px; font-size: 12px;\n color: var(--text-dim); background: var(--panel-2); }\n .demo-act.primary { grid-column: 1 / -1; border-color: var(--accent); color: var(--text);\n background: rgba(31,111,235,0.10); font-weight: 600; }\n .demo-subtabs { margin-top: 10px; font-size: 11px; color: var(--text-muted); }\n .demo-code { font-family: var(--mono); font-size: 11.5px; line-height: 1.5; color: var(--text);\n background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px;\n white-space: pre-wrap; margin: 8px 0 0; }\n .demo-loc-row { display: flex; align-items: center; gap: 8px; padding: 5px 2px; font-size: 12px;\n border-bottom: 1px solid var(--border); }\n .demo-loc-row code { font-family: var(--mono); font-size: 11px; color: var(--text-dim);\n overflow-wrap: anywhere; }\n .demo-cat { font-size: 10px; font-weight: 700; border: 1px solid var(--border); border-radius: 999px;\n padding: 1px 7px; color: var(--text-dim); white-space: nowrap; }\n .demo-cat.id { color: var(--success); border-color: rgba(26,127,55,0.35); background: #dafbe1; }\n .demo-cat.uiautomator { color: #6639ba; border-color: rgba(102,57,186,0.35); background: #f3e8ff; }\n .demo-cat.xpath { color: var(--text-muted); }\n .demo-pick { margin-left: auto; font-size: 10px; color: var(--accent); font-weight: 600; }\n .demo-attrs { width: 100%; border-collapse: collapse; font-size: 11.5px; }\n .demo-attrs td { border-bottom: 1px solid var(--border); padding: 4px 6px; vertical-align: top; }\n .demo-attrs td:first-child { color: var(--text-muted); font-family: var(--mono); width: 38%; }\n</style>\n</head>\n<body class=\"view-setup\">\n<header>\n <img class=\"logo\" src=\"/static/logo.png\" alt=\"taqwright\" />\n <h1><span class=\"brand\">taqwright</span> codegen</h1>\n <span class=\"dot\">\u00B7</span>\n <span class=\"meta\" id=\"session-meta\">setup</span>\n <span class=\"spacer\"></span>\n <a class=\"header-ad\" href=\"https://www.taqwright.ai/\" target=\"_blank\" rel=\"noopener noreferrer\"\n title=\"taqwright \u2014 In-sprint mobile UI automation, on autopilot\">\n <span class=\"header-ad-text\">In-sprint mobile UI automation, on autopilot.</span>\n <span class=\"header-ad-arrow\" aria-hidden=\"true\">\u2197</span>\n </a>\n <button class=\"icon\" id=\"btn-help\" title=\"Help & guided tour\">? Help</button>\n <button class=\"icon danger inspector-only\" id=\"btn-disconnect\" title=\"End the WebDriver session and return to setup\">Disconnect</button>\n <button class=\"primary attached-only\" id=\"btn-resume\" title=\"Resume the paused test and close this inspector\" style=\"display:none\">Resume \u25B6</button>\n</header>\n\n<!-- \u2500\u2500\u2500 Setup landing view (3-step wizard) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"setup\" class=\"setup-only\">\n <!-- Stepper -->\n <div class=\"wizard-stepper\" role=\"tablist\">\n <div class=\"wizard-step-pill active\" data-step=\"1\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">1</span></span>\n <span class=\"label\">Prerequisites</span>\n </div>\n <span class=\"wizard-line\"></span>\n <div class=\"wizard-step-pill\" data-step=\"2\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">2</span></span>\n <span class=\"label\">Select device</span>\n </div>\n <span class=\"wizard-line\"></span>\n <div class=\"wizard-step-pill\" data-step=\"3\" role=\"tab\">\n <span class=\"num\"><span class=\"digit\">3</span></span>\n <span class=\"label\">Configure & connect</span>\n </div>\n </div>\n\n <div class=\"wizard-content\">\n <!-- \u2500\u2500\u2500 Step 1: connection mode + prereqs / cloud auth \u2500\u2500\u2500 -->\n <div class=\"wizard-page active\" data-page=\"1\">\n <div class=\"wizard-page-head\">\n <h2>Check prerequisites</h2>\n <p id=\"step1-intro\">Confirming the CLIs you need (adb, xcrun, Java) are installed and that the Appium server is reachable. If the Appium pill is grey, click <strong>Start Appium</strong> \u2014 <strong>Next</strong> unlocks once it turns green.</p>\n </div>\n\n <!-- Connection mode -->\n <div class=\"conn-mode-card\">\n <div class=\"conn-mode-label\">Where will the device run?</div>\n <div class=\"conn-mode-toggle\" role=\"tablist\">\n <button class=\"conn-mode-btn active\" data-conn-mode=\"local\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\uD83D\uDDA5</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">Local</span>\n <span class=\"conn-mode-sub\">Emulators & simulators on this machine</span>\n </span>\n </button>\n <button class=\"conn-mode-btn\" data-conn-mode=\"browserstack\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\u2601</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">BrowserStack</span>\n <span class=\"conn-mode-sub\">App Automate cloud devices</span>\n </span>\n </button>\n <button class=\"conn-mode-btn\" data-conn-mode=\"lambdatest\" type=\"button\" role=\"tab\">\n <span class=\"conn-mode-ico\">\u2601</span>\n <span class=\"conn-mode-body\">\n <span class=\"conn-mode-title\">LambdaTest</span>\n <span class=\"conn-mode-sub\">Real-device cloud</span>\n </span>\n </button>\n </div>\n </div>\n\n <!-- Local prereqs (env + appium) -->\n <div id=\"step1-local-block\">\n <div class=\"prereq-progress\" id=\"prereq-progress\"></div>\n <div class=\"prereq-grid\">\n <div class=\"card card-env\">\n <div class=\"card-head\">\n <h2>Environment</h2>\n <span class=\"grow\"></span>\n </div>\n <div class=\"doctor-summary\" id=\"doctor-summary\">\n <span id=\"doctor-summary-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"doctor-summary-label\">checking\u2026</span></span>\n <span class=\"grow\"></span>\n <span class=\"twisty\" id=\"doctor-twisty\">\u25BE</span>\n </div>\n <ul class=\"doctor-list\" id=\"doctor-list\"></ul>\n </div>\n <div class=\"card card-appium\">\n <div class=\"card-head\">\n <h2>Appium server</h2>\n <span class=\"grow\"></span>\n <span id=\"appium-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"appium-pill-label\">checking\u2026</span></span>\n </div>\n <div class=\"field-tri\">\n <label for=\"appium-host\">host</label>\n <input id=\"appium-host\" />\n <label for=\"appium-port\" style=\"text-align:right\">port</label>\n <input id=\"appium-port\" />\n </div>\n <div class=\"field\">\n <label for=\"appium-path\">path</label>\n <input id=\"appium-path\" />\n </div>\n <div class=\"btn-row\" style=\"margin-top:8px\">\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"btn-appium-recheck\">Recheck</button>\n <button class=\"icon\" id=\"btn-appium-restart\">Restart Appium</button>\n <button class=\"icon\" id=\"btn-appium-start\">Start Appium</button>\n </div>\n <div id=\"appium-start-hint\" class=\"appium-hint\" style=\"display:none\">First start can take up to a minute while the UiAutomator2 / XCUITest drivers load.</div>\n </div>\n </div>\n </div>\n\n <!-- Cloud creds card (BrowserStack / LambdaTest) -->\n <div id=\"step1-cloud-block\" style=\"display:none\">\n <div class=\"card\">\n <div class=\"card-head\">\n <h2 id=\"cloud-creds-title\">Cloud credentials</h2>\n <span class=\"grow\"></span>\n <span id=\"cloud-creds-pill\" class=\"pill down\"><span class=\"led\"></span><span id=\"cloud-creds-pill-label\">awaiting\u2026</span></span>\n </div>\n <div class=\"field\">\n <label for=\"cloud-user\">Username</label>\n <input id=\"cloud-user\" placeholder=\"username\" autocomplete=\"off\" />\n </div>\n <div class=\"field\">\n <label for=\"cloud-key\">Access key</label>\n <input id=\"cloud-key\" type=\"password\" placeholder=\"access key\" autocomplete=\"off\" />\n </div>\n <div id=\"cloud-creds-hint\" class=\"info-banner\" style=\"margin-top:10px\"></div>\n </div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Step 2: select device \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"wizard-page\" data-page=\"2\">\n <div class=\"wizard-page-head\">\n <h2>Pick a device</h2>\n <p>Boot an emulator or simulator below, then <strong>tap a running device</strong> to select it (Android or iOS \u2014 only your last tap counts). Click <strong>Next</strong> when ready.</p>\n </div>\n <div class=\"card card-devices\">\n <div class=\"card-head\">\n <h2>Devices</h2>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"btn-devices-refresh\" type=\"button\">\u21BB Refresh</button>\n </div>\n <div id=\"devices-warn\"></div>\n <div class=\"device-tabs\" role=\"tablist\">\n <button class=\"device-tab active\" data-device-tab=\"android\" type=\"button\">\n <span>Android</span><span class=\"count\" id=\"device-count-android\">0</span>\n </button>\n <button class=\"device-tab\" data-device-tab=\"ios\" type=\"button\">\n <span>iOS</span><span class=\"count\" id=\"device-count-ios\">0</span>\n </button>\n </div>\n <div class=\"device-grid\" id=\"device-grid\"></div>\n <div class=\"device-pagination\" id=\"device-pagination\"></div>\n </div>\n </div>\n\n <!-- \u2500\u2500\u2500 Step 3: capabilities + connect \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"wizard-page\" data-page=\"3\">\n <div class=\"wizard-page-head\">\n <h2>Configure capabilities & connect</h2>\n <p>The device you picked already filled most of these. Optionally browse for an <strong>.apk</strong>, <strong>.ipa</strong>, <strong>.app</strong>, or <strong>.app.zip</strong> to install \u2014 its package / bundle ID will populate automatically.</p>\n </div>\n <div class=\"card card-caps flex\">\n <div class=\"card-head\">\n <h2>Capabilities</h2>\n </div>\n <div class=\"caps-fields\">\n <div class=\"field\">\n <label for=\"cap-platform\">Platform</label>\n <select id=\"cap-platform\">\n <option value=\"Android\">Android \u00B7 UiAutomator2</option>\n <option value=\"iOS\">iOS \u00B7 XCUITest</option>\n </select>\n </div>\n <div class=\"field\">\n <label for=\"cap-device\">Device</label>\n <input id=\"cap-device\" placeholder=\"emulator-5554, Pixel 6, iPhone 15\u2026\" />\n </div>\n <div class=\"field\">\n <label for=\"cap-version\">OS version</label>\n <input id=\"cap-version\" placeholder=\"optional \u00B7 e.g. 14, 17.0\" />\n </div>\n <div class=\"app-browse-row\">\n <label for=\"cap-app\">App</label>\n <input id=\"cap-app\" placeholder=\"optional \u00B7 path to .apk / .ipa / .app / .app.zip\" />\n <button class=\"icon browse-btn\" id=\"btn-app-browse\" type=\"button\" title=\"Pick a file with the system file dialog\">Browse\u2026</button>\n </div>\n <div class=\"app-inspect-status\" id=\"app-inspect-status\"></div>\n <div class=\"field\">\n <label for=\"cap-bundle\"><span id=\"cap-bundle-label\">Package</span></label>\n <input id=\"cap-bundle\" placeholder=\"optional \u00B7 com.example.app\" />\n </div>\n <div class=\"field\">\n <label for=\"cap-udid\">UDID</label>\n <input id=\"cap-udid\" placeholder=\"optional \u00B7 device serial\" />\n </div>\n <label class=\"checkbox-row\" for=\"cap-noreset\">\n <input type=\"checkbox\" id=\"cap-noreset\" checked />\n <span class=\"label\">noReset</span>\n <span class=\"hint\">don't reinstall the app between sessions</span>\n </label>\n <div class=\"extras-head\">\n <span>Extra capabilities</span>\n <span style=\"flex:1\"></span>\n </div>\n <div class=\"extras-list\" id=\"extras-list\"></div>\n <button class=\"add-cap-btn\" id=\"btn-add-cap\" type=\"button\">\n <span class=\"plus\">+</span><span>Add capability</span>\n </button>\n </div>\n <datalist id=\"known-caps\">\n <option value=\"appium:autoGrantPermissions\">\n <option value=\"appium:autoAcceptAlerts\">\n <option value=\"appium:autoDismissAlerts\">\n <option value=\"appium:fullReset\">\n <option value=\"appium:enforceAppInstall\">\n <option value=\"appium:dontStopAppOnReset\">\n <option value=\"appium:skipServerInstallation\">\n <option value=\"appium:skipDeviceInitialization\">\n <option value=\"appium:appActivity\">\n <option value=\"appium:appWaitActivity\">\n <option value=\"appium:appWaitPackage\">\n <option value=\"appium:appWaitDuration\">\n <option value=\"appium:newCommandTimeout\">\n <option value=\"appium:orientation\">\n <option value=\"appium:language\">\n <option value=\"appium:locale\">\n <option value=\"appium:systemPort\">\n <option value=\"appium:adbPort\">\n <option value=\"appium:mjpegServerPort\">\n <option value=\"appium:mjpegScreenshotUrl\">\n <option value=\"appium:chromedriverExecutable\">\n <option value=\"appium:nativeWebScreenshot\">\n <option value=\"appium:disableWindowAnimation\">\n <option value=\"appium:wdaLocalPort\">\n <option value=\"appium:wdaLaunchTimeout\">\n <option value=\"appium:wdaConnectionTimeout\">\n <option value=\"appium:simulatorStartupTimeout\">\n <option value=\"appium:useNewWDA\">\n <option value=\"appium:usePrebuiltWDA\">\n <option value=\"appium:webDriverAgentUrl\">\n <option value=\"appium:resetOnSessionStartOnly\">\n <option value=\"appium:nativeWebTap\">\n <option value=\"appium:printPageSourceOnFindFailure\">\n <option value=\"browserName\">\n <option value=\"appium:browserName\">\n </datalist>\n </div>\n </div>\n </div>\n\n <!-- Wizard navigation footer (Back / Next or Connect) -->\n <div class=\"action-bar wizard-bar\">\n <button class=\"primary\" id=\"btn-step-back\" type=\"button\" style=\"display:none\">\u2190 Back</button>\n <div class=\"action-summary\" id=\"connect-summary\">Connect to <strong>localhost:4725</strong> \u00B7 <strong>Android</strong> \u00B7 UiAutomator2</div>\n <button class=\"primary\" id=\"btn-step-next\" type=\"button\">Next \u2192</button>\n <button class=\"primary\" id=\"btn-connect\" type=\"button\" style=\"display:none\">Connect \u2192</button>\n </div>\n</div>\n<main>\n <!-- \u2500\u2500\u2500 Tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Hierarchy</span>\n <div class=\"hier-mode-toggle\" role=\"tablist\" aria-label=\"hierarchy view\">\n <button class=\"hier-mode-btn active\" data-hier-mode=\"tree\" type=\"button\" role=\"tab\">Tree</button>\n <button class=\"hier-mode-btn\" data-hier-mode=\"xml\" type=\"button\" role=\"tab\">XML</button>\n </div>\n <span class=\"loc-spacer\"></span>\n <input class=\"tree-search\" id=\"tree-search\" placeholder=\"filter by tag, id, text\u2026\" />\n </div>\n <div class=\"pane-body tree-body\" id=\"hier-tree-body\">\n <ul class=\"tree\" id=\"tree\"></ul>\n </div>\n <div class=\"pane-body hier-xml-body\" id=\"hier-xml-body\" style=\"display:none\">\n <pre id=\"hier-xml-pre\"></pre>\n </div>\n </div>\n <!-- \u2500\u2500\u2500 Screen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Screen</span>\n <span class=\"screen-help-btn\" id=\"screen-help-btn\" role=\"button\" tabindex=\"0\"\n title=\"What can I do on the screen?\">\u24D8 How to use</span>\n <span class=\"meta\" id=\"screen-meta\" style=\"margin-left:auto\"></span>\n <select class=\"context-select hidden\" id=\"context-select\"\n title=\"Automation context \u2014 switch into a WebView to inspect the web DOM\"></select>\n <span class=\"context-hint hidden\" id=\"context-hint\" role=\"button\" tabindex=\"0\"\n title=\"No WebView context detected \u2014 click for help\">\u24D8 No WebView</span>\n </div>\n <div class=\"pane-body\" id=\"screen-wrap\">\n <div class=\"screen-help-pop\" id=\"screen-help-pop\" role=\"dialog\" aria-label=\"Using the screen\">\n <button class=\"screen-help-x\" id=\"screen-help-close\" type=\"button\" aria-label=\"Dismiss\">\u00D7</button>\n <div class=\"screen-help-title\">Working on the screen</div>\n <ul>\n <li><b>Click any element</b> on the screen to <b>select</b> it \u2014 then read its\n <b>Attributes</b> / <b>Locators</b>, or record an action on it from the <b>Record</b> tab.</li>\n <li>The blue box highlights the selected element's bounds.</li>\n <li>Hard to hit something small or overlapping? Pick it from the <b>Hierarchy</b> tree on the left.</li>\n <li>When recording a <b>tap at coordinates</b> or a <b>drag target</b>, click the exact spot on the screen.</li>\n </ul>\n <button class=\"primary screen-help-ok\" id=\"screen-help-ok2\" type=\"button\">Got it</button>\n </div>\n <div id=\"screen-host\">\n <img id=\"screen-img\" alt=\"device screen\" />\n <div class=\"screen-unavailable-msg\">\n <div class=\"screen-unavailable-title\">Device screen unavailable</div>\n <div class=\"screen-unavailable-sub\">Couldn't capture the device \u2014 retrying\u2026</div>\n </div>\n <div id=\"highlight\" style=\"display:none\"></div>\n <div id=\"screen-action-overlay\" class=\"screen-action-overlay\" aria-hidden=\"true\">\n <div class=\"screen-action-card\">\n <span class=\"rec-sel-spinner\"></span>\n <span class=\"screen-action-check\">\u2713</span>\n <span id=\"screen-action-label\">Performing action\u2026</span>\n </div>\n </div>\n </div>\n </div>\n </div>\n <!-- \u2500\u2500\u2500 Inspector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n <div class=\"pane\">\n <div class=\"tabs\" role=\"tablist\">\n <div class=\"tab active\" data-tab=\"record\" role=\"tab\">Record</div>\n <div class=\"tab\" data-tab=\"script\" role=\"tab\">Recorded script</div>\n <div class=\"tab\" data-tab=\"locators\" role=\"tab\">Locators</div>\n <div class=\"tab\" data-tab=\"attrs\" role=\"tab\">Attributes</div>\n </div>\n <div class=\"tab-content\" id=\"tab-record\">\n <!-- Recording start/stop banner -->\n <div class=\"rec-toggle\" id=\"rec-toggle\">\n <span class=\"rec-led\"></span>\n <div class=\"rec-status\" id=\"rec-status\">\n <strong>Not recording</strong> \u2014 press Start to capture actions as a script.\n </div>\n <button class=\"btn-rec-toggle\" id=\"btn-rec-toggle\" type=\"button\">\n <span class=\"rec-ico\"></span>\n <span id=\"btn-rec-toggle-label\">Start record</span>\n </button>\n </div>\n\n <!-- Pick-target banner (only shown while waiting for the user to click a point on the screen) -->\n <div class=\"rec-pickhint\" id=\"rec-pickhint\" style=\"display:none\">\n <span class=\"pulse\"></span>\n <span id=\"rec-pickhint-label\">Click a target on the screen to complete the action.</span>\n <button class=\"icon\" id=\"btn-rec-cancel\">Cancel</button>\n </div>\n\n <!-- Selected element card -->\n <div class=\"rec-selected\" id=\"rec-selected\">\n <div class=\"rec-sel-icon\" id=\"rec-sel-icon\">\u25CB</div>\n <div class=\"rec-sel-body\">\n <div class=\"rec-sel-title\" id=\"rec-sel-title\">No element selected</div>\n <div class=\"rec-sel-sub\" id=\"rec-sel-sub\">Tap an element on the screen or in the Hierarchy.</div>\n </div>\n </div>\n\n <!-- Subtab bar -->\n <div class=\"rec-subtabs\" role=\"tablist\">\n <button class=\"rec-subtab active\" data-subtab=\"actions\" type=\"button\">Actions</button>\n <button class=\"rec-subtab\" data-subtab=\"screen\" type=\"button\">Screen</button>\n <button class=\"rec-subtab\" data-subtab=\"assert\" type=\"button\">Assertions</button>\n </div>\n\n <!-- Actions pane (element-scoped) -->\n <div class=\"rec-pane\" id=\"rec-pane-actions\">\n <button class=\"rec-act primary\" data-act=\"click\" disabled style=\"width:100%\">\n <span class=\"ico\">\u25B6</span><span>Click</span>\n </button>\n <button class=\"rec-act\" data-screen=\"tap-point\" style=\"width:100%;margin-top:7px\">\n <span class=\"ico\">\u2299</span><span>Click @ coordinates</span>\n </button>\n <div class=\"rec-grid cols-2\" style=\"margin-top:7px\">\n <button class=\"rec-act\" data-act=\"doubleTap\" disabled><span class=\"ico\">\u23EF</span><span>Double tap</span></button>\n <button class=\"rec-act\" data-act=\"longPress\" disabled><span class=\"ico\">\u23F1</span><span>Long press</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Toggle</div>\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-act=\"check\" disabled><span class=\"ico\">\u2611</span><span>Check</span></button>\n <button class=\"rec-act\" data-act=\"uncheck\" disabled><span class=\"ico\">\u2610</span><span>Uncheck</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Focus</div>\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-act=\"focus\" disabled><span class=\"ico\">\u2316</span><span>Focus</span></button>\n <button class=\"rec-act\" data-act=\"blur\" disabled><span class=\"ico\">\u2298</span><span>Blur</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Type text</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-type-input\" placeholder=\"Type text into the field\u2026\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-type\" disabled><span class=\"ico\">\u2328</span><span>Type</span></button>\n <button class=\"rec-act\" id=\"btn-rec-clear\" disabled title=\"Clear the field\"><span class=\"ico\">\u232B</span><span>Clear</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Type sequentially (one char at a time)</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-seq-input\" placeholder=\"Text\u2026\" disabled />\n <input class=\"rec-input\" id=\"rec-seq-delay\" placeholder=\"delay (ms)\" inputmode=\"numeric\" style=\"max-width:90px\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-seq\" disabled><span class=\"ico\">\u2328</span><span>Type slowly</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Press key</div>\n <div class=\"rec-input-row\">\n <select class=\"rec-input\" id=\"rec-press-key\" disabled>\n <option value=\"Enter\">Enter</option>\n <option value=\"Tab\">Tab</option>\n <option value=\"Backspace\">Backspace</option>\n <option value=\"Space\">Space</option>\n <option value=\"Escape\">Escape</option>\n <option value=\"ArrowUp\">ArrowUp</option>\n <option value=\"ArrowDown\">ArrowDown</option>\n <option value=\"ArrowLeft\">ArrowLeft</option>\n <option value=\"ArrowRight\">ArrowRight</option>\n <option value=\"Delete\">Delete</option>\n <option value=\"Home\">Home</option>\n <option value=\"End\">End</option>\n <option value=\"PageUp\">PageUp</option>\n <option value=\"PageDown\">PageDown</option>\n </select>\n <button class=\"rec-act\" id=\"btn-rec-press\" disabled><span class=\"ico\">\u23CE</span><span>Press</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Select picker option</div>\n <div class=\"rec-input-row\">\n <input class=\"rec-input\" id=\"rec-select-label\" placeholder=\"Option label\u2026\" disabled />\n <button class=\"rec-act\" id=\"btn-rec-select\" disabled><span class=\"ico\">\u25BC</span><span>Select</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Swipe within element</div>\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-act=\"swipe-left\" disabled><span class=\"ico\">\u2190</span><span>Left</span></button>\n <button class=\"rec-act\" data-act=\"swipe-right\" disabled><span class=\"ico\">\u2192</span><span>Right</span></button>\n <button class=\"rec-act\" data-act=\"swipe-up\" disabled><span class=\"ico\">\u2191</span><span>Up</span></button>\n <button class=\"rec-act\" data-act=\"swipe-down\" disabled><span class=\"ico\">\u2193</span><span>Down</span></button>\n </div>\n\n <div class=\"rec-subtitle\">Gestures</div>\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-act=\"pinch-in\" disabled><span class=\"ico\">\u2296</span><span>Pinch in</span></button>\n <button class=\"rec-act\" data-act=\"pinch-out\" disabled><span class=\"ico\">\u2295</span><span>Pinch out</span></button>\n <button class=\"rec-act\" data-act=\"scrollIntoView\" disabled><span class=\"ico\">\u2195</span><span>Scroll to</span></button>\n <button class=\"rec-act\" data-act=\"dragToPoint\" disabled title=\"Drag the selected element onto a target you pick\"><span class=\"ico\">\u26F6</span><span>Drag to target</span></button>\n </div>\n </div>\n\n <!-- Screen pane (no element selection required) -->\n <div class=\"rec-pane hidden\" id=\"rec-pane-screen\">\n <div class=\"rec-grid cols-2\">\n <button class=\"rec-act\" data-screen=\"scroll-up\"><span class=\"ico\">\u2191</span><span>Scroll up</span></button>\n <button class=\"rec-act\" data-screen=\"scroll-down\"><span class=\"ico\">\u2193</span><span>Scroll down</span></button>\n </div>\n <div class=\"rec-y-range\">\n <div class=\"rec-y-range-label\">\n <span>Custom region (% of screen, optional)</span>\n <span class=\"rec-y-range-defaults\">defaults: y 40\u201360% \u00B7 x 50%</span>\n </div>\n <div class=\"rec-y-range-fields\">\n <span class=\"rec-y-cell\">\n <span>y from</span>\n <input id=\"rec-scroll-top\" placeholder=\"40\" inputmode=\"numeric\" />\n <span>to</span>\n <input id=\"rec-scroll-bottom\" placeholder=\"60\" inputmode=\"numeric\" />\n <span>%</span>\n </span>\n <span class=\"rec-y-cell\">\n <span>x at</span>\n <input id=\"rec-scroll-x\" placeholder=\"50\" inputmode=\"numeric\" />\n <span>%</span>\n </span>\n <button class=\"icon\" id=\"btn-rec-y-clear\" type=\"button\" title=\"Clear range\">\u00D7</button>\n </div>\n </div>\n <div class=\"rec-grid\" style=\"margin-top:7px\">\n <button class=\"rec-act\" data-screen=\"drag-and-drop\" title=\"Pick a source element, then a drop target\"><span class=\"ico\">\u26F6</span><span>Drag & drop</span></button>\n </div>\n </div>\n\n <!-- Assertions pane (element-scoped) -->\n <div class=\"rec-pane hidden\" id=\"rec-pane-assert\">\n <div class=\"rec-grid\">\n <button class=\"rec-act\" data-assert=\"visible\" disabled><span class=\"ico\">\u2713</span><span>Visible</span></button>\n <button class=\"rec-act\" data-assert=\"hidden\" disabled><span class=\"ico\">\u2717</span><span>Hidden</span></button>\n <button class=\"rec-act\" data-assert=\"enabled\" disabled><span class=\"ico\">\uD83D\uDD13</span><span>Enabled</span></button>\n <button class=\"rec-act\" data-assert=\"disabled\" disabled><span class=\"ico\">\uD83D\uDD12</span><span>Disabled</span></button>\n <button class=\"rec-act\" data-assert=\"checked\" disabled><span class=\"ico\">\u2611</span><span>Checked</span></button>\n <button class=\"rec-act\" data-assert=\"unchecked\" disabled><span class=\"ico\">\u2610</span><span>Unchecked</span></button>\n <button class=\"rec-act\" data-assert=\"editable\" disabled><span class=\"ico\">\u270E</span><span>Editable</span></button>\n <button class=\"rec-act\" data-assert=\"readonly\" disabled><span class=\"ico\">\u2298</span><span>Readonly</span></button>\n <button class=\"rec-act\" data-assert=\"focused\" disabled><span class=\"ico\">\u2316</span><span>Focused</span></button>\n <button class=\"rec-act\" data-assert=\"attached\" disabled><span class=\"ico\">\u2693</span><span>Attached</span></button>\n <button class=\"rec-act\" data-assert=\"empty\" disabled><span class=\"ico\">\u2205</span><span>Empty</span></button>\n <button class=\"rec-act\" data-assert=\"inViewport\" disabled><span class=\"ico\">\uD83D\uDDBD</span><span>In viewport</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-text\" placeholder=\"text equals\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"text-exact\" disabled><span class=\"ico\">\u2261</span><span>Equals</span></button>\n <button class=\"rec-act\" data-assert=\"text-contains\" disabled><span class=\"ico\">\u2283</span><span>Contains</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-value\" placeholder=\"value equals\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"value\" disabled><span class=\"ico\">\u2261</span><span>Assert value</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-count\" placeholder=\"match count\u2026\" inputmode=\"numeric\" disabled />\n <button class=\"rec-act\" data-assert=\"count\" disabled><span class=\"ico\">#</span><span>Assert count</span></button>\n </div>\n <div class=\"rec-assert-row\">\n <input id=\"rec-assert-attr-name\" placeholder=\"attribute name\u2026\" disabled />\n <input id=\"rec-assert-attr-value\" placeholder=\"value\u2026\" disabled />\n <button class=\"rec-act\" data-assert=\"attribute\" disabled><span class=\"ico\">\u2261</span><span>Assert attribute</span></button>\n </div>\n </div>\n\n </div>\n <div class=\"tab-content hidden\" id=\"tab-script\">\n <div class=\"rec-group\">\n <div class=\"rec-group-title\">\n Recorded script\n <span class=\"grow\"></span>\n <span class=\"lang-seg\" id=\"script-lang\">\n <button class=\"icon active\" data-lang=\"ts\" type=\"button\">Taqwright</button>\n <button class=\"icon\" data-lang=\"python\" type=\"button\">Python</button>\n <button class=\"icon\" data-lang=\"java\" type=\"button\">Java</button>\n </span>\n <button class=\"icon\" id=\"btn-copy-script\" type=\"button\">\u2398 Copy</button>\n <button class=\"icon\" id=\"btn-export-script\" type=\"button\" title=\"Save the recorded script into your project's tests folder\">\u2193 Export</button>\n <button class=\"icon\" id=\"btn-clear-script\" type=\"button\">Clear</button>\n </div>\n <div class=\"rec-lang-note\" id=\"script-lang-note\" style=\"display:none\">Steps only \u2014 paste into your own Appium test (driver/setup not included).</div>\n <div class=\"rec-script-card\">\n <pre id=\"script\"></pre>\n </div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-locators\">\n <div class=\"empty-state\">\n <div>Select an element to see unique locator strategies.</div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-attrs\">\n <div class=\"empty-state\">\n <div>Select an element.</div>\n </div>\n </div>\n <div class=\"tab-content hidden\" id=\"tab-script-OLD-UNUSED\" style=\"display:none\">\n <pre id=\"script-old\"></pre>\n </div>\n </div>\n</main>\n<div class=\"loader-overlay\" id=\"loader\" aria-live=\"polite\" aria-hidden=\"true\">\n <div class=\"loader-spinner\"></div>\n <div class=\"loader-message\" id=\"loader-msg\">Loading\u2026</div>\n <div class=\"loader-sub\" id=\"loader-sub\"></div>\n <button id=\"loader-cancel\" type=\"button\">Cancel</button>\n</div>\n<div id=\"toasts\" aria-live=\"polite\"></div>\n<div id=\"modal-overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"modal-title\">\n <div class=\"modal-card\">\n <div class=\"modal-body\">\n <span class=\"modal-icon\" id=\"modal-icon\">\u26A0\uFE0F</span>\n <div class=\"modal-text\">\n <div class=\"modal-title\" id=\"modal-title\">Are you sure?</div>\n <div class=\"modal-msg\" id=\"modal-msg\"></div>\n </div>\n </div>\n <div class=\"modal-actions\">\n <button class=\"modal-btn\" id=\"modal-cancel\">Cancel</button>\n <button class=\"modal-btn confirm\" id=\"modal-confirm\">Confirm</button>\n </div>\n </div>\n</div>\n<div id=\"status\">ready</div>\n\n<!-- \u2500\u2500\u2500 Demo stage (illustrated inspector for the Inspector tour) \u2500\u2500\u2500 -->\n<div id=\"demo-stage\" aria-hidden=\"true\">\n <div class=\"demo-bar\">\n <span class=\"demo-badge\">DEMO</span>\n <span class=\"demo-bar-title\">Inspector \u2014 example walkthrough (Taqelah demo app)</span>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"demo-disconnect\" type=\"button\" disabled>Disconnect</button>\n </div>\n <div class=\"demo-panes\">\n <!-- Hierarchy -->\n <div class=\"pane demo-pane\" id=\"demo-hier\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Hierarchy</span>\n <div class=\"demo-seg\"><span class=\"on\">Tree</span><span>XML</span></div>\n <span class=\"grow\"></span>\n <span class=\"demo-search\">filter by tag, id, text\u2026</span>\n </div>\n <div class=\"pane-body\">\n <ul class=\"demo-tree\">\n <li>\u25BE android.widget.FrameLayout</li>\n <li class=\"i1\">\u25BE android.view.View</li>\n <li class=\"i2 sel\">android.widget.EditText <span class=\"demo-q\">hint=\"Username\"</span></li>\n <li class=\"i2\">android.widget.EditText <span class=\"demo-q\">hint=\"Password\"</span></li>\n <li class=\"i2\">android.view.View <span class=\"demo-id\">desc=\"Login\"</span></li>\n </ul>\n </div>\n </div>\n <!-- Screen (demo login phone) -->\n <div class=\"pane demo-pane\" id=\"demo-screen\">\n <div class=\"pane-head\">\n <span class=\"pane-title\">Screen</span>\n <span class=\"grow\"></span>\n <span class=\"demo-meta\">1080 \u00D7 2340</span>\n </div>\n <div class=\"pane-body demo-screen-body\">\n <div class=\"demo-phone\">\n <div class=\"demo-statusbar\"><span>1:11</span><span>\u25BE \u25AE \u25B6</span></div>\n <div class=\"demo-app\">\n <div class=\"demo-app-logo\"><span class=\"demo-logo-glass\">\uD83C\uDF79</span><span class=\"demo-logo-name\">taqelah!</span></div>\n <div class=\"demo-app-title\">DemoApp</div>\n <div class=\"demo-app-sub\">Sign in to shop the latest styles</div>\n <div class=\"demo-field sel\"><span class=\"demo-field-ico\">\uD83D\uDC64</span><span class=\"demo-field-ph\">Username</span></div>\n <div class=\"demo-field\"><span class=\"demo-field-ico\">\uD83D\uDD12</span><span class=\"demo-field-ph\">Password</span><span class=\"demo-field-eye\">\uD83D\uDC41</span></div>\n <button class=\"demo-app-btn\" type=\"button\">Login</button>\n <div class=\"demo-creds\">\n <div class=\"demo-creds-title\">Demo Credentials</div>\n <div>Username: emma@demoapp.com</div>\n <div>Password: 10203040</div>\n </div>\n </div>\n </div>\n </div>\n </div>\n <!-- Inspector tabs -->\n <div class=\"pane demo-pane\" id=\"demo-tabs\">\n <div class=\"tabs\" role=\"tablist\">\n <div class=\"tab active\" data-demo-tab=\"rec\">Record</div>\n <div class=\"tab\" data-demo-tab=\"script\">Recorded script</div>\n <div class=\"tab\" data-demo-tab=\"loc\">Locators</div>\n <div class=\"tab\" data-demo-tab=\"attrs\">Attributes</div>\n </div>\n <div class=\"demo-tabwrap\">\n <div class=\"demo-tabpane\" id=\"demo-rec\">\n <div class=\"demo-rec-banner\"><span class=\"demo-led\"></span>Recording \u2014 selected: <b>Username field</b></div>\n <div class=\"demo-rec-grid\">\n <span class=\"demo-act primary\">\u25B6 Click</span>\n <span class=\"demo-act\">\u2328 Type</span>\n <span class=\"demo-act\">\u232B Clear</span>\n <span class=\"demo-act\">\u23F1 Long press</span>\n <span class=\"demo-act\">\u2195 Scroll to</span>\n <span class=\"demo-act\">\u2713 Assert visible</span>\n </div>\n <div class=\"demo-subtabs\">Actions \u00B7 Screen \u00B7 Assertions</div>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-script\">\n <div class=\"demo-seg sm\"><span class=\"on\">Taqwright</span><span>Python</span><span>Java</span>\n <span class=\"grow\"></span><span>\u2398 Copy</span><span>\u2193 Export</span></div>\n <pre class=\"demo-code\">await mobile.getByXpath(\"//*[@hint='Username']\").fill('emma@demoapp.com');\nawait mobile.getByXpath(\"//*[@hint='Password']\").fill('10203040');\nawait mobile.getByUiSelector('new UiSelector().description(\"Login\")').click();</pre>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-loc\">\n <div class=\"demo-loc-row\"><span class=\"demo-cat xpath\">xpath</span><code>//android.widget.EditText[@hint=\"Username\"]</code><span class=\"demo-pick\">recommended</span></div>\n <div class=\"demo-loc-row\"><span class=\"demo-cat uiautomator\">UIAutomator</span><code>new UiSelector().className(\"android.widget.EditText\").instance(0)</code></div>\n <div class=\"demo-loc-row\"><span class=\"demo-cat xpath\">xpath</span><code>(//android.widget.EditText)[1]</code></div>\n </div>\n <div class=\"demo-tabpane hidden\" id=\"demo-attrs\">\n <table class=\"demo-attrs\">\n <tr><td>class</td><td>android.widget.EditText</td></tr>\n <tr><td>hint</td><td>Username</td></tr>\n <tr><td>text</td><td></td></tr>\n <tr><td>content-desc</td><td></td></tr>\n <tr><td>resource-id</td><td></td></tr>\n <tr><td>bounds</td><td>[72,560][1008,696]</td></tr>\n </table>\n </div>\n </div>\n </div>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Guided tour (spotlight) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"tour-overlay\" aria-hidden=\"true\">\n <div id=\"tour-catcher\"></div>\n <div id=\"tour-spotlight\"></div>\n <div id=\"tour-pop\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"tour-title\">\n <button id=\"tour-skip\" type=\"button\" aria-label=\"Skip tour\" title=\"Skip\">\u00D7</button>\n <h3 id=\"tour-title\"></h3>\n <div class=\"tour-body\" id=\"tour-text\"></div>\n <div id=\"tour-foot\">\n <span id=\"tour-progress\"></span>\n <span class=\"grow\"></span>\n <button class=\"icon\" id=\"tour-back\" type=\"button\">\u2190 Back</button>\n <button class=\"primary\" id=\"tour-next\" type=\"button\">Next \u2192</button>\n </div>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Help reference panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 -->\n<div id=\"help-overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"help-title\">\n <div id=\"help-panel\">\n <div class=\"help-head\">\n <h2 id=\"help-title\">taqwright codegen \u2014 Help</h2>\n <span class=\"grow\"></span>\n <button class=\"primary\" id=\"help-tour-setup\" type=\"button\" title=\"Guided tour of the setup wizard\">\u25B6 Setup tour</button>\n <button class=\"icon\" id=\"help-tour-inspector\" type=\"button\" title=\"Preview the inspector / device-screen tour (no device needed)\">\u25B6 Inspector tour</button>\n <button id=\"help-close\" type=\"button\" aria-label=\"Close help\">\u00D7</button>\n </div>\n <p class=\"help-lead\">codegen lets you drive a real device, inspect its UI, record your actions\n as you go, and export a runnable test. Take the <b>Setup tour</b> or preview the\n <b>Inspector tour</b> (the device-screen view \u2014 works even before you connect), or read the\n topics below.</p>\n\n <details open>\n <summary>Quick start</summary>\n <div class=\"help-sec\">\n <ol>\n <li><b>Connect</b> a device (the 3-step setup wizard).</li>\n <li><b>Click an element</b> on the screen (or a node in the Hierarchy) to select it.</li>\n <li>Press <b>Start record</b>, then pick actions / assertions for the selected element.</li>\n <li>Open <b>Recorded script</b> and <b>Export</b> it into your project.</li>\n </ol>\n </div>\n </details>\n\n <details open>\n <summary>1 \u00B7 Connecting to a device</summary>\n <div class=\"help-sec\">\n Choose <b>Local</b> (an emulator / simulator or USB device on this machine) or <b>Cloud</b>\n (BrowserStack / LambdaTest) at the top, then walk the 3-step wizard:\n <ul>\n <li><b>Step 1 \u2014 Prerequisites:</b> the <b>Environment</b> card runs a health check\n (<code>adb</code>, JDK, Android SDK, Appium drivers \u2014 expand it for details); the\n <b>Appium server</b> card lets you <b>Start</b> / Restart / Recheck a local Appium.\n <b>Next</b> unlocks once Appium is green. Cloud mode shows a credentials card instead.</li>\n <li><b>Step 2 \u2014 Pick a device:</b> switch the <b>Android / iOS</b> tabs,\n <code>\u21BB Refresh</code> the list, and <b>Start</b> a shutdown emulator (or select a\n running one / a cloud device).</li>\n <li><b>Step 3 \u2014 App & capabilities:</b> point at the app under test with\n <b>Browse\u2026</b>, tweak or <b>+ Add</b> Appium capabilities, then <b>Connect \u2192</b>.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>2 \u00B7 The window layout</summary>\n <div class=\"help-sec\">\n Once connected you get three panes:\n <ul>\n <li><b>Hierarchy</b> (left) \u2014 the UI element tree.</li>\n <li><b>Screen</b> (center) \u2014 a live mirror of the device.</li>\n <li><b>Inspector</b> (right) \u2014 four tabs: <b>Record</b>, <b>Recorded script</b>,\n <b>Locators</b>, <b>Attributes</b>.</li>\n </ul>\n Selecting an element anywhere drives all of these at once.\n </div>\n </details>\n\n <details>\n <summary>3 \u00B7 Hierarchy \u2014 Tree & XML</summary>\n <div class=\"help-sec\">\n <ul>\n <li>Toggle <b>Tree</b> (collapsible element tree) or raw <b>XML</b> page source.</li>\n <li><b>Filter</b> with the search box \u2014 matches by tag, id, or text.</li>\n <li><b>Click a node</b> to select it: it highlights on the screen and populates the\n Locators / Attributes tabs.</li>\n <li>Use the tree to reach <b>small or overlapping</b> elements that are hard to click on\n the screen.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>4 \u00B7 Screen mirror & WebView</summary>\n <div class=\"help-sec\">\n <ul>\n <li><b>Click any element</b> on the live screen to <b>select</b> it (the blue box shows\n its bounds). The mirror is for selecting / inspecting \u2014 actions are recorded from the\n Record tab.</li>\n <li>When recording a <b>tap at coordinates</b> or a <b>drag target</b>, click the exact\n spot on the screen.</li>\n <li><b>WebView:</b> if the app has a WebView, the context dropdown above the screen lets\n you switch into it to inspect the web DOM.</li>\n </ul>\n </div>\n </details>\n\n <details>\n <summary>5 \u00B7 Recording \u2014 Actions</summary>\n <div class=\"help-sec\">\n Press <b>Start record</b>, select an element, then choose an action; each is appended to\n the script live.\n <ul>\n <li><b>Element:</b> Click, Double tap, Long press, Check / Uncheck, Focus / Blur, Type,\n Clear, Type slowly, Press (a key), Select (a dropdown value).</li>\n <li><b>Gestures:</b> Swipe \u2190 \u2192 \u2191 \u2193, Pinch in / out, Scroll to (scroll the element into\n view), Drag to target (drag the element onto a point you click).</li>\n </ul>\n The <b>Actions / Screen / Assertions</b> sub-tabs switch what the palette records.\n </div>\n </details>\n\n <details>\n <summary>6 \u00B7 Recording \u2014 Screen taps</summary>\n <div class=\"help-sec\">\n The <b>Screen</b> sub-tab records raw interactions <b>at coordinates</b> \u2014 no element\n selection needed. Useful for canvases, maps, games, or anything the hierarchy doesn't\n expose as a tappable element.\n </div>\n </details>\n\n <details>\n <summary>7 \u00B7 Recording \u2014 Assertions</summary>\n <div class=\"help-sec\">\n The <b>Assertions</b> sub-tab records checks that verify state on the selected element:\n <ul>\n <li><b>State:</b> Visible, Hidden, Enabled, Disabled, Checked, Unchecked, Editable,\n Readonly, Focused, Attached, Empty, In viewport.</li>\n <li><b>Text:</b> Equals (exact) or Contains.</li>\n <li><b>Value</b>, <b>Count</b> (how many match), and <b>Attribute</b> (assert a specific\n attribute value).</li>\n </ul>\n Assertions are how your exported test catches regressions.\n </div>\n </details>\n\n <details>\n <summary>8 \u00B7 Locators</summary>\n <div class=\"help-sec\">\n With an element selected, the <b>Locators</b> tab lists <b>ranked, uniqueness-verified</b>\n candidates per strategy:\n <ul>\n <li><b>id</b> and <b>accessibility id</b> (most stable).</li>\n <li><b>UIAutomator</b> (Android), <b>NSPredicate</b> / <b>Class Chain</b> (iOS).</li>\n <li><b>xpath</b> (fallback).</li>\n </ul>\n A <b>Recommended</b> pick is floated to the top. Click any candidate to copy it.\n </div>\n </details>\n\n <details>\n <summary>9 \u00B7 Attributes</summary>\n <div class=\"help-sec\">\n The <b>Attributes</b> tab shows the selected element's full attribute set (resource-id,\n text, content-desc / name, bounds, class, \u2026) plus its xpath \u2014 handy for crafting your own\n locators.\n </div>\n </details>\n\n <details>\n <summary>10 \u00B7 The recorded script & export</summary>\n <div class=\"help-sec\">\n The <b>Recorded script</b> tab renders your test in three languages:\n <ul>\n <li><b>Taqwright</b> \u2014 a complete, runnable test.</li>\n <li><b>Python</b> / <b>Java</b> \u2014 the steps only (paste into your own Appium test;\n driver / setup not included).</li>\n </ul>\n Use <code>\u2398 Copy</code>, <code>\u2193 Export</code> (saves into your project's tests folder), or\n <b>Clear</b> to start over.\n </div>\n </details>\n\n <details>\n <summary>Tips & shortcuts</summary>\n <div class=\"help-sec\">\n <ul>\n <li>Re-open this help any time with <b>? Help</b> in the header.</li>\n <li>During the tour: <b>\u2192 / \u2190</b> next / back, <b>Esc</b> to skip.</li>\n <li>The Screen pane's <b>\u24D8 How to use</b> explains on-screen interactions.</li>\n <li><b>Disconnect</b> ends the session and returns you to setup.</li>\n </ul>\n </div>\n </details>\n </div>\n</div>\n<script>\n(() => {\n 'use strict';\n const $ = (id) => document.getElementById(id);\n const status = $('status');\n const setStatus = (s, busy) => {\n status.textContent = s;\n status.classList.toggle('busy', !!busy);\n };\n\n /** Full-screen loader overlay. Use during multi-second blocking work like\n * opening a WebDriver session or downloading the first snapshot. */\n function showLoader(msg, sub, onCancel) {\n const el = $('loader');\n if (!el) return;\n $('loader-msg').textContent = msg || 'Loading\u2026';\n $('loader-sub').textContent = sub || '';\n // Optional Cancel button \u2014 shown only when the caller passes a handler\n // (e.g. a long cloud connect the user may want to abort).\n const cancel = $('loader-cancel');\n if (onCancel) {\n cancel.onclick = onCancel;\n cancel.classList.add('shown');\n } else {\n cancel.onclick = null;\n cancel.classList.remove('shown');\n }\n el.classList.add('shown');\n el.setAttribute('aria-hidden', 'false');\n }\n function hideLoader() {\n const el = $('loader');\n if (!el) return;\n const cancel = $('loader-cancel');\n cancel.onclick = null;\n cancel.classList.remove('shown');\n el.classList.remove('shown');\n el.setAttribute('aria-hidden', 'true');\n }\n\n /** Floating, layout-neutral notifications. Errors stick until dismissed; success/info auto-hide. */\n function showToast(message, type, options) {\n type = type || 'info';\n options = options || {};\n const cont = $('toasts');\n if (!cont) return () => {};\n const el = document.createElement('div');\n el.className = 'toast ' + type;\n const title = options.title || (type === 'error' ? 'Error' : type === 'success' ? 'Success' : 'Info');\n el.innerHTML =\n '<div class=\"body\">' +\n '<div class=\"title\"></div>' +\n '<div class=\"msg\"></div>' +\n '</div>' +\n '<button class=\"close\" type=\"button\" aria-label=\"dismiss\">\u00D7</button>';\n el.querySelector('.title').textContent = title;\n el.querySelector('.msg').textContent = message;\n const dismiss = () => {\n if (!el.parentNode) return;\n el.classList.add('fading');\n setTimeout(() => el.remove(), 200);\n };\n el.querySelector('.close').onclick = dismiss;\n cont.appendChild(el);\n const ttl = options.ttl != null ? options.ttl : (type === 'error' ? 0 : 3500);\n if (ttl > 0) setTimeout(dismiss, ttl);\n return dismiss;\n }\n\n /** Remove every toast on screen. Useful before retrying an action. */\n function clearToasts() {\n const cont = $('toasts');\n if (cont) cont.replaceChildren();\n }\n\n const state = {\n platform: 'android',\n project: '',\n viewport: { w: 0, h: 0 },\n sourceXml: '',\n xmlDoc: null,\n selected: null,\n nodeMap: new Map(),\n nextId: 0,\n suggestSeq: 0,\n context: 'NATIVE_APP',\n };\n\n function isWebContext() {\n return !!state.context && state.context !== 'NATIVE_APP';\n }\n\n // \u2500\u2500\u2500 Snapshot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** Identifying fingerprint for an element across snapshots. */\n function elementSignature(el) {\n if (!el) return '';\n return [\n el.tagName,\n el.getAttribute('class') || '',\n el.getAttribute('resource-id') || '',\n el.getAttribute('content-desc') || '',\n el.getAttribute('name') || '',\n el.getAttribute('text') || el.getAttribute('label') || '',\n el.getAttribute('hint') || el.getAttribute('placeholderValue') || '',\n ].join('|');\n }\n\n /**\n * Rebind state.selected to the new tree node without re-fetching locator\n * suggestions or flipping the Record-tab card to \"resolving\". Used when a\n * snapshot refresh produces an element with the SAME xpath + signature as\n * the prior selection \u2014 semantically the same element, no need to redo\n * the work.\n */\n function quietlyRebindSelection(el) {\n state.selected = el;\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n if (el.__nodeId) {\n const li = document.querySelector('li.node[data-id=\"' + el.__nodeId + '\"]');\n if (li) li.classList.add('selected');\n }\n drawHighlight(el);\n }\n\n /** Drop the current selection (used when the new snapshot doesn't contain it). */\n function clearSelection() {\n state.selected = null;\n clearLocatorState();\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n $('highlight').style.display = 'none';\n $('tab-attrs').innerHTML = '<div class=\"empty-state\">Select an element.</div>';\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">Select an element to see unique locator strategies.</div>';\n }\n\n // \u2500\u2500\u2500 Auto-refresh \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Polls /api/snapshot so the inspector mirrors the device live. Always on\n // while connected (no toggle); auto-paused during snapshots, anchor-picks,\n // and locator resolves.\n const AUTO_REFRESH_MS = 1500;\n const WEB_REFRESH_MS = 4000; // WebView snapshots are heavier \u2014 larger floor\n let autoRefreshOn = true;\n let autoRefreshTimer = null;\n let snapshotInFlight = false;\n\n function scheduleNextRefresh(delay) {\n if (autoRefreshTimer) clearTimeout(autoRefreshTimer);\n autoRefreshTimer = setTimeout(autoRefreshTick, delay);\n }\n function startAutoRefresh() {\n if (autoRefreshTimer) return;\n autoRefreshOn = true;\n scheduleNextRefresh(0);\n refreshContexts(); // populate Native + any WebView contexts on connect\n }\n function stopAutoRefresh() {\n autoRefreshOn = false;\n if (autoRefreshTimer) clearTimeout(autoRefreshTimer);\n autoRefreshTimer = null;\n // Reset the context selector back to its hidden default on disconnect.\n state.context = 'NATIVE_APP';\n const sel = document.getElementById('context-select');\n if (sel) {\n sel.classList.add('hidden');\n sel.classList.remove('web');\n }\n }\n async function autoRefreshTick() {\n autoRefreshTimer = null;\n if (!autoRefreshOn) return;\n // Busy (snapshot/verify/anchor-pick in progress) \u2014 re-check soon.\n if (snapshotInFlight || anchorPickHandler !== null || locatorState === 'resolving') {\n scheduleNextRefresh(AUTO_REFRESH_MS);\n return;\n }\n const started = performance.now();\n await fetchSnapshot();\n const elapsed = performance.now() - started;\n // Gap at least as long as the snapshot took (with a webview floor) so we\n // never pile onto a slow device.\n const base = isWebContext() ? WEB_REFRESH_MS : AUTO_REFRESH_MS;\n if (autoRefreshOn) scheduleNextRefresh(Math.max(base, elapsed));\n }\n\n // Toggle the \"device screen unavailable\" fallback (shown when a snapshot\n // fails or returns no screenshot, so we never render a broken <img>).\n function setScreenUnavailable(on) {\n $('screen-host').classList.toggle('screen-unavailable', !!on);\n }\n\n async function fetchSnapshot(opts) {\n const force = opts && opts.force;\n if (snapshotInFlight) {\n if (!force) return; // Non-forced refresh: skip if a snapshot is already running\n // Forced (context switch): wait for the in-flight snapshot to finish,\n // then run ours so the tree re-renders for the new context.\n while (snapshotInFlight) await new Promise((r) => setTimeout(r, 50));\n }\n snapshotInFlight = true;\n setStatus('snapshot\u2026', true);\n try {\n const r = await fetch('/api/snapshot');\n if (!r.ok) throw new Error('HTTP ' + r.status);\n const j = await r.json();\n // Capture the selected element's xpath + identity fingerprint AFTER the\n // fetch resolves, so a selection the user made while the snapshot was in\n // flight (e.g. tapping a new element right after an action) is the one we\n // re-bind across the new tree \u2014 not the stale selection from when the\n // snapshot started. Reading these off the now-detached node is safe;\n // renderTree builds fresh nodes. The xpath+signature match below still\n // drops an unrelated element sitting at the same xpath after a navigation.\n const prevXpath = state.selected?.__xpath;\n const prevSig = elementSignature(state.selected);\n state.platform = j.platform;\n state.project = j.project;\n state.viewport = j.viewport;\n state.sourceXml = j.source;\n $('session-meta').textContent = formatSessionMeta(j.platform, j.project);\n $('screen-meta').textContent = j.viewport.w + ' \u00D7 ' + j.viewport.h;\n // Only set the image when there's an actual screenshot \u2014 an empty/missing\n // one would render as a broken <img>; show the fallback instead.\n if (typeof j.screenshot === 'string' && j.screenshot.length > 0) {\n $('screen-img').src = 'data:image/png;base64,' + j.screenshot;\n setScreenUnavailable(false);\n } else {\n setScreenUnavailable(true);\n }\n renderTree();\n if (hierarchyMode === 'xml') refreshHierarchyXml();\n if (prevXpath && prevSig) {\n let match = null;\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === prevXpath && elementSignature(el) === prevSig) {\n match = el; break;\n }\n }\n if (match) {\n // Same xpath + identifying signature \u2192 it's semantically the same\n // element. Just rebind state.selected to the new DOM node and\n // refresh the highlight; skip the locator re-fetch and the Record-\n // tab \"resolving\u2026\" flash. This is what makes auto-refresh stop\n // blinking.\n quietlyRebindSelection(match);\n } else {\n clearSelection();\n }\n }\n setStatus('idle');\n } catch (err) {\n setStatus('error: ' + err.message);\n setScreenUnavailable(true);\n } finally {\n snapshotInFlight = false;\n }\n }\n\n // \u2500\u2500\u2500 Tree rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function renderTree() {\n state.nodeMap.clear();\n state.nextId = 0;\n const parser = new DOMParser();\n // In a WebView the page source is an HTML DOM (often not well-formed XML),\n // so parse it as HTML. Native sources stay XML.\n const doc = parser.parseFromString(state.sourceXml, isWebContext() ? 'text/html' : 'text/xml');\n state.xmlDoc = doc;\n const root = doc.documentElement;\n if (!root) {\n $('tree').innerHTML = '<li class=\"empty-state\">No source.</li>';\n return;\n }\n annotateXpaths(root, '/' + root.tagName);\n $('tree').innerHTML = renderNode(root, true);\n bindTreeClicks();\n applyTreeFilter($('tree-search').value);\n }\n\n function annotateXpaths(el, xp) {\n el.__xpath = xp;\n const children = Array.from(el.children);\n const counts = {};\n for (const c of children) counts[c.tagName] = (counts[c.tagName] ?? 0) + 1;\n const seen = {};\n for (const c of children) {\n seen[c.tagName] = (seen[c.tagName] ?? 0) + 1;\n const idx = counts[c.tagName] > 1 ? '[' + seen[c.tagName] + ']' : '';\n annotateXpaths(c, xp + '/' + c.tagName + idx);\n }\n }\n\n function renderNode(el, isRoot) {\n const id = ++state.nextId;\n state.nodeMap.set(id, el);\n el.__nodeId = id;\n const tag = shortTag(el.tagName);\n const ident = pickIdent(el);\n const textHint = pickTextHint(el, ident);\n const children = Array.from(el.children);\n const twisty = children.length\n ? '<span class=\"twisty\">\u25BE</span>'\n : '<span class=\"twisty empty\">\u00B7</span>';\n const identHtml = ident\n ? ' <span class=\"ident\">' + escapeHtml(truncate(ident, 50)) + '</span>'\n : '';\n const textHtml = textHint\n ? ' <span class=\"text-snippet\">\"' + escapeHtml(truncate(textHint, 50)) + '\"</span>'\n : '';\n let html = '<li class=\"node\" data-id=\"' + id + '\">';\n html += '<span class=\"label\">' + twisty;\n html += '<span class=\"tag\">' + escapeHtml(tag) + '</span>' + identHtml + textHtml;\n html += '</span>';\n if (children.length) {\n html += '<ul' + (isRoot ? '' : '') + '>' + children.map((c) => renderNode(c, false)).join('') + '</ul>';\n }\n html += '</li>';\n return html;\n }\n\n /** Trim \"android.widget.\" or \"XCUIElementType\" prefix for compactness. */\n function shortTag(tag) {\n if (tag.startsWith('XCUIElementType')) return tag.slice('XCUIElementType'.length);\n if (tag.startsWith('android.widget.')) return tag.slice('android.widget.'.length);\n if (tag.startsWith('android.view.')) return tag.slice('android.view.'.length);\n return tag;\n }\n\n function pickIdent(el) {\n const rid = el.getAttribute('resource-id');\n if (rid) {\n return rid.includes(':id/') ? rid.split(':id/')[1] : rid;\n }\n return el.getAttribute('content-desc')\n || el.getAttribute('name')\n || '';\n }\n\n function pickTextHint(el, ident) {\n const t = el.getAttribute('text') || el.getAttribute('label') || el.getAttribute('value') || '';\n if (!t || t === ident) return '';\n return t;\n }\n\n function bindTreeClicks() {\n $('tree').onclick = (ev) => {\n const li = ev.target.closest('li.node');\n if (!li) return;\n const id = Number(li.dataset.id);\n const el = state.nodeMap.get(id);\n if (el) selectElement(el);\n };\n }\n\n // \u2500\u2500\u2500 Tree filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n $('tree-search').addEventListener('input', (ev) => {\n if (hierarchyMode === 'xml') applyXmlFilter(ev.target.value);\n else applyTreeFilter(ev.target.value);\n });\n function applyTreeFilter(q) {\n q = (q || '').trim().toLowerCase();\n const items = $('tree').querySelectorAll('li.node');\n if (!q) {\n items.forEach((li) => { li.style.display = ''; li.classList.remove('match'); });\n return;\n }\n items.forEach((li) => {\n const id = Number(li.dataset.id);\n const el = state.nodeMap.get(id);\n if (!el) return;\n const hay = (el.tagName + ' ' +\n (el.getAttribute('resource-id') || '') + ' ' +\n (el.getAttribute('content-desc') || '') + ' ' +\n (el.getAttribute('name') || '') + ' ' +\n (el.getAttribute('label') || '') + ' ' +\n (el.getAttribute('text') || '')).toLowerCase();\n const hit = hay.includes(q);\n li.classList.toggle('match', hit);\n });\n }\n\n // \u2500\u2500\u2500 Selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function selectElement(el) {\n // Anchor-pick mode (relative-xpath builder): consume this click as the\n // anchor and don't actually re-select. The pick handler does its own UI.\n if (anchorPickHandler && state.selected && el !== state.selected) {\n const handler = anchorPickHandler;\n endAnchorPick();\n handler(el);\n return;\n }\n // Note: we deliberately keep stickyRelative alive across selections.\n // fetchAndRenderLocators only re-injects the relative card when the\n // newly-selected element signature matches stickyRelative.elementSig\n // \u2014 so navigating away hides it and navigating back surfaces it.\n // The Dismiss button is the only thing that wipes it permanently.\n state.selected = el;\n // Invalidate stale Record-tab locator. fetchAndRenderLocators flips this\n // to 'resolving' immediately so the user sees the in-flight state.\n markLocatorResolving();\n document.querySelectorAll('li.node.selected').forEach((n) => n.classList.remove('selected'));\n if (el.__nodeId) {\n const li = document.querySelector('li.node[data-id=\"' + el.__nodeId + '\"]');\n if (li) {\n li.classList.add('selected');\n li.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n }\n }\n drawHighlight(el);\n renderAttrs(el);\n fetchAndRenderLocators(el);\n }\n\n function selectByXpath(xp) {\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === xp) { selectElement(el); return; }\n }\n }\n\n function getBounds(el) {\n if (state.platform === 'android') {\n const b = el.getAttribute('bounds');\n if (!b) return null;\n const m = b.match(/\\[(-?\\d+),(-?\\d+)\\]\\[(-?\\d+),(-?\\d+)\\]/);\n if (!m) return null;\n const x1 = +m[1], y1 = +m[2], x2 = +m[3], y2 = +m[4];\n return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };\n }\n const x = +(el.getAttribute('x') ?? 0);\n const y = +(el.getAttribute('y') ?? 0);\n const w = +(el.getAttribute('width') ?? 0);\n const h = +(el.getAttribute('height') ?? 0);\n return { x, y, w, h };\n }\n\n function drawHighlight(el) {\n const b = getBounds(el);\n if (!b || b.w <= 0 || b.h <= 0) {\n $('highlight').style.display = 'none';\n return;\n }\n const img = $('screen-img');\n // Same isotropic scale as imgToDevice (inverse direction) so the highlight\n // tracks the screenshot, not a per-axis-distorted bounds projection.\n const scale = Math.min(img.clientWidth / state.viewport.w, img.clientHeight / state.viewport.h);\n const hl = $('highlight');\n hl.style.left = (b.x * scale) + 'px';\n hl.style.top = (b.y * scale) + 'px';\n hl.style.width = (b.w * scale) + 'px';\n hl.style.height = (b.h * scale) + 'px';\n hl.style.display = 'block';\n }\n\n // \u2500\u2500\u2500 Attributes panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function renderAttrs(el) {\n const rows = [];\n for (const a of Array.from(el.attributes)) {\n rows.push(\n '<tr><td>' + escapeHtml(a.name) + '</td><td>' +\n escapeHtml(truncate(a.value, 200)) + '</td></tr>',\n );\n }\n rows.push('<tr><td>xpath</td><td>' + escapeHtml(el.__xpath ?? '') + '</td></tr>');\n $('tab-attrs').innerHTML = '<table class=\"attrs\"><tbody>' + rows.join('') + '</tbody></table>';\n }\n\n // \u2500\u2500\u2500 Relative-xpath builder (anchor pick + path computation) \u2500\u2500\u2500\n /** When set, the next selectElement(...) becomes the anchor, not the new selection. */\n let anchorPickHandler = null;\n /**\n * Sticky relative xpath bound to the current selection. Survives snapshot\n * refreshes (re-injected after fetchAndRenderLocators) and is dismissed\n * either explicitly via the card's Dismiss button or implicitly when the\n * user selects a different element. Identified by element XPATH (not just\n * signature) \u2014 featureless Views all share an empty signature so xpath\n * is what actually distinguishes them.\n */\n let stickyRelative = null; // { elementXpath, elementSig, xpath, code, anchorLabel }\n\n function isStickyMatch(el) {\n if (!stickyRelative || !el) return false;\n return el.__xpath === stickyRelative.elementXpath\n && elementSignature(el) === stickyRelative.elementSig;\n }\n\n function startRelativeAnchorPick() {\n if (!state.selected) return;\n // Snapshot the target by xpath + signature so we can re-resolve it from\n // whichever tree is current when the anchor finally gets clicked. Holding\n // a direct reference would point at a stale XMLDocument if any refresh\n // (auto-refresh polling or post-action) parses a new doc in between \u2014\n // anchor and target would then live in different docs and share no\n // common ancestor.\n const targetXpath = state.selected.__xpath;\n const targetSig = elementSignature(state.selected);\n anchorPickHandler = (anchor) => {\n let target = null;\n for (const [, el] of state.nodeMap) {\n if (el.__xpath === targetXpath && elementSignature(el) === targetSig) {\n target = el; break;\n }\n }\n if (!target) {\n showToast(\n 'The target element is no longer in the current page source. ' +\n 'Refresh and re-select it before building the relative xpath.',\n 'error',\n { title: 'Target lost' },\n );\n return;\n }\n buildRelativeLocator(target, anchor);\n };\n $('rec-pickhint-label').textContent =\n 'Pick the anchor element (must have a unique attribute like text, id, or content-desc).';\n $('rec-pickhint').style.display = 'flex';\n $('screen-host').classList.add('pick-mode');\n }\n function endAnchorPick() {\n anchorPickHandler = null;\n $('rec-pickhint').style.display = 'none';\n $('screen-host').classList.remove('pick-mode');\n }\n\n /** Walk anchor \u2192 root and target \u2192 root, find common ancestor, build a relative xpath. */\n function buildRelativePath(anchor, target) {\n function chain(el) {\n const out = [];\n for (let n = el; n; n = n.parentElement) out.unshift(n);\n return out;\n }\n const aChain = chain(anchor);\n const tChain = chain(target);\n let i = 0;\n while (i < aChain.length && i < tChain.length && aChain[i] === tChain[i]) i++;\n if (i === 0) return null;\n const stepsUp = aChain.length - i;\n let down = '';\n for (let j = i; j < tChain.length; j++) {\n const node = tChain[j];\n const parent = tChain[j - 1];\n const sibs = Array.from(parent.children).filter((c) => c.tagName === node.tagName);\n const idx = sibs.indexOf(node) + 1;\n down += '/' + node.tagName + (sibs.length > 1 ? '[' + idx + ']' : '');\n }\n let path = '';\n for (let k = 0; k < stepsUp; k++) path += '/..';\n return path + down;\n }\n\n async function buildRelativeLocator(target, anchor) {\n if (anchor === target) {\n showToast('Pick a different element as the anchor.', 'error', { title: 'Same element' });\n return;\n }\n setStatus('building relative xpath\u2026', true);\n try {\n const anchorAttrs = {};\n for (const a of Array.from(anchor.attributes)) anchorAttrs[a.name] = a.value;\n // Ask the server for the anchor's locator candidates and find a unique xpath.\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs: anchorAttrs, xpath: anchor.__xpath ?? '' }),\n });\n const { all } = await r.json();\n const xpathCandidates = (all || [])\n .filter((s) => s.using === 'xpath' && s.unique)\n .sort((a, b) => b.priority - a.priority);\n if (xpathCandidates.length === 0) {\n showToast(\n 'The chosen anchor has no unique xpath of its own. Try an element with text, id, or content-desc.',\n 'error',\n { title: 'Anchor not unique' },\n );\n return;\n }\n const anchorXpath = xpathCandidates[0].value;\n const relPath = buildRelativePath(anchor, target);\n if (relPath === null) {\n showToast('Anchor and target are not in the same tree.', 'error',\n { title: 'No relative path' });\n return;\n }\n const combined = anchorXpath + relPath;\n // Verify uniqueness on the live device.\n const vr = await fetch('/api/verify-xpath', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ xpath: combined }),\n });\n const verify = await vr.json();\n if (!verify.unique) {\n showToast(\n 'Relative xpath matches ' + verify.count + ' elements \u2014 not unique. ' +\n 'Try a closer anchor.', 'error', { title: 'Not unique' });\n return;\n }\n const code = 'mobile.getByXpath(' + JSON.stringify(combined) + ')';\n const anchorLabel = shortTag(anchor.tagName) +\n (pickIdent(anchor) ? ' \u00B7 ' + pickIdent(anchor) : '');\n // Persist for the current selection so post-action snapshot refreshes\n // can re-inject the card and re-promote the locator.\n stickyRelative = {\n elementXpath: state.selected.__xpath,\n elementSig: elementSignature(state.selected),\n xpath: combined,\n code,\n anchorLabel,\n };\n injectRelativeCard(combined, code, anchorLabel);\n promoteRelativeLocator(combined, code);\n setStatus('relative xpath built \u2713');\n } catch (err) {\n showToast(err.message, 'error', { title: 'Failed to build relative xpath' });\n }\n }\n\n /** Promote the relative xpath as the active Record-tab locator. */\n function promoteRelativeLocator(xpath, code) {\n setBestLocator({\n category: 'xpath',\n subLabel: 'relative',\n priority: 9999,\n code,\n using: 'xpath',\n value: xpath,\n unique: true,\n count: 1,\n });\n }\n\n /** Insert (or replace) the relative-xpath card at the top of the Locators tab. */\n function injectRelativeCard(xpath, code, anchorLabel) {\n const wrapper = document.createElement('div');\n wrapper.className = 'rel-card';\n wrapper.innerHTML =\n '<div class=\"anchor-line\">\u2693 anchored to <strong>' + escapeHtml(anchorLabel) + '</strong></div>' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge xpath\">XPath</span>' +\n '<span class=\"cat-sub\">relative path</span>' +\n '<span class=\"loc-spacer\"></span>' +\n '<span class=\"badge unique\">unique</span>' +\n '</div>' +\n '<div class=\"loc-code\"></div>' +\n '<div class=\"loc-actions\">' +\n '<button class=\"icon\" data-act=\"dismiss\">Dismiss</button>' +\n '</div>' +\n '<div class=\"rel-tip\">' +\n '<span class=\"ico\">\u26A0</span>' +\n '<div>' +\n '<strong>Heads-up:</strong> relative xpaths are fragile \u2014 they break ' +\n 'when the surrounding layout changes. Ask your mobile engineer to ' +\n 'add a stable identifier to this element ' +\n '(<code>testID</code> on React Native, <code>android:id</code> / ' +\n '<code>contentDescription</code> on Android, ' +\n '<code>accessibilityIdentifier</code> on iOS). Then you can switch ' +\n 'to <code>mobile.getById(...)</code> and the locator stays robust ' +\n 'across UI changes.' +\n '</div>' +\n '</div>';\n wrapper.querySelector('.loc-code').textContent = code;\n\n const tab = $('tab-locators');\n const existing = tab.querySelector(':scope > .rel-card');\n if (existing) existing.remove();\n tab.insertBefore(wrapper, tab.firstChild);\n\n wrapper.querySelector('[data-act=\"dismiss\"]').onclick = () => {\n wrapper.remove();\n stickyRelative = null;\n setBestLocator(null);\n };\n }\n\n // \u2500\u2500\u2500 Locators panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function fetchAndRenderLocators(el) {\n const seq = ++state.suggestSeq;\n setStatus('verifying locators\u2026', true);\n markLocatorResolving();\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\"><span class=\"rec-sel-spinner\"></span>Verifying locator uniqueness\u2026</div>';\n const attrs = {};\n for (const a of Array.from(el.attributes)) attrs[a.name] = a.value;\n attrs['__tag'] = (el.tagName || '').toLowerCase();\n try {\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs, xpath: el.__xpath ?? '' }),\n });\n if (seq !== state.suggestSeq) return; // newer request landed\n const { best, recommended } = await r.json();\n renderLocatorCards(best, recommended);\n // Tell the Record tab which locator to use for element-targeted actions.\n // Prefer the cross-category robust pick over the first per-category unique.\n setBestLocator(recommended || best.find((s) => s.unique) || null);\n // If the user previously built a sticky relative xpath for THIS exact\n // element (xpath + signature match), re-inject the card and re-promote\n // it as the active locator. xpath is required because featureless\n // Views all share an empty signature.\n if (isStickyMatch(state.selected)) {\n injectRelativeCard(stickyRelative.xpath, stickyRelative.code, stickyRelative.anchorLabel);\n promoteRelativeLocator(stickyRelative.xpath, stickyRelative.code);\n }\n setStatus('idle');\n } catch (err) {\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">Suggest error: ' + escapeHtml(err.message) + '</div>';\n setBestLocator(null);\n setStatus('error');\n }\n }\n\n function isTextInput(el) {\n if (!el) return false;\n if (state.platform === 'android') {\n const cls = el.getAttribute('class') || '';\n if (cls === 'android.widget.EditText') return true;\n if (cls.endsWith('.EditText')) return true;\n if (cls === 'android.widget.AutoCompleteTextView') return true;\n if ((el.getAttribute('text-entry-key') || '') === 'true') return true;\n if ((el.getAttribute('password') || '') === 'true') return true;\n return false;\n }\n const type = el.getAttribute('type') || '';\n return type === 'XCUIElementTypeTextField'\n || type === 'XCUIElementTypeSecureTextField'\n || type === 'XCUIElementTypeSearchField'\n || type === 'XCUIElementTypeTextView';\n }\n\n function renderLocatorCards(list, recommended) {\n if (!Array.isArray(list) || list.length === 0) {\n $('tab-locators').innerHTML =\n '<div class=\"empty-state\">No locator strategies found for this element.</div>';\n return;\n }\n // Recommended pick is cross-category \u2014 it may not even be in the\n // per-category best list. Surface it, then float it to the top so it\n // reads as the answer regardless of the id/uiautomator/xpath order.\n const cards_src = list.slice();\n const recCode = recommended ? recommended.code : null;\n if (recCode && !cards_src.some((s) => s.code === recCode)) {\n cards_src.unshift(recommended);\n }\n if (recCode) {\n const ri = cards_src.findIndex((s) => s.code === recCode);\n if (ri > 0) {\n const rec = cards_src.splice(ri, 1)[0];\n cards_src.unshift(rec);\n }\n }\n const showType = isTextInput(state.selected);\n const typeTarget =\n (recommended && recommended.unique && recommended) ||\n cards_src.find((s) => s.unique) ||\n null;\n const typeHtml = (showType && typeTarget)\n ? '<div class=\"type-card\">' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge id\">Type</span>' +\n '<span class=\"cat-sub\">into this field via ' +\n escapeHtml(labelForCategory(typeTarget.category)) + '</span>' +\n '</div>' +\n '<div class=\"type-row\">' +\n '<input class=\"type-input\" id=\"type-input\" placeholder=\"text to type\u2026\" />' +\n '<button class=\"icon\" id=\"btn-type-send\">Send</button>' +\n '</div>' +\n '<div class=\"type-hint\">\u21B5 Enter to send \u00B7 clears the field first, like ' +\n '<code>.fill()</code></div>' +\n '</div>'\n : (showType\n ? '<div class=\"type-card\"><div class=\"cat-sub\">' +\n 'Text input detected, but no unique locator yet \u2014 pick one below.' +\n '</div></div>'\n : '');\n const cards = cards_src.map((s, i) => {\n // Positional = synthesized .nth(i). Unique right now but index-fragile;\n // badge it distinctly so it doesn't read as confidently as a stable\n // attribute locator. (descriptor.kind === 'nth' is the only producer.)\n const positional = !!(s.descriptor && s.descriptor.kind === 'nth');\n const isRec = !!(recCode && s.code === recCode);\n const badgeHtml = !s.unique\n ? (s.count > 1\n ? '<span class=\"badge collision\">' + s.count + ' matches</span>'\n : '<span class=\"badge empty\">no match</span>')\n : positional\n ? '<span class=\"badge positional\">positional \u00B7 fragile</span>'\n : '<span class=\"badge unique\">unique</span>';\n const recHtml = isRec\n ? '<span class=\"badge recommended\">\u2605 Recommended</span>'\n : '';\n const catLabel = labelForCategory(s.category);\n return (\n '<div class=\"loc-card' + (isRec ? ' is-rec' : '') + '\" data-i=\"' + i + '\">' +\n '<div class=\"loc-head\">' +\n '<span class=\"cat-badge ' + s.category + '\">' + escapeHtml(catLabel) + '</span>' +\n '<span class=\"cat-sub\">' + escapeHtml(s.subLabel) + '</span>' +\n '<span class=\"loc-spacer\"></span>' +\n recHtml + badgeHtml +\n '</div>' +\n '<div class=\"loc-code\">' + escapeHtml(s.code) + '</div>' +\n '</div>'\n );\n }).join('');\n // Affordance for building a relative xpath when the existing locators\n // aren't a fit (no unique, or user wants a more semantic anchor).\n const buildRelHtml =\n '<button class=\"build-rel-btn\" id=\"btn-build-rel\" type=\"button\">' +\n '<span class=\"ico\">\u2693</span>' +\n '<span class=\"body\">' +\n '<span class=\"title\">Build a relative xpath</span>' +\n '<span class=\"sub\">Pick another element as an anchor \u2014 taqwright will compute a path-walking xpath rooted at it.</span>' +\n '</span>' +\n '</button>';\n\n $('tab-locators').innerHTML = typeHtml + cards + buildRelHtml;\n $('btn-build-rel').onclick = startRelativeAnchorPick;\n\n if (showType && typeTarget) {\n const sendType = async () => {\n const inp = $('type-input');\n const text = inp.value;\n if (!text) { inp.focus(); return; }\n setStatus('typing\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n kind: 'fill',\n using: typeTarget.using,\n value: typeTarget.value,\n descriptor: typeTarget.descriptor,\n code: typeTarget.code,\n text,\n }),\n });\n if (!r.ok) {\n const j = await r.json().catch(() => ({}));\n throw new Error(j.error || ('HTTP ' + r.status));\n }\n inp.value = '';\n await refreshScript();\n setTimeout(fetchSnapshot, 300);\n setStatus('typed');\n } catch (err) {\n setStatus('type error: ' + err.message);\n }\n };\n $('btn-type-send').onclick = sendType;\n $('type-input').addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') { ev.preventDefault(); sendType(); }\n });\n }\n\n }\n\n function labelForCategory(c) {\n return ({\n id: 'ID',\n uiautomator: 'UIAutomator',\n predicate: 'NSPredicate',\n classChain: 'Class Chain',\n xpath: 'XPath',\n })[c] || c;\n }\n\n // \u2500\u2500\u2500 Pointer events on the screen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function imgToDevice(ev) {\n const img = $('screen-img');\n const rect = img.getBoundingClientRect();\n // One isotropic scale from the inset-free axis. The screenshot can be\n // taller/wider than the logical bounds space when it includes a system-bar\n // inset (e.g. BrowserStack Android nav bar); scaling each axis on its own\n // then distorts the off-inset axis and shifts hit-testing to a neighbour.\n // max() picks the axis with no inset (its bounds dimension isn't shrunk).\n const scale = Math.max(state.viewport.w / rect.width, state.viewport.h / rect.height);\n return {\n x: Math.round((ev.clientX - rect.left) * scale),\n y: Math.round((ev.clientY - rect.top) * scale),\n };\n }\n\n // A corrupt/truncated data URI fails to decode \u2014 fall back rather than show\n // the browser's broken-image glyph.\n $('screen-img').addEventListener('error', () => setScreenUnavailable(true));\n\n $('screen-img').addEventListener('mouseup', (ev) => {\n const pt = imgToDevice(ev);\n // Pick mode (Record tab) takes priority \u2014 consume one click then dismiss.\n if (pickHandler) {\n const handler = pickHandler;\n cancelPickMode();\n handler(pt);\n return;\n }\n // Default: clicking the screen selects the element under the cursor.\n const hit = findHit(pt.x, pt.y);\n if (hit) selectElement(hit);\n });\n\n /** Does this element have any attribute that the locator suggester can use? */\n function hasUsefulAttrs(el) {\n return !!(\n el.getAttribute('resource-id') ||\n el.getAttribute('content-desc') ||\n el.getAttribute('text') ||\n el.getAttribute('hint') ||\n el.getAttribute('name') ||\n el.getAttribute('label') ||\n el.getAttribute('value') ||\n el.getAttribute('placeholderValue')\n );\n }\n\n /** BFS the subtree under root to find the closest descendant with a useful attribute. */\n function findUsefulDescendant(root) {\n const queue = Array.from(root.children);\n while (queue.length > 0) {\n const el = queue.shift();\n if (hasUsefulAttrs(el)) return el;\n for (const c of Array.from(el.children)) queue.push(c);\n }\n return null;\n }\n\n function findHit(x, y) {\n let smallest = null;\n let smallestArea = Infinity;\n for (const [, el] of state.nodeMap) {\n const b = getBounds(el);\n if (!b || b.w <= 0 || b.h <= 0) continue;\n if (x < b.x || y < b.y || x > b.x + b.w || y > b.y + b.h) continue;\n const area = b.w * b.h;\n if (area < smallestArea) { smallestArea = area; smallest = el; }\n }\n if (!smallest) return null;\n // If the innermost hit is a featureless wrapper (common in React Native /\n // Flutter / SwiftUI views), reach into its subtree for a child with\n // identifying attributes \u2014 otherwise the Record tab actions stay disabled\n // because no unique locator can be built.\n if (hasUsefulAttrs(smallest)) return smallest;\n return findUsefulDescendant(smallest) ?? smallest;\n }\n\n\n // Tabs.\n document.querySelectorAll('.tab').forEach((t) => {\n t.onclick = () => {\n document.querySelectorAll('.tab').forEach((x) => x.classList.remove('active'));\n t.classList.add('active');\n for (const k of ['record', 'script', 'locators', 'attrs']) {\n $('tab-' + k).classList.toggle('hidden', k !== t.dataset.tab);\n }\n if (t.dataset.tab === 'script') refreshScript();\n };\n });\n\n // Record subtabs (Actions / Screen / Assertions) \u2014 independent of the\n // top-level .tab bar; panes live inside #tab-record so all the existing\n // #tab-record handlers/selectors keep matching.\n document.querySelectorAll('.rec-subtab').forEach((t) => {\n t.onclick = () => {\n document.querySelectorAll('.rec-subtab').forEach((x) => x.classList.remove('active'));\n t.classList.add('active');\n for (const k of ['actions', 'screen', 'assert']) {\n $('rec-pane-' + k).classList.toggle('hidden', k !== t.dataset.subtab);\n }\n };\n });\n\n // \u2500\u2500\u2500 Hierarchy view-mode toggle (XML / Tree) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Tree is the default \u2014 the structured view is easier to scan; XML is opt-in.\n let hierarchyMode = 'tree';\n function setHierarchyMode(mode) {\n hierarchyMode = mode;\n document.querySelectorAll('.hier-mode-btn').forEach((b) => {\n b.classList.toggle('active', b.dataset.hierMode === mode);\n });\n const treeBody = document.getElementById('hier-tree-body');\n const xmlBody = document.getElementById('hier-xml-body');\n // The filter field stays visible in both modes; re-apply it for the mode\n // we're switching into so highlights stay correct.\n if (mode === 'xml') {\n treeBody.style.display = 'none';\n xmlBody.style.display = '';\n refreshHierarchyXml();\n } else {\n treeBody.style.display = '';\n xmlBody.style.display = 'none';\n applyTreeFilter($('tree-search').value);\n }\n }\n document.querySelectorAll('.hier-mode-btn').forEach((b) => {\n b.onclick = () => setHierarchyMode(b.dataset.hierMode);\n });\n\n // \u2500\u2500\u2500 Context (Native / WebView) selector \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function contextLabel(ctx) {\n if (ctx === 'NATIVE_APP') return 'Native';\n // WEBVIEW_com.example \u2192 'WebView (com.example)'\n const m = ctx.match(/^WEBVIEW_?(.*)$/i);\n return m && m[1] ? 'WebView (' + m[1] + ')' : 'WebView';\n }\n\n function applyContextUi() {\n const sel = document.getElementById('context-select');\n if (!sel) return;\n sel.classList.toggle('web', isWebContext());\n }\n\n async function refreshContexts() {\n const sel = document.getElementById('context-select');\n if (!sel) return;\n try {\n const r = await fetch('/api/contexts');\n if (!r.ok) return;\n const j = await r.json();\n const contexts = Array.isArray(j.contexts) && j.contexts.length\n ? j.contexts : ['NATIVE_APP'];\n state.context = j.current || state.context || 'NATIVE_APP';\n sel.innerHTML = '';\n for (const ctx of contexts) {\n const opt = document.createElement('option');\n opt.value = ctx;\n opt.textContent = contextLabel(ctx);\n if (ctx === state.context) opt.selected = true;\n sel.appendChild(opt);\n }\n sel.classList.remove('hidden');\n // Surface a hint when the device exposes no WebView \u2014 e.g. an Android\n // WebView that isn't debuggable, so it never appears as a context.\n const hasWeb = contexts.some(function (c) { return /^WEBVIEW/i.test(c); });\n const hint = document.getElementById('context-hint');\n if (hint) hint.classList.toggle('hidden', hasWeb);\n applyContextUi();\n } catch {\n // No session / driver error \u2014 leave the selector as-is.\n }\n }\n\n {\n const sel = document.getElementById('context-select');\n if (sel) {\n // Contexts appear only after the WebView finishes loading, so refresh\n // the list lazily when the user opens the dropdown rather than polling.\n sel.addEventListener('mousedown', () => { refreshContexts(); });\n sel.addEventListener('change', async () => {\n const target = sel.value;\n if (target === state.context) return;\n setStatus('switching context\u2026', true);\n try {\n const r = await fetch('/api/context', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ context: target }),\n });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n state.context = j.current || target;\n applyContextUi();\n // A successful switch means a WebView context exists \u2014 drop the hint.\n const hint = document.getElementById('context-hint');\n if (hint) hint.classList.add('hidden');\n clearSelection();\n state.sourceXml = '';\n await fetchSnapshot({ force: true });\n showToast('Now in ' + contextLabel(state.context), 'success',\n { title: 'Context switched' });\n } catch (err) {\n showToast(err.message, 'error', { title: 'Context switch failed' });\n // Revert the dropdown to the still-active context.\n refreshContexts();\n } finally {\n setStatus('idle');\n }\n });\n }\n const hint = document.getElementById('context-hint');\n if (hint) {\n const explain = function () {\n // Re-check in case the WebView just finished loading and now appears.\n refreshContexts();\n const android =\n 'No WebView context found. The app\\'s WebView must be debuggable \u2014 ' +\n 'call WebView.setWebContentsDebuggingEnabled(true) (automatic in ' +\n 'debuggable builds). To switch into it, Appium also needs ' +\n 'chromedriver: enable appium:chromedriverAutodownload or set ' +\n 'appium:chromedriverExecutable. Note: Chrome Custom Tabs / external ' +\n 'browsers won\\'t appear as a context.';\n const ios =\n 'No WebView context found. Ensure the WebView has loaded; on iOS, ' +\n 'Safari Web Inspector / WKWebView inspection must be enabled for the ' +\n 'app or device.';\n const msg = state.platform === 'ios' ? ios : android;\n showToast(msg, 'info', { title: 'WebView not detected', ttl: 0 });\n };\n hint.addEventListener('click', explain);\n hint.addEventListener('keydown', (e) => {\n if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); explain(); }\n });\n }\n }\n\n function refreshHierarchyXml() {\n applyXmlFilter($('tree-search').value);\n }\n // Highlight (not hide) substring matches in the XML view \u2014 parity with the\n // tree filter. Empty query renders the plain source.\n function applyXmlFilter(q) {\n const pre = document.getElementById('hier-xml-pre');\n if (!pre) return;\n const xml = state.sourceXml || '';\n q = (q || '').trim();\n if (!q) {\n pre.textContent = xml;\n return;\n }\n const lx = xml.toLowerCase();\n const lq = q.toLowerCase();\n let html = '';\n let idx = 0;\n let pos = lx.indexOf(lq);\n while (pos !== -1) {\n html +=\n escapeHtml(xml.slice(idx, pos)) +\n '<mark class=\"xml-match\">' +\n escapeHtml(xml.slice(pos, pos + q.length)) +\n '</mark>';\n idx = pos + q.length;\n pos = lx.indexOf(lq, idx);\n }\n html += escapeHtml(xml.slice(idx));\n pre.innerHTML = html;\n }\n\n // \u2500\u2500\u2500 Record tab wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /** When set, the next click on the screen completes a coordinate-targeted action. */\n let pickHandler = null;\n\n function startPickMode(label, onPick) {\n pickHandler = onPick;\n $('rec-pickhint-label').textContent = label;\n $('rec-pickhint').style.display = 'flex';\n $('screen-host').classList.add('pick-mode');\n }\n function cancelPickMode() {\n pickHandler = null;\n $('rec-pickhint').style.display = 'none';\n $('screen-host').classList.remove('pick-mode');\n }\n $('btn-rec-cancel').onclick = cancelPickMode;\n\n /** Best-unique locator for the currently selected element, if any. */\n let bestLocatorForSelected = null;\n /** 'idle' | 'resolving' | 'resolved' \u2014 what state the locator suggestion is in. */\n let locatorState = 'idle';\n\n function setBestLocator(s) {\n bestLocatorForSelected = s;\n locatorState = 'resolved';\n refreshRecordButtons();\n }\n function markLocatorResolving() {\n bestLocatorForSelected = null;\n locatorState = 'resolving';\n refreshRecordButtons();\n }\n function clearLocatorState() {\n bestLocatorForSelected = null;\n locatorState = 'idle';\n refreshRecordButtons();\n }\n\n /** Enable/disable element-action buttons based on whether we have a selection + unique locator. */\n function refreshRecordButtons() {\n const hasUnique = !!(bestLocatorForSelected && bestLocatorForSelected.unique);\n\n // Selected-element card.\n const card = $('rec-selected');\n const titleEl = $('rec-sel-title');\n const subEl = $('rec-sel-sub');\n const iconEl = $('rec-sel-icon');\n if (state.selected) {\n const tag = shortTag(state.selected.tagName);\n const ident = pickIdent(state.selected);\n titleEl.textContent = ident ? tag + ' \u00B7 ' + ident : tag;\n if (hasUnique) {\n iconEl.textContent = '\u2713';\n } else if (locatorState === 'resolving') {\n iconEl.innerHTML = '<span class=\"rec-sel-spinner\"></span>';\n } else {\n iconEl.textContent = '\u26A0';\n }\n if (hasUnique) {\n card.classList.add('has');\n subEl.textContent = bestLocatorForSelected.code;\n } else {\n card.classList.remove('has');\n if (locatorState === 'resolving') {\n subEl.innerHTML = 'Resolving locator\u2026' +\n (isCloudMode()\n ? '<span class=\"rec-resolving-hint\">Verifying candidates against the cloud device \u2014 this can take a few seconds.</span>'\n : '');\n } else {\n // No unique locator: render an inline Build-relative-xpath button\n // here so the user doesn't have to leave the Record tab.\n subEl.innerHTML =\n '<div class=\"rec-no-unique\">No unique locator for this element. Anchor it against a nearby element instead:</div>' +\n '<button class=\"build-rel-btn\" id=\"btn-build-rel-record\" type=\"button\">' +\n '<span class=\"ico\">\u2693</span>' +\n '<span class=\"body\">' +\n '<span class=\"title\">Build a relative xpath</span>' +\n '<span class=\"sub\">Pick another element as an anchor \u2014 taqwright will compute a path-walking xpath rooted at it.</span>' +\n '</span>' +\n '</button>';\n const btn = document.getElementById('btn-build-rel-record');\n if (btn) {\n btn.onclick = (e) => { e.stopPropagation(); startRelativeAnchorPick(); };\n }\n }\n }\n } else {\n card.classList.remove('has');\n iconEl.textContent = '\u25CB';\n titleEl.textContent = 'No element selected';\n subEl.textContent = 'Tap an element on the screen or in the Hierarchy.';\n }\n\n // Element action buttons.\n document.querySelectorAll('#tab-record .rec-act[data-act]').forEach((btn) => {\n btn.disabled = !hasUnique;\n });\n $('btn-rec-type').disabled = !hasUnique;\n $('btn-rec-clear').disabled = !hasUnique;\n $('rec-type-input').disabled = !hasUnique;\n $('btn-rec-seq').disabled = !hasUnique;\n $('rec-seq-input').disabled = !hasUnique;\n $('rec-seq-delay').disabled = !hasUnique;\n $('btn-rec-press').disabled = !hasUnique;\n $('rec-press-key').disabled = !hasUnique;\n $('btn-rec-select').disabled = !hasUnique;\n $('rec-select-label').disabled = !hasUnique;\n document.querySelectorAll('#tab-record .rec-act[data-assert]').forEach((btn) => {\n btn.disabled = !hasUnique;\n });\n $('rec-assert-text').disabled = !hasUnique;\n $('rec-assert-value').disabled = !hasUnique;\n $('rec-assert-count').disabled = !hasUnique;\n $('rec-assert-attr-name').disabled = !hasUnique;\n $('rec-assert-attr-value').disabled = !hasUnique;\n // Pre-fill the text/value inputs from the currently-selected element so\n // the user just confirms what's there. Read straight from the parsed\n // page source \u2014 no extra device round-trip.\n if (hasUnique && state.selected) {\n const t = state.selected.getAttribute('text') ||\n state.selected.getAttribute('label') ||\n state.selected.getAttribute('name') || '';\n const v = state.selected.getAttribute('value') || '';\n $('rec-assert-text').value = t;\n $('rec-assert-value').value = v;\n }\n }\n\n // \u2500\u2500\u2500 Action progress overlay (over the device screenshot) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Bridges the gap between clicking an action and the screen updating: a veil\n // with a per-action label while the device works, then a brief success \u2713.\n let actionInFlight = false;\n function actionLabel(kind) {\n const m = { click: 'Tapping\u2026', doubleTap: 'Double-tapping\u2026', longPress: 'Long-pressing\u2026',\n fill: 'Typing\u2026', clear: 'Clearing\u2026', swipe: 'Swiping\u2026', scrollIntoView: 'Scrolling\u2026',\n pinch: 'Pinching\u2026', check: 'Checking\u2026', uncheck: 'Unchecking\u2026', focus: 'Focusing\u2026',\n blur: 'Blurring\u2026', press: 'Pressing key\u2026', pressSequentially: 'Typing\u2026',\n selectOption: 'Selecting\u2026', dragTo: 'Dragging\u2026', scroll: 'Scrolling\u2026' };\n return m[kind] || 'Performing action\u2026';\n }\n function beginAction(label) {\n if (actionInFlight) return false; // ignore re-entrant clicks\n actionInFlight = true;\n const el = $('screen-action-overlay');\n el.classList.remove('done');\n $('screen-action-label').textContent = label;\n el.classList.add('shown');\n el.setAttribute('aria-hidden', 'false');\n setStatus('action\u2026', true);\n return true;\n }\n function endActionSuccess() {\n const el = $('screen-action-overlay');\n el.classList.add('done');\n $('screen-action-label').textContent = 'Done';\n setTimeout(() => { el.classList.remove('shown'); el.setAttribute('aria-hidden', 'true'); }, 700);\n setStatus('done');\n actionInFlight = false;\n }\n function endActionError(msg) {\n const el = $('screen-action-overlay');\n el.classList.remove('shown');\n el.setAttribute('aria-hidden', 'true');\n setStatus('action error: ' + msg);\n showToast(msg, 'error', { title: 'Action failed' });\n actionInFlight = false;\n }\n\n async function postLocatorAction(extra) {\n if (!bestLocatorForSelected) return;\n if (!beginAction(actionLabel(extra.kind))) return;\n const s = bestLocatorForSelected;\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n using: s.using, value: s.value, descriptor: s.descriptor, code: s.code, ...extra,\n }),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n await new Promise((res) => setTimeout(res, 300)); // let the device settle\n await fetchSnapshot({ force: true });\n endActionSuccess();\n } catch (err) {\n endActionError(err.message);\n }\n }\n async function postScreenAction(body) {\n if (!beginAction(actionLabel(body.kind))) return;\n try {\n const r = await fetch('/api/screen-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n await new Promise((res) => setTimeout(res, 300)); // let the device settle\n await fetchSnapshot({ force: true });\n endActionSuccess();\n } catch (err) {\n endActionError(err.message);\n }\n }\n\n // Resolve the element under a device point to its best UNIQUE locator\n // suggestion, without touching the current selection / Record-tab state.\n // Mirrors fetchAndRenderLocators' attrs/xpath/suggest pipeline. Returns the\n // suggestion ({ code, using, value, descriptor, unique }) or null when no\n // uniquely-locatable element sits there.\n async function resolveUniqueLocatorAt(pt) {\n const el = findHit(pt.x, pt.y);\n if (!el) return null;\n const attrs = {};\n for (const a of Array.from(el.attributes)) attrs[a.name] = a.value;\n attrs['__tag'] = (el.tagName || '').toLowerCase();\n try {\n const r = await fetch('/api/suggest', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ attrs, xpath: el.__xpath ?? '' }),\n });\n if (!r.ok) return null;\n const { best, recommended } = await r.json();\n const pick = recommended || (best || []).find((s) => s.unique) || null;\n return (pick && pick.unique) ? pick : null;\n } catch {\n return null;\n }\n }\n\n // Drive + record an element-to-element drag. Both src and target are\n // locator suggestions; renders as await <src>.dragTo(<target>).\n async function postDragTo(src, target) {\n setStatus('action\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n kind: 'dragTo',\n using: src.using, value: src.value,\n descriptor: src.descriptor, code: src.code,\n target: {\n using: target.using, value: target.value,\n descriptor: target.descriptor, code: target.code,\n },\n }),\n });\n if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || ('HTTP ' + r.status)); }\n await refreshScript();\n setTimeout(fetchSnapshot, 300);\n setStatus('done');\n } catch (err) {\n setStatus('action error: ' + err.message);\n }\n }\n\n // Element-action button delegation.\n document.querySelectorAll('#tab-record .rec-act[data-act]').forEach((btn) => {\n btn.onclick = () => {\n const act = btn.dataset.act;\n if (!bestLocatorForSelected) return;\n switch (act) {\n case 'click': return postLocatorAction({ kind: 'click' });\n case 'doubleTap': return postLocatorAction({ kind: 'doubleTap' });\n case 'longPress': return postLocatorAction({ kind: 'longPress' });\n case 'check': return postLocatorAction({ kind: 'check' });\n case 'uncheck': return postLocatorAction({ kind: 'uncheck' });\n case 'focus': return postLocatorAction({ kind: 'focus' });\n case 'blur': return postLocatorAction({ kind: 'blur' });\n case 'swipe-left': return postLocatorAction({ kind: 'swipe', direction: 'left' });\n case 'swipe-right': return postLocatorAction({ kind: 'swipe', direction: 'right' });\n case 'swipe-up': return postLocatorAction({ kind: 'swipe', direction: 'up' });\n case 'swipe-down': return postLocatorAction({ kind: 'swipe', direction: 'down' });\n case 'scrollIntoView': return postLocatorAction({ kind: 'scrollIntoView' });\n case 'pinch-in': return postLocatorAction({ kind: 'pinch', direction: 'in' });\n case 'pinch-out': return postLocatorAction({ kind: 'pinch', direction: 'out' });\n case 'dragToPoint': {\n // Source is the selected element (button is gated on a unique\n // locator). Drop target must resolve to a uniquely-locatable\n // element too \u2014 on a miss, re-arm pick mode so the user retries.\n const src = bestLocatorForSelected;\n const pickTarget = () => startPickMode('Click the drop target element.', async (pt) => {\n const target = await resolveUniqueLocatorAt(pt);\n if (!target) {\n setStatus('No uniquely-locatable element there \u2014 pick another drop target');\n pickTarget();\n return;\n }\n postDragTo(src, target);\n });\n pickTarget();\n return;\n }\n }\n };\n });\n\n $('btn-rec-seq').onclick = () => {\n const text = $('rec-seq-input').value;\n if (!text) { $('rec-seq-input').focus(); return; }\n const delayStr = $('rec-seq-delay').value;\n const delay = delayStr ? parseInt(delayStr, 10) : undefined;\n const extra = (delay && delay > 0) ? { kind: 'pressSequentially', text, delay } : { kind: 'pressSequentially', text };\n postLocatorAction(extra).then(() => {\n $('rec-seq-input').value = '';\n });\n };\n\n $('btn-rec-press').onclick = () => {\n const key = $('rec-press-key').value;\n if (!key) return;\n postLocatorAction({ kind: 'press', key });\n };\n\n $('btn-rec-select').onclick = () => {\n const label = $('rec-select-label').value;\n if (!label) { $('rec-select-label').focus(); return; }\n postLocatorAction({ kind: 'selectOption', value: { label } }).then(() => {\n $('rec-select-label').value = '';\n });\n };\n\n $('btn-rec-type').onclick = () => {\n const text = $('rec-type-input').value;\n if (!text) { $('rec-type-input').focus(); return; }\n postLocatorAction({ kind: 'fill', text }).then(() => {\n $('rec-type-input').value = '';\n });\n };\n $('rec-type-input').addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') { ev.preventDefault(); $('btn-rec-type').click(); }\n });\n $('btn-rec-clear').onclick = () => {\n postLocatorAction({ kind: 'clear' }).then(() => {\n $('rec-type-input').value = '';\n });\n };\n\n // \u2500\u2500\u2500 Custom Y range for screen scroll up/down \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /**\n * Read the user's top% and bottom% inputs and map them into direction-\n * aware fromY/toY fractions. Recorded code uses from.y for the finger\n * start and to.y for the finger end \u2014 same convention as Mobile.swipe.\n * Empty inputs \u2192 no overrides.\n */\n function readScrollYRange(direction) {\n const topRaw = $('rec-scroll-top').value.trim();\n const botRaw = $('rec-scroll-bottom').value.trim();\n const xRaw = $('rec-scroll-x').value.trim();\n const out = {};\n\n // Y range: top%/bottom% \u2192 direction-aware fromY/toY (finger start/end).\n if (topRaw !== '' || botRaw !== '') {\n const topPct = topRaw === '' ? null : Math.max(0, Math.min(100, Number(topRaw)));\n const botPct = botRaw === '' ? null : Math.max(0, Math.min(100, Number(botRaw)));\n if (topPct !== null || botPct !== null) {\n const top = (topPct ?? 0) / 100;\n const bot = (botPct ?? 100) / 100;\n // For scroll('down') the finger moves UP across the region: from y=bot to y=top.\n // For scroll('up') the finger moves DOWN across the region: from y=top to y=bot.\n if (direction === 'down') { out.fromY = bot; out.toY = top; }\n else if (direction === 'up') { out.fromY = top; out.toY = bot; }\n }\n }\n\n // X anchor: single value where the vertical scroll happens horizontally.\n if (xRaw !== '') {\n const xPct = Math.max(0, Math.min(100, Number(xRaw)));\n if (!Number.isNaN(xPct)) {\n const x = xPct / 100;\n out.fromX = x;\n out.toX = x;\n }\n }\n\n return out;\n }\n $('btn-rec-y-clear').onclick = () => {\n $('rec-scroll-top').value = '';\n $('rec-scroll-bottom').value = '';\n $('rec-scroll-x').value = '';\n };\n\n // \u2500\u2500\u2500 Assert-action button delegation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n /**\n * Send an assertion to the server. The server runs a short verify check\n * against the live device first; if it would fail, we surface a \"Record\n * anyway\" toast and re-post with force=true on confirmation.\n */\n async function postAssertion(opts) {\n if (!bestLocatorForSelected) return;\n const s = bestLocatorForSelected;\n const body = {\n kind: 'assertion',\n using: s.using, value: s.value, descriptor: s.descriptor, code: s.code,\n matcher: opts.matcher,\n expected: opts.expected,\n expectedCount: opts.expectedCount,\n attrName: opts.attrName,\n mode: opts.mode,\n force: !!opts.force,\n };\n setStatus('verifying assertion\u2026', true);\n try {\n const r = await fetch('/api/locator-action', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n if (j.recorded) {\n setStatus('asserted \u2713');\n await refreshScript();\n showToast('Assertion recorded', 'success', { title: 'Recorded' });\n } else if (!j.verified) {\n const got = j.actual !== undefined ? ' (got: ' + JSON.stringify(j.actual) + ')' : '';\n const dismiss = showToast(\n 'This assertion would fail right now' + got + '. Record it anyway?',\n 'error',\n { title: 'Assertion would fail', ttl: 0 },\n );\n // Patch the toast to add a \"Record anyway\" button that re-posts with force.\n const cont = $('toasts');\n const lastToast = cont.querySelector('.toast.error:last-child');\n if (lastToast) {\n const btn = document.createElement('button');\n btn.className = 'icon';\n btn.style.marginLeft = '4px';\n btn.textContent = 'Record anyway';\n btn.onclick = () => {\n dismiss();\n postAssertion({ ...opts, force: true });\n };\n const body = lastToast.querySelector('.body');\n if (body) body.appendChild(btn);\n }\n } else {\n // Verified but not recorded \u2014 recording is off.\n showToast('Recording is off \u2014 Start record first.', 'info', { title: 'Not recorded' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Assertion failed' });\n }\n }\n\n document.querySelectorAll('#tab-record .rec-act[data-assert]').forEach((btn) => {\n btn.onclick = () => {\n const which = btn.dataset.assert;\n switch (which) {\n case 'visible':\n case 'hidden':\n case 'enabled':\n case 'disabled':\n case 'checked':\n case 'unchecked':\n case 'editable':\n case 'readonly':\n case 'focused':\n case 'attached':\n case 'empty':\n case 'inViewport':\n return postAssertion({ matcher: which });\n case 'text-exact': {\n const expected = $('rec-assert-text').value;\n return postAssertion({ matcher: 'text', expected, mode: 'exact' });\n }\n case 'text-contains': {\n const expected = $('rec-assert-text').value;\n return postAssertion({ matcher: 'text', expected, mode: 'contains' });\n }\n case 'value': {\n const expected = $('rec-assert-value').value;\n return postAssertion({ matcher: 'value', expected });\n }\n case 'count': {\n const raw = $('rec-assert-count').value;\n const expectedCount = parseInt(raw, 10);\n if (Number.isNaN(expectedCount)) {\n setStatus('count assertion needs a number');\n return;\n }\n return postAssertion({ matcher: 'count', expectedCount });\n }\n case 'attribute': {\n const attrName = $('rec-assert-attr-name').value.trim();\n const expected = $('rec-assert-attr-value').value;\n if (!attrName) {\n setStatus('attribute assertion needs a name');\n return;\n }\n return postAssertion({ matcher: 'attribute', attrName, expected });\n }\n }\n };\n });\n\n // Screen-action button delegation.\n document.querySelectorAll('#tab-record .rec-act[data-screen]').forEach((btn) => {\n btn.onclick = () => {\n const act = btn.dataset.screen;\n switch (act) {\n case 'scroll-up': return postScreenAction({ kind: 'scroll', direction: 'up', ...readScrollYRange('up') });\n case 'scroll-down': return postScreenAction({ kind: 'scroll', direction: 'down', ...readScrollYRange('down') });\n case 'tap-point':\n startPickMode('Click the screen where the tap should land.', (pt) => {\n // Use existing /api/tap (already records as 'tap').\n fetch('/api/tap', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(pt),\n }).then(refreshScript).then(() => setTimeout(fetchSnapshot, 300));\n });\n return;\n case 'drag-and-drop': {\n // Both endpoints must resolve to uniquely-locatable elements;\n // re-arm the relevant pick step on a miss. Records as\n // await <src>.dragTo(<target>).\n const pickSrc = () => startPickMode('Click the element to drag.', async (p1) => {\n const src = await resolveUniqueLocatorAt(p1);\n if (!src) {\n setStatus('No uniquely-locatable element there \u2014 pick another source');\n pickSrc();\n return;\n }\n const pickTgt = () => startPickMode('Now click the drop target element.', async (p2) => {\n const target = await resolveUniqueLocatorAt(p2);\n if (!target) {\n setStatus('No uniquely-locatable element there \u2014 pick another drop target');\n pickTgt();\n return;\n }\n postDragTo(src, target);\n });\n pickTgt();\n });\n pickSrc();\n return;\n }\n }\n };\n });\n\n // \u2500\u2500\u2500 Recording start/stop toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let recording = false;\n function applyRecordingState(on) {\n recording = !!on;\n const banner = $('rec-toggle');\n const status = $('rec-status');\n const btn = $('btn-rec-toggle');\n const label = $('btn-rec-toggle-label');\n banner.classList.toggle('live', recording);\n btn.classList.toggle('stop', recording);\n if (recording) {\n status.innerHTML = '<strong>Recording</strong> \u2014 every action below is appended to the script.';\n label.textContent = 'Stop record';\n } else {\n status.innerHTML = \"<strong>Not recording</strong> \u2014 press Start to capture actions as a script.\";\n label.textContent = 'Start record';\n }\n }\n $('btn-rec-toggle').onclick = async () => {\n const next = !recording;\n const path = next ? '/api/recording/start' : '/api/recording/stop';\n try {\n const r = await fetch(path, { method: 'POST' });\n const j = await r.json();\n if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));\n const wasRecording = recording;\n applyRecordingState(j.recording);\n await refreshScript();\n // Stop transition: surface a confirmation that the script is captured.\n if (wasRecording && !j.recording) {\n if (lastSpec) {\n // Use action-line count (everything between the test() body braces)\n // as a rough \"N actions recorded\" hint.\n const actionLines = lastSpec.split('\\n').filter((l) => /^\\s*await\\s/.test(l)).length;\n const actionLabel = actionLines + (actionLines === 1 ? ' action' : ' actions');\n showToast(\n 'Recording stopped \u2014 ' + actionLabel + ' captured. Use \u2193 Export or \u2398 Copy from the Recorded script tab.',\n 'success',\n { title: 'Script saved' },\n );\n } else {\n showToast(\n 'Recording stopped \u2014 no actions were captured.',\n 'info',\n { title: 'Nothing recorded' },\n );\n }\n } else if (!wasRecording && j.recording) {\n showToast('Recording \u2014 every action you take will append to the script.', 'info', { title: 'Recording' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Recording toggle failed' });\n }\n };\n\n /** Cache the most-recent unstyled spec so Copy doesn't paste highlighted HTML. */\n let lastSpec = '';\n // Target language for the Recorded-script tab: 'ts' (default, taqwright) |\n // 'python' (Appium-Python-Client) | 'java' (Appium java-client).\n let scriptLang = 'ts';\n function defaultScriptName() {\n return scriptLang === 'python'\n ? 'recorded_steps.py'\n : scriptLang === 'java'\n ? 'RecordedSteps.java'\n : 'recorded.spec.ts';\n }\n async function refreshScript() {\n const r = await fetch('/api/recording?lang=' + scriptLang);\n const j = await r.json();\n lastSpec = j.spec || '';\n $('script').innerHTML = lastSpec ? highlightCode(lastSpec, scriptLang) : '';\n if (typeof j.recording === 'boolean' && j.recording !== recording) {\n applyRecordingState(j.recording);\n }\n }\n document.querySelectorAll('#script-lang button').forEach((b) => {\n b.onclick = async () => {\n scriptLang = b.dataset.lang;\n document\n .querySelectorAll('#script-lang button')\n .forEach((x) => x.classList.toggle('active', x === b));\n $('script-lang-note').style.display = scriptLang === 'ts' ? 'none' : '';\n await refreshScript();\n };\n });\n $('btn-copy-script').onclick = async () => {\n try {\n await refreshScript();\n if (!lastSpec) {\n showToast('Recorded script is empty \u2014 record something first.', 'info', { title: 'Nothing to copy' });\n return;\n }\n await navigator.clipboard.writeText(lastSpec);\n showToast('Copied ' + lastSpec.length + ' chars to clipboard.', 'success', { title: 'Copied' });\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Copy failed' });\n }\n };\n $('btn-clear-script').onclick = async () => {\n try {\n await refreshScript();\n if (!lastSpec) {\n showToast('Already empty.', 'info', { title: 'Nothing to clear' });\n return;\n }\n const r = await fetch('/api/recording/clear', { method: 'POST' });\n if (!r.ok) throw new Error('HTTP ' + r.status);\n await refreshScript();\n showToast('Recorded script cleared.', 'success', { title: 'Cleared' });\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Clear failed' });\n }\n };\n $('btn-export-script').onclick = async () => {\n try {\n // Look up where this lands so the prompt + native panel can show the path.\n const infoR = await fetch('/api/export-script/info');\n const info = await infoR.json();\n if (!info.ok) {\n showToast(\n info.error || 'No taqwright.config.ts found \u2014 run the inspector from a project directory.',\n 'error',\n { title: 'Cannot export' },\n );\n return;\n }\n await refreshScript();\n if (!lastSpec) {\n showToast('Recorded script is empty \u2014 record something first.', 'info', { title: 'Nothing to export' });\n return;\n }\n\n // Preferred path: macOS native save panel \u2014 lets the user navigate\n // anywhere, pick a filename, and the OS itself handles overwrite\n // confirmation. Falls back to a plain prompt() on Linux/Windows or\n // when osascript isn't available.\n let absolutePath = '';\n try {\n const sR = await fetch('/api/file-save-picker', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n defaultName: defaultScriptName(),\n defaultLocation: info.absoluteDir,\n }),\n });\n const sJ = await sR.json();\n if (sJ.cancelled) return;\n if (sJ.ok && sJ.path) absolutePath = sJ.path;\n // sJ.error \u2192 fall through to prompt below.\n } catch { /* fall through to prompt */ }\n\n if (absolutePath) {\n // Native panel already confirmed overwrite at the OS level.\n const r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n absolutePath,\n content: lastSpec,\n overwrite: true,\n }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || 'HTTP ' + r.status);\n showToast(\n 'Saved to ' + j.path + ' (' + j.bytes + ' bytes).',\n 'success',\n { title: 'Exported' },\n );\n return;\n }\n\n // Fallback: plain prompt for filename within testDir (non-macOS).\n const filename = window.prompt(\n 'Save as (relative to ' + info.absoluteDir + '):',\n defaultScriptName(),\n );\n if (!filename) return;\n let r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ filename, content: lastSpec }),\n });\n let j = await r.json();\n if (!r.ok || !j.ok) {\n if (/already exists/i.test(j.error || '')) {\n const ok = window.confirm(j.error + '\\n\\nOverwrite?');\n if (!ok) return;\n r = await fetch('/api/export-script', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ filename, content: lastSpec, overwrite: true }),\n });\n j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || 'HTTP ' + r.status);\n } else {\n throw new Error(j.error || 'HTTP ' + r.status);\n }\n }\n showToast(\n 'Saved to ' + j.path + ' (' + j.bytes + ' bytes).',\n 'success',\n { title: 'Exported' },\n );\n } catch (err) {\n showToast(err.message || String(err), 'error', { title: 'Export failed' });\n }\n };\n\n // Keyboard: R = refresh.\n document.addEventListener('keydown', (ev) => {\n if (\n (ev.key === 'r' || ev.key === 'R') &&\n !ev.metaKey && !ev.ctrlKey && !ev.altKey &&\n !(ev.target instanceof HTMLInputElement) &&\n !(ev.target instanceof HTMLTextAreaElement)\n ) {\n ev.preventDefault();\n fetchSnapshot();\n }\n });\n\n /** Build the header meta line \u2014 drop the project name when it duplicates the platform. */\n function formatSessionMeta(platform, project) {\n const p = String(platform || '').toLowerCase();\n const proj = String(project || '').trim();\n if (!proj || proj.toLowerCase() === p) return platform || '';\n return platform + ' \u00B7 ' + proj;\n }\n\n /**\n * Tiny JS/TS syntax highlighter for the recorded script. Single-pass\n * tokenizer (more robust than regex passes which choke when keywords\n * appear inside strings) producing colored <span> tags.\n */\n // Language-agnostic tokenizer shared by the Taqwright (TS), Python and Java\n // views. Strings / numbers / identifiers / function-calls / punctuation are\n // common; only line-comment syntax and the keyword set vary by language.\n const KW_BY_LANG = {\n ts: new Set([\n 'import', 'from', 'export', 'async', 'await', 'return',\n 'if', 'else', 'const', 'let', 'var', 'new',\n 'true', 'false', 'null', 'undefined',\n ]),\n python: new Set([\n 'import', 'from', 'as', 'def', 'class', 'return',\n 'if', 'elif', 'else', 'for', 'while', 'in', 'is', 'and', 'or', 'not',\n 'lambda', 'assert', 'with', 'try', 'except', 'None', 'True', 'False',\n ]),\n java: new Set([\n 'import', 'package', 'public', 'private', 'protected', 'static', 'final',\n 'void', 'var', 'new', 'return', 'if', 'else', 'for', 'while', 'class',\n 'this', 'throws', 'throw', 'try', 'catch', 'true', 'false', 'null', 'assert',\n ]),\n };\n function highlightCode(src, lang) {\n const KW = KW_BY_LANG[lang] || KW_BY_LANG.ts;\n const out = [];\n const n = src.length;\n let i = 0;\n while (i < n) {\n const c = src[i];\n // Line comment: // (TS/Java) or # (Python) ... newline\n if ((c === '/' && src[i + 1] === '/') || (c === '#' && lang === 'python')) {\n const end = src.indexOf('\\n', i);\n const stop = end === -1 ? n : end;\n out.push(span('cmt', src.slice(i, stop)));\n i = stop;\n continue;\n }\n // String 'foo' or \"foo\"\n if (c === \"'\" || c === '\"') {\n const quote = c;\n let j = i + 1;\n while (j < n && src[j] !== quote) {\n if (src[j] === '\\\\' && j + 1 < n) j += 2;\n else j += 1;\n }\n out.push(span('str', src.slice(i, Math.min(j + 1, n))));\n i = Math.min(j + 1, n);\n continue;\n }\n // Number\n if (c >= '0' && c <= '9') {\n let j = i;\n while (j < n && /[\\d._]/.test(src[j])) j++;\n out.push(span('num', src.slice(i, j)));\n i = j;\n continue;\n }\n // Identifier\n if (/[A-Za-z_$]/.test(c)) {\n let j = i;\n while (j < n && /[\\w$]/.test(src[j])) j++;\n const word = src.slice(i, j);\n // Skip whitespace to peek for an open-paren (function-call style).\n let k = j;\n while (k < n && (src[k] === ' ' || src[k] === '\t')) k++;\n const tag = KW.has(word) ? 'kw' : (src[k] === '(' ? 'fn' : 'id');\n out.push(span(tag, word));\n i = j;\n continue;\n }\n // Whitespace passes through verbatim (no span needed \u2014 saves bytes).\n if (c === ' ' || c === '\\t' || c === '\\n' || c === '\\r') {\n out.push(c);\n i++;\n continue;\n }\n // Punctuation / operators.\n out.push(span('pun', c));\n i++;\n }\n return out.join('');\n }\n function span(tag, text) {\n return '<span class=\"tok-' + tag + '\">' + escapeHtml(text) + '</span>';\n }\n\n function escapeHtml(s) {\n return String(s).replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''',\n }[c]));\n }\n function truncate(s, n) {\n s = String(s);\n return s.length > n ? s.slice(0, n - 1) + '\u2026' : s;\n }\n\n // Promise-based confirm dialog \u2014 replaces window.confirm with an in-page modal.\n // Resolves true on confirm, false on cancel / overlay-click / Escape.\n function confirmModal(opts) {\n const o = opts || {};\n const overlay = $('modal-overlay');\n $('modal-title').textContent = o.title || 'Are you sure?';\n $('modal-msg').textContent = o.message || '';\n $('modal-icon').textContent = o.icon || '\u26A0\uFE0F';\n const confirmBtn = $('modal-confirm');\n const cancelBtn = $('modal-cancel');\n confirmBtn.textContent = o.confirmLabel || 'Confirm';\n cancelBtn.textContent = o.cancelLabel || 'Cancel';\n confirmBtn.classList.toggle('confirm', o.danger !== false);\n return new Promise((resolve) => {\n function cleanup(result) {\n overlay.classList.remove('open');\n confirmBtn.onclick = null;\n cancelBtn.onclick = null;\n overlay.onclick = null;\n document.removeEventListener('keydown', onKey);\n resolve(result);\n }\n function onKey(e) {\n if (e.key === 'Escape') cleanup(false);\n else if (e.key === 'Enter') cleanup(true);\n }\n confirmBtn.onclick = () => cleanup(true);\n cancelBtn.onclick = () => cleanup(false);\n overlay.onclick = (e) => { if (e.target === overlay) cleanup(false); };\n document.addEventListener('keydown', onKey);\n overlay.classList.add('open');\n confirmBtn.focus();\n });\n }\n\n // \u2500\u2500\u2500 Setup / landing logic \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function showView(name) {\n document.body.classList.toggle('view-setup', name === 'setup');\n document.body.classList.toggle('view-inspector', name === 'inspector');\n }\n\n async function bootstrap() {\n setStatus('checking session\u2026', true);\n try {\n const r = await fetch('/api/status');\n const j = await r.json();\n if (j.connected) {\n // Attached mode: the inspector is borrowing a driver from a paused\n // test (mobile.pause()). Surface \"Resume\" instead of Disconnect.\n if (j.attached) {\n $('btn-disconnect').style.display = 'none';\n $('btn-resume').style.display = '';\n $('session-meta').textContent = 'paused \u2014 ' + formatSessionMeta(j.platform, j.project);\n } else {\n $('session-meta').textContent = formatSessionMeta(j.platform, j.project);\n }\n applyRecordingState(j.recording);\n showLoader('Loading device screen\u2026',\n 'Reconnecting to the active session and pulling the latest snapshot.');\n showView('inspector');\n await fetchSnapshot();\n startAutoRefresh();\n hideLoader();\n onInspectorReady();\n } else {\n showView('setup');\n await initSetup(j);\n maybeStartSetupTour();\n }\n setStatus('idle');\n } catch (err) {\n setStatus('bootstrap error: ' + err.message);\n }\n }\n\n $('btn-resume').onclick = async () => {\n $('btn-resume').disabled = true;\n $('btn-resume').textContent = 'Resuming\u2026';\n try {\n await fetch('/api/resume', { method: 'POST' });\n autoRefreshOn = false;\n document.body.innerHTML =\n '<div style=\"display:flex;align-items:center;justify-content:center;' +\n 'height:100vh;font:14px -apple-system,sans-serif;color:#888;text-align:center;\">' +\n '<div><div style=\"font-size:32px;margin-bottom:12px\">\u25B6</div>' +\n 'Test resumed. You can close this tab.</div></div>';\n } catch (err) {\n $('btn-resume').disabled = false;\n $('btn-resume').textContent = 'Resume \u25B6';\n setStatus('resume error: ' + err.message);\n }\n };\n\n // Keys we map to dedicated form fields. Anything else lives in the\n // advanced JSON editor and is merged on top at connect time.\n const KNOWN_CAP_KEYS = new Set([\n 'platformName',\n 'appium:automationName',\n 'appium:deviceName',\n 'appium:platformVersion',\n 'appium:app',\n 'appium:bundleId',\n 'appium:appPackage',\n 'appium:udid',\n 'appium:noReset',\n ]);\n\n /** Split a flat caps object into form fields + an ordered array of extra rows. */\n function splitCaps(caps) {\n const c = caps || {};\n const platform = c.platformName === 'iOS' ? 'iOS' : 'Android';\n const form = {\n platform,\n device: c['appium:deviceName'] || '',\n version: c['appium:platformVersion'] || '',\n app: c['appium:app'] || '',\n bundle: c['appium:bundleId'] || c['appium:appPackage'] || '',\n udid: c['appium:udid'] || '',\n noReset: c['appium:noReset'] !== false,\n };\n const extras = [];\n for (const [k, v] of Object.entries(c)) {\n if (KNOWN_CAP_KEYS.has(k)) continue;\n extras.push({ key: k, value: stringifyCapValue(v) });\n }\n return { form, extras };\n }\n\n /** Build a flat caps object from form + extras. Extras override on key collision. */\n function buildCaps(form, extras) {\n const caps = {\n platformName: form.platform,\n 'appium:automationName': form.platform === 'iOS' ? 'XCUITest' : 'UiAutomator2',\n };\n if (form.device) caps['appium:deviceName'] = form.device;\n if (form.version) caps['appium:platformVersion'] = form.version;\n if (form.app) caps['appium:app'] = form.app;\n if (form.bundle) {\n if (form.platform === 'iOS') caps['appium:bundleId'] = form.bundle;\n else caps['appium:appPackage'] = form.bundle;\n }\n if (form.udid) caps['appium:udid'] = form.udid;\n if (form.noReset) caps['appium:noReset'] = true;\n for (const row of extras || []) {\n const key = String(row.key || '').trim();\n if (!key) continue;\n caps[key] = parseCapValue(row.value);\n }\n return caps;\n }\n\n /** Coerce a string value into the most specific JSON type \u2014 bool, number, JSON, else string. */\n function parseCapValue(v) {\n if (typeof v !== 'string') return v;\n const s = v.trim();\n if (s === '') return '';\n if (s === 'true') return true;\n if (s === 'false') return false;\n if (s === 'null') return null;\n if (/^-?\\d+$/.test(s)) return parseInt(s, 10);\n if (/^-?\\d+\\.\\d+$/.test(s)) return parseFloat(s);\n if (s[0] === '{' || s[0] === '[' || s[0] === '\"') {\n try { return JSON.parse(s); } catch { /* fall through */ }\n }\n return s;\n }\n\n function stringifyCapValue(v) {\n if (typeof v === 'string') return v;\n if (typeof v === 'boolean' || typeof v === 'number') return String(v);\n if (v == null) return '';\n return JSON.stringify(v);\n }\n\n async function initSetup(initial) {\n // Appium fields.\n $('appium-host').value = initial.appium.host;\n $('appium-port').value = String(initial.appium.port);\n $('appium-path').value = initial.appium.path;\n\n // Capability fields.\n applyCapsToForm(initial.defaults.capabilities);\n\n // Re-initialization after disconnect must clear the previous device choice too,\n // otherwise the stale tile shows selected while the (now-empty) cap-device gate\n // keeps Next disabled. Mirrors the reset in setConnectionMode().\n selectedDeviceKey = null;\n selectedCloudDevice = null;\n\n // Reset wizard state (bootstrap re-runs after disconnect).\n prereqsDoctorDone = false;\n prereqsAppiumDone = false;\n const progressEl = document.getElementById('prereq-progress');\n if (progressEl) progressEl.classList.remove('done');\n $('app-inspect-status').textContent = '';\n $('app-inspect-status').className = 'app-inspect-status';\n\n // Doctor + appium probes + device list.\n await loadDoctor();\n await refreshAppiumPill();\n await loadDevices();\n\n // Wire interactions.\n $('btn-appium-recheck').onclick = refreshAppiumPill;\n $('btn-appium-restart').onclick = restartAppium;\n $('btn-appium-start').onclick = startAppium;\n $('btn-connect').onclick = doConnect;\n $('btn-add-cap').onclick = () => addExtraRow({ key: '', value: '' }, true);\n $('btn-devices-refresh').onclick = loadDevices;\n $('btn-app-browse').onclick = pickAppFile;\n $('btn-step-back').onclick = () => goToStep(wizardStep - 1);\n $('btn-step-next').onclick = () => goToStep(wizardStep + 1);\n\n // Connection-mode picker (Local / BrowserStack / LambdaTest).\n document.querySelectorAll('.conn-mode-btn').forEach((b) => {\n b.onclick = () => setConnectionMode(b.dataset.connMode);\n });\n // Cloud creds inputs \u2014 refresh pill + summary on every keystroke.\n for (const id of ['cloud-user', 'cloud-key']) {\n $(id).addEventListener('input', refreshCloudCredsPill);\n $(id).addEventListener('change', refreshCloudCredsPill);\n }\n for (const id of ['appium-host', 'appium-port', 'appium-path']) {\n $(id).addEventListener('change', () => { refreshAppiumPill(); updateConnectSummary(); });\n $(id).addEventListener('input', updateConnectSummary);\n }\n for (const id of ['cap-platform', 'cap-device', 'cap-version', 'cap-app', 'cap-bundle', 'cap-udid', 'cap-noreset']) {\n $(id).addEventListener('input', updateConnectSummary);\n $(id).addEventListener('change', updateConnectSummary);\n }\n $('cap-platform').addEventListener('change', () => {\n clearAppIfPlatformMismatch($('cap-platform').value);\n updateBundleLabel();\n });\n $('cap-app').addEventListener('change', () => inspectAppPath());\n $('doctor-summary').addEventListener('click', toggleDoctorList);\n\n // Stepper pills: clicking a completed pill jumps back to it.\n document.querySelectorAll('.wizard-step-pill').forEach((pill) => {\n pill.addEventListener('click', () => {\n const target = Number(pill.getAttribute('data-step'));\n if (target && target < wizardStep) goToStep(target);\n });\n });\n\n updateBundleLabel();\n updateConnectSummary();\n goToStep(1);\n }\n\n // \u2500\u2500\u2500 Wizard state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let wizardStep = 1;\n let prereqsDoctorDone = false;\n let prereqsAppiumDone = false;\n // Connection mode: 'local' (existing emulator/sim flow), 'browserstack',\n // or 'lambdatest'. Cloud modes skip the local Appium card and use the\n // cloud's own hub.\n let connectionMode = 'local';\n let cloudCredsValid = false;\n\n function isCloudMode() {\n return connectionMode === 'browserstack' || connectionMode === 'lambdatest';\n }\n\n function setConnectionMode(mode) {\n // Snapshot current cloud creds before swapping \u2014 keeps each provider's\n // values isolated so the user can flip back and forth without losing\n // what they typed for either one.\n snapshotCloudCreds();\n connectionMode = mode;\n document.querySelectorAll('.conn-mode-btn').forEach((b) => {\n b.classList.toggle('active', b.dataset.connMode === mode);\n });\n const local = document.getElementById('step1-local-block');\n const cloud = document.getElementById('step1-cloud-block');\n const intro = document.getElementById('step1-intro');\n if (mode === 'local') {\n if (local) local.style.display = '';\n if (cloud) cloud.style.display = 'none';\n if (intro) intro.innerHTML = 'Confirming the CLIs you need (adb, xcrun, Java) are installed and that the Appium server is reachable. If the Appium pill is grey, click <strong>Start Appium</strong> \u2014 <strong>Next</strong> unlocks once it turns green.';\n } else {\n if (local) local.style.display = 'none';\n if (cloud) cloud.style.display = '';\n const provLabel = mode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n if (intro) intro.innerHTML = 'Connecting to <strong>' + provLabel + '</strong> cloud devices. Enter your credentials below \u2014 <strong>Next</strong> unlocks once they are filled in.';\n const titleEl = document.getElementById('cloud-creds-title');\n if (titleEl) titleEl.textContent = provLabel + ' credentials';\n // Restore the new provider's creds: in-memory cache first, env vars\n // as fallback. Always overwrites \u2014 no leakage from the previous one.\n loadCloudCredsForMode(mode);\n }\n applyModeToStep3();\n // Selecting a different mode invalidates the previous device choice.\n selectedDeviceKey = null;\n selectedCloudDevice = null;\n $('cap-device').value = '';\n $('cap-version').value = '';\n $('cap-udid').value = '';\n // Drop the previous source's catalog so step 2 doesn't flash stale tiles\n // before the new source's loadDevices() resolves.\n lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n devicePage = { android: 0, ios: 0 };\n updateConnectSummary();\n }\n\n /** Re-skin the Capabilities form for the current connection mode. */\n function applyModeToStep3() {\n const cloud = isCloudMode();\n // App field placeholder + hint.\n const appInput = document.getElementById('cap-app');\n if (appInput) {\n appInput.placeholder = cloud\n ? (connectionMode === 'browserstack'\n ? 'bs://\u2026 (uploaded via BrowserStack app-upload)'\n : 'lt://\u2026 (uploaded via LambdaTest app-upload)')\n : 'optional \u00B7 path to .apk / .ipa / .app';\n }\n // Browse button is meaningless for cloud \u2014 no native picker uploads to cloud yet.\n const browseBtn = document.getElementById('btn-app-browse');\n if (browseBtn) browseBtn.style.display = cloud ? 'none' : '';\n // UDID is local-only.\n const udidRow = document.getElementById('cap-udid');\n if (udidRow) {\n const field = udidRow.closest('.field');\n if (field) field.style.display = cloud ? 'none' : '';\n }\n }\n\n /** Server-side env-var snapshot, fetched once. */\n let cloudEnvCache = null;\n async function loadCloudEnvOnce() {\n if (cloudEnvCache) return cloudEnvCache;\n try {\n const r = await fetch('/api/cloud/env');\n cloudEnvCache = await r.json();\n } catch {\n cloudEnvCache = { browserstack: { user: '', key: '' }, lambdatest: { user: '', key: '' } };\n }\n return cloudEnvCache;\n }\n\n // Per-provider in-memory cache of what the user has typed. Lets the\n // user toggle BrowserStack \u2194 LambdaTest without losing the creds for\n // either one.\n const cloudCredsByProvider = { browserstack: null, lambdatest: null };\n\n // Save the currently-displayed cloud creds into the cache for the\n // current cloud mode (no-op when local).\n function snapshotCloudCreds() {\n if (!isCloudMode()) return;\n const userEl = document.getElementById('cloud-user');\n const keyEl = document.getElementById('cloud-key');\n if (!userEl || !keyEl) return;\n cloudCredsByProvider[connectionMode] = {\n user: (userEl.value || '').trim(),\n key: (keyEl.value || '').trim(),\n };\n }\n\n // Populate the cloud-user / cloud-key inputs for the given mode: cached\n // value if the user has typed something for it, else env-var default.\n // Always overwrites \u2014 never leaves stale values from another provider.\n async function loadCloudCredsForMode(mode) {\n const userEl = $('cloud-user');\n const keyEl = $('cloud-key');\n let user = '';\n let key = '';\n let fromCache = false;\n const cached = cloudCredsByProvider[mode];\n if (cached && (cached.user || cached.key)) {\n user = cached.user;\n key = cached.key;\n fromCache = true;\n } else {\n const env = await loadCloudEnvOnce();\n const slot = env[mode] || { user: '', key: '' };\n user = slot.user || '';\n key = slot.key || '';\n }\n if (userEl) userEl.value = user;\n if (keyEl) keyEl.value = key;\n const hint = $('cloud-creds-hint');\n if (hint) {\n const envName = mode === 'browserstack'\n ? 'BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY'\n : 'LAMBDATEST_USERNAME / LAMBDATEST_ACCESS_KEY';\n hint.innerHTML = fromCache\n ? '\u2713 Restored from this session.'\n : ((user || key)\n ? '\u2713 Prefilled from <code>' + envName + '</code>. Override here for this session.'\n : 'No env vars detected (<code>' + envName + '</code>). Paste credentials above or set the env vars before launching the inspector.');\n }\n refreshCloudCredsPill();\n }\n\n function refreshCloudCredsPill() {\n const pill = document.getElementById('cloud-creds-pill');\n const label = document.getElementById('cloud-creds-pill-label');\n if (!pill || !label) return;\n const u = ($('cloud-user').value || '').trim();\n const k = ($('cloud-key').value || '').trim();\n if (u && k) {\n pill.className = 'pill live';\n label.textContent = 'creds detected';\n cloudCredsValid = true;\n } else {\n pill.className = 'pill down';\n label.textContent = 'awaiting\u2026';\n cloudCredsValid = false;\n }\n updateConnectSummary();\n }\n\n // Whether the wizard is allowed to advance forward off the given step (its\n // prerequisites are met). Mirrors the gating in updateConnectSummary.\n function canAdvanceFrom(step) {\n if (step === 1) {\n return isCloudMode() ? cloudCredsValid : $('appium-pill').classList.contains('live');\n }\n // Require an actual selected, booted device \u2014 not just a pre-filled\n // cap-device value (config defaults seed it, which would wrongly enable Next).\n if (step === 2) return selectedDeviceKey !== null;\n return true;\n }\n\n function goToStep(n) {\n if (n < 1 || n > 3) return;\n // Hard-gate forward navigation: never advance past a step whose\n // prerequisites aren't met \u2014 even for programmatic callers like the guided\n // tour. Backward navigation and re-selecting the current step are free.\n if (n > wizardStep && !canAdvanceFrom(wizardStep)) return;\n wizardStep = n;\n document.querySelectorAll('.wizard-page').forEach((p) => {\n p.classList.toggle('active', Number(p.getAttribute('data-page')) === n);\n });\n document.querySelectorAll('.wizard-step-pill').forEach((p) => {\n const ps = Number(p.getAttribute('data-step'));\n p.classList.toggle('active', ps === n);\n p.classList.toggle('done', ps < n);\n });\n document.querySelectorAll('.wizard-line').forEach((line, i) => {\n line.classList.toggle('done', i < n - 1);\n });\n $('btn-step-back').style.display = n > 1 ? '' : 'none';\n $('btn-step-next').style.display = n < 3 ? '' : 'none';\n $('btn-connect').style.display = n === 3 ? '' : 'none';\n updateConnectSummary();\n // Reload the catalog whenever step 2 is entered so the list always\n // reflects the currently selected source (loadDevices branches on mode).\n if (n === 2) loadDevices();\n }\n\n function maybeHidePrereqProgress() {\n if (prereqsDoctorDone && prereqsAppiumDone) {\n const el = document.getElementById('prereq-progress');\n if (el) el.classList.add('done');\n }\n }\n\n // \u2500\u2500\u2500 App-file inspection (step 3) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function pickAppFile() {\n try {\n const r = await fetch('/api/file-picker', { method: 'POST' });\n const j = await r.json();\n if (j.ok && j.path) {\n $('cap-app').value = j.path;\n updateConnectSummary();\n inspectAppPath();\n } else if (j.cancelled) {\n // Silent cancel.\n } else if (j.error) {\n showToast(j.error, 'error', { title: 'Browse failed' });\n }\n } catch (err) {\n showToast(err.message, 'error', { title: 'Browse failed' });\n }\n }\n\n let inspectAppToken = 0;\n async function inspectAppPath() {\n const status = $('app-inspect-status');\n const path = $('cap-app').value.trim();\n if (!path) { status.textContent = ''; status.className = 'app-inspect-status'; return; }\n // Cloud / remote URLs aren't on the local filesystem \u2014 the cloud\n // session resolves them on its own; we skip parsing aapt/plutil\n // and just acknowledge the URL so the user sees positive feedback.\n if (/^(bs|lt|https?):\\/\\//i.test(path)) {\n const kind = path.toLowerCase().startsWith('bs://') ? 'BrowserStack URL'\n : path.toLowerCase().startsWith('lt://') ? 'LambdaTest URL'\n : 'remote URL';\n status.textContent = '\u2713 ' + kind + ' \u2014 bundle id will come from the cloud session.';\n status.className = 'app-inspect-status ok';\n return;\n }\n const token = ++inspectAppToken;\n status.innerHTML = '<span class=\"spinner\"></span>Inspecting ' + escapeHtml(path) + '\u2026';\n status.className = 'app-inspect-status busy';\n try {\n const r = await fetch('/api/inspect-app', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ path }),\n });\n if (token !== inspectAppToken) return;\n const j = await r.json();\n if (!r.ok || !j.ok) {\n status.textContent = '\u26A0 ' + (j.error || ('HTTP ' + r.status));\n status.className = 'app-inspect-status err';\n return;\n }\n // Auto-fill the bundle/package field.\n $('cap-bundle').value = j.bundleId;\n // For Android, also set platform (in case user pointed to .apk after picking iOS device).\n if (j.kind === 'apk') $('cap-platform').value = 'Android';\n else if (j.kind === 'ipa' || j.kind === 'app' || j.kind === 'app.zip') $('cap-platform').value = 'iOS';\n updateBundleLabel();\n // For Android with a launchable activity, set appium:appActivity as an extra.\n if (j.appActivity) {\n let foundRow = null;\n document.querySelectorAll('#extras-list .extra-cap').forEach((row) => {\n const k = row.querySelector('.extra-key').value.trim();\n if (k === 'appium:appActivity') foundRow = row;\n });\n if (foundRow) {\n foundRow.querySelector('.extra-val').value = j.appActivity;\n } else {\n addExtraRow({ key: 'appium:appActivity', value: j.appActivity }, false);\n }\n }\n const detail = j.bundleId + (j.appActivity ? ' \u00B7 ' + j.appActivity : '');\n status.textContent = '\u2713 ' + j.kind.toUpperCase() + ' \u00B7 ' + detail;\n status.className = 'app-inspect-status ok';\n updateConnectSummary();\n } catch (err) {\n if (token !== inspectAppToken) return;\n status.textContent = '\u26A0 ' + err.message;\n status.className = 'app-inspect-status err';\n }\n }\n\n function applyCapsToForm(caps) {\n const { form, extras } = splitCaps(caps);\n $('cap-platform').value = form.platform;\n $('cap-device').value = form.device;\n $('cap-version').value = form.version;\n $('cap-app').value = form.app;\n $('cap-bundle').value = form.bundle;\n $('cap-udid').value = form.udid;\n $('cap-noreset').checked = form.noReset;\n $('extras-list').innerHTML = '';\n for (const row of extras) addExtraRow(row, false);\n updateBundleLabel();\n updateConnectSummary();\n }\n\n /** Append a new key/value row to the extras list. */\n function addExtraRow(row, focus) {\n const list = $('extras-list');\n const div = document.createElement('div');\n div.className = 'extra-cap';\n div.innerHTML =\n '<input class=\"extra-key\" list=\"known-caps\" placeholder=\"key (e.g. appium:autoGrantPermissions)\" />' +\n '<input class=\"extra-val\" placeholder=\"value\" />' +\n '<button class=\"x-btn\" type=\"button\" title=\"Remove\">\u00D7</button>';\n const keyInp = div.querySelector('.extra-key');\n const valInp = div.querySelector('.extra-val');\n const rmBtn = div.querySelector('.x-btn');\n keyInp.value = row.key || '';\n valInp.value = row.value || '';\n keyInp.addEventListener('input', updateConnectSummary);\n valInp.addEventListener('input', updateConnectSummary);\n rmBtn.addEventListener('click', () => { div.remove(); updateConnectSummary(); });\n list.appendChild(div);\n if (focus) keyInp.focus();\n updateConnectSummary();\n }\n\n /** Remove local-emulator-only cap rows (appium:avd, \u2026) \u2014 wrong for cloud. */\n function stripLocalOnlyExtras() {\n var localOnly = ['appium:avd', 'appium:avdLaunchTimeout', 'appium:avdReadyTimeout'];\n var rows = document.querySelectorAll('#extras-list .extra-cap');\n rows.forEach(function (div) {\n var k = String(div.querySelector('.extra-key').value || '').trim();\n if (localOnly.indexOf(k) !== -1) div.remove();\n });\n updateConnectSummary();\n }\n\n /** Read all extras rows into an array of {key, value} (skips empty keys). */\n function readExtras() {\n const out = [];\n const rows = document.querySelectorAll('#extras-list .extra-cap');\n rows.forEach((div) => {\n const k = div.querySelector('.extra-key').value;\n const v = div.querySelector('.extra-val').value;\n if (String(k).trim()) out.push({ key: k.trim(), value: v });\n });\n return out;\n }\n\n function readFormCaps() {\n return {\n platform: $('cap-platform').value || 'Android',\n device: $('cap-device').value.trim(),\n version: $('cap-version').value.trim(),\n app: $('cap-app').value.trim(),\n bundle: $('cap-bundle').value.trim(),\n udid: $('cap-udid').value.trim(),\n noReset: $('cap-noreset').checked,\n };\n }\n\n // Infer the platform a local app path implies. 'Android' for .apk,\n // 'iOS' for .app/.ipa, or null for unknown / remote (bs:// lt:// http)\n // URLs \u2014 null means \"don't infer, don't clear\".\n function appPlatformFromPath(path) {\n const p = (path || '').trim().toLowerCase();\n if (p.endsWith('.apk')) return 'Android';\n if (p.endsWith('.app') || p.endsWith('.ipa')) return 'iOS';\n return null;\n }\n\n // When the chosen platform no longer matches the local app already in\n // the form, that app/bundle can't install or launch on the new\n // platform (the .apk-on-iOS \"returned nil\" crash). Clear them so the\n // user picks the right app (Browse re-detects the bundle id). Only\n // fires on a KNOWN-extension mismatch \u2014 a valid same-platform app or a\n // remote/cloud URL is left untouched.\n function clearAppIfPlatformMismatch(newPlatform) {\n const ap = appPlatformFromPath($('cap-app').value);\n if (!ap || ap === newPlatform) return;\n $('cap-app').value = '';\n $('cap-bundle').value = '';\n const s = $('app-inspect-status');\n s.textContent = '';\n s.className = 'app-inspect-status';\n // Leaving Android: drop the Android-only appium:appActivity extra\n // that inspectAppPath auto-adds (meaningless off Android, would be a\n // bogus iOS cap). Accepted edge: if the user manually emptied cap-app\n // first, the early-return above leaves that extra \u2014 a benign unknown\n // cap, far less harmful than a wrong app path.\n if (ap === 'Android') {\n document.querySelectorAll('#extras-list .extra-cap').forEach((row) => {\n const k = row.querySelector('.extra-key');\n if (k && k.value.trim() === 'appium:appActivity') row.remove();\n });\n }\n updateConnectSummary();\n }\n\n function updateBundleLabel() {\n const platform = $('cap-platform').value;\n $('cap-bundle-label').textContent = platform === 'iOS' ? 'Bundle ID' : 'Package';\n }\n\n /**\n * Step-aware footer: tells the user what they need to do next, and gates\n * the \"Next \u2192\" button when prerequisites for the current step aren't met.\n */\n function updateConnectSummary() {\n const summary = $('connect-summary');\n const nextBtn = $('btn-step-next');\n if (wizardStep === 1) {\n if (isCloudMode()) {\n const provLabel = connectionMode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n if (cloudCredsValid) {\n summary.innerHTML = '<strong>' + provLabel + ' creds set</strong> \u2014 continue to pick a device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML = 'Enter your <strong>' + provLabel + '</strong> username + access key to continue.';\n nextBtn.disabled = true;\n }\n } else {\n const reachable = $('appium-pill').classList.contains('live');\n if (reachable) {\n summary.innerHTML = '<strong>Appium reachable</strong> \u2014 continue to pick a device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML =\n 'Start the Appium server before continuing. Use <strong>Start Appium</strong> above.';\n nextBtn.disabled = true;\n }\n }\n return;\n }\n if (wizardStep === 2) {\n // Gate on the real selection (a tapped, booted device), not the pre-filled\n // cap-device value \u2014 otherwise Next is enabled before any live device is picked.\n if (selectedDeviceKey !== null) {\n const sel = $('cap-device').value.trim();\n summary.innerHTML =\n 'Selected <strong>' + escapeHtml(sel) + '</strong> \u2014 click <strong>Next</strong> or pick another device.';\n nextBtn.disabled = false;\n } else {\n summary.innerHTML = isCloudMode()\n ? 'Pick a cloud device by tapping its tile to continue.'\n : 'Pick a booted device by tapping its tile to continue.';\n nextBtn.disabled = true;\n }\n return;\n }\n // Step 3 \u2014 full connect summary, drives the Connect button label.\n const f = readFormCaps();\n const auto = f.platform === 'iOS' ? 'XCUITest' : 'UiAutomator2';\n const dev = f.device ? ' \u00B7 <strong>' + escapeHtml(f.device) + '</strong>' : '';\n if (isCloudMode()) {\n const provLabel = connectionMode === 'browserstack' ? 'BrowserStack' : 'LambdaTest';\n summary.innerHTML =\n 'Connect to <strong>' + provLabel + '</strong> \u00B7 <strong>' + f.platform + '</strong> \u00B7 ' + auto + dev;\n } else {\n const a = readAppiumForm();\n summary.innerHTML =\n 'Connect to <strong>' + escapeHtml(a.host) + ':' + a.port + '</strong>' +\n ' \u00B7 <strong>' + f.platform + '</strong> \u00B7 ' + auto + dev;\n }\n }\n\n function toggleDoctorList() {\n const list = $('doctor-list');\n const open = list.classList.toggle('expanded');\n $('doctor-twisty').textContent = open ? '\u25B4' : '\u25BE';\n }\n\n async function loadDoctor() {\n try {\n const r = await fetch('/api/doctor');\n const { checks } = await r.json();\n const total = checks.length;\n const oks = checks.filter((c) => c.status === 'ok').length;\n const errs = checks.filter((c) => c.status === 'error').length;\n const warns = checks.filter((c) => c.status === 'warn').length;\n const pill = $('doctor-summary-pill');\n const label = $('doctor-summary-label');\n if (errs === 0 && warns === 0) {\n pill.className = 'pill live';\n label.textContent = 'all ' + total + ' checks passed';\n } else if (errs === 0) {\n pill.className = 'pill down';\n label.textContent = warns + ' warning' + (warns === 1 ? '' : 's') + ' \u00B7 ' + oks + '/' + total + ' ok';\n } else {\n pill.className = 'pill down';\n label.textContent = errs + ' error' + (errs === 1 ? '' : 's') + ' \u00B7 ' + oks + '/' + total + ' ok';\n }\n $('doctor-list').innerHTML = checks.map((c) => {\n const sym = c.status === 'ok' ? '\u2713' : c.status === 'warn' ? '!' : '\u2717';\n // OK rows show their short value inline-right; warn/error details (often\n // long paths/commands) drop to a full-width wrapping line below the name.\n const inline = c.status === 'ok' && c.detail\n ? '<span class=\"detail\">' + escapeHtml(c.detail) + '</span>' : '';\n const block = c.status !== 'ok' && c.detail\n ? '<div class=\"detail-block\">' + escapeHtml(c.detail) + '</div>' : '';\n return '<li>' +\n '<div class=\"doctor-row\">' +\n '<span class=\"ico ' + c.status + '\">' + sym + '</span>' +\n '<span class=\"name\">' + escapeHtml(c.name) + '</span>' +\n inline +\n '</div>' + block +\n '</li>';\n }).join('');\n // Auto-expand if anything failed.\n if (errs > 0 || warns > 0) {\n $('doctor-list').classList.add('expanded');\n $('doctor-twisty').textContent = '\u25B4';\n }\n } catch (err) {\n $('doctor-summary-label').textContent = 'doctor failed: ' + err.message;\n } finally {\n prereqsDoctorDone = true;\n maybeHidePrereqProgress();\n }\n }\n\n function readAppiumForm() {\n return {\n host: $('appium-host').value.trim() || 'localhost',\n port: Number($('appium-port').value) || 4723,\n path: $('appium-path').value.trim() || '/',\n };\n }\n\n // \u2500\u2500\u2500 Devices card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const DEVICE_PAGE_SIZE = 8;\n let deviceTab = 'android'; // active tab\n let devicePage = { android: 0, ios: 0 }; // 0-based page per tab\n let lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n\n /** Pull the current device list from the server and re-render. */\n async function loadDevices() {\n const refreshBtn = $('btn-devices-refresh');\n refreshBtn.disabled = true;\n // Show a loading placeholder synchronously so switching device source (or a\n // slow cloud fetch) never flashes the previously rendered device list.\n $('devices-warn').innerHTML = '';\n $('device-pagination').innerHTML = '';\n $('device-count-android').textContent = '\u2026';\n $('device-count-ios').textContent = '\u2026';\n $('device-grid').innerHTML =\n '<div class=\"device-empty\"><span class=\"rec-sel-spinner\"></span>Loading devices\u2026</div>';\n try {\n if (isCloudMode()) {\n const u = ($('cloud-user').value || '').trim();\n const k = ($('cloud-key').value || '').trim();\n if (!u || !k) {\n lastDeviceData = { android: [], ios: [], toolsMissing: {} };\n $('devices-warn').innerHTML =\n '<div class=\"device-warn\">Cloud creds missing \u2014 go back to step 1.</div>';\n renderDevices();\n return;\n }\n const r = await fetch('/api/cloud/devices', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ provider: connectionMode, user: u, key: k }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n // Convert cloud catalog \u2192 same shape as local devices, state='booted'\n // so the existing tile UI treats them as ready.\n const android = [];\n const ios = [];\n for (const d of j.devices) {\n const synthUdid = connectionMode + ':' + d.platform + ':' + d.deviceName + ':' + d.osVersion;\n const dev = {\n type: d.platform,\n udid: synthUdid,\n name: d.deviceName,\n osVersion: d.osVersion,\n state: 'booted',\n cloud: { provider: connectionMode, realDevice: !!d.realDevice },\n };\n (d.platform === 'ios' ? ios : android).push(dev);\n }\n lastDeviceData = { android, ios, toolsMissing: {} };\n if (android.length === 0 && ios.length > 0) deviceTab = 'ios';\n renderDevices();\n } else {\n const r = await fetch('/api/devices');\n const data = await r.json();\n lastDeviceData = data;\n if (data.android.length === 0 && data.ios.length > 0) deviceTab = 'ios';\n renderDevices();\n }\n } catch (err) {\n $('device-grid').innerHTML = '';\n $('devices-warn').innerHTML =\n '<div class=\"device-warn\">Failed to load devices: ' + escapeHtml(err.message) + '</div>';\n } finally {\n refreshBtn.disabled = false;\n }\n }\n\n function renderDevices() {\n const data = lastDeviceData;\n\n // Drop a stale selection: if the selected device is no longer booted (e.g.\n // it was stopped, or shut down between polls), clear it so Next disables \u2014\n // a selection must always point at a currently-live device.\n if (selectedDeviceKey !== null) {\n const all = [...(data.android || []), ...(data.ios || [])];\n const stillLive = all.some((d) => d.state === 'booted' && bootingKey(d) === selectedDeviceKey);\n if (!stillLive) {\n selectedDeviceKey = null;\n selectedCloudDevice = null;\n $('cap-device').value = '';\n updateConnectSummary();\n }\n }\n\n // Tool-missing warnings.\n const warns = [];\n if (data.toolsMissing?.adb) warns.push(\"adb not on PATH \u2014 Android emulators won't show.\");\n if (data.toolsMissing?.emulator) warns.push(\"emulator not on PATH \u2014 Android AVDs won't show (install Android command-line tools).\");\n if (data.toolsMissing?.xcrun) warns.push(\"xcrun not on PATH \u2014 iOS simulators won't show (Xcode required).\");\n const warnHtml = warns.map((w) => '<div class=\"device-warn\">' + escapeHtml(w) + '</div>').join('');\n // AVDs whose system image is installed in no SDK are shown but flagged\n // unbootable per-tile (see renderTile) rather than hidden here.\n $('devices-warn').innerHTML = warnHtml;\n\n // Update tab counts and active class.\n $('device-count-android').textContent = String(data.android.length);\n $('device-count-ios').textContent = String(data.ios.length);\n document.querySelectorAll('.device-tab').forEach((t) => {\n t.classList.toggle('active', t.dataset.deviceTab === deviceTab);\n });\n // On platforms without xcrun the iOS tab is meaningless \u2014 hide it entirely.\n document.querySelectorAll('.device-tab[data-device-tab=\"ios\"]').forEach((t) => {\n t.style.display = data.toolsMissing?.xcrun ? 'none' : '';\n });\n\n // Render the active tab's slice.\n const list = deviceTab === 'android' ? data.android : data.ios;\n const totalPages = Math.max(1, Math.ceil(list.length / DEVICE_PAGE_SIZE));\n if (devicePage[deviceTab] >= totalPages) devicePage[deviceTab] = totalPages - 1;\n const page = devicePage[deviceTab];\n const slice = list.slice(page * DEVICE_PAGE_SIZE, (page + 1) * DEVICE_PAGE_SIZE);\n\n if (list.length === 0) {\n const what = deviceTab === 'android' ? 'Android emulators' : 'iOS simulators';\n $('device-grid').innerHTML = '<div class=\"device-empty\">No ' + what + ' found.</div>';\n } else {\n $('device-grid').innerHTML = slice.map((dev, i) => renderTile(dev, page * DEVICE_PAGE_SIZE + i)).join('');\n }\n\n // Pagination controls (only shown when there's more than one page).\n if (list.length > DEVICE_PAGE_SIZE) {\n $('device-pagination').innerHTML =\n '<button class=\"icon\" id=\"btn-dev-prev\"' + (page === 0 ? ' disabled' : '') + ' type=\"button\">\u2190 Prev</button>' +\n '<span class=\"info\">Page ' + (page + 1) + ' of ' + totalPages + ' \u00B7 ' + list.length + ' total</span>' +\n '<button class=\"icon\" id=\"btn-dev-next\"' + (page === totalPages - 1 ? ' disabled' : '') + ' type=\"button\">Next \u2192</button>';\n const prev = document.getElementById('btn-dev-prev');\n const next = document.getElementById('btn-dev-next');\n if (prev) prev.onclick = () => { devicePage[deviceTab] = Math.max(0, page - 1); renderDevices(); };\n if (next) next.onclick = () => { devicePage[deviceTab] = Math.min(totalPages - 1, page + 1); renderDevices(); };\n } else {\n $('device-pagination').innerHTML = '';\n }\n\n // Wire per-tile buttons + click-to-select (only for the visible slice).\n document.querySelectorAll('#device-grid .device-tile').forEach((tile) => {\n const idx = Number(tile.dataset.idx);\n const dev = list[idx];\n if (!dev) return;\n const startBtn = tile.querySelector('[data-act=\"start\"]');\n const stopBtn = tile.querySelector('[data-act=\"stop\"]');\n // Action buttons stop event bubbling so a Stop click doesn't\n // re-select the device tile underneath.\n if (startBtn) {\n startBtn.onclick = (e) => { e.stopPropagation(); startDevice(dev); };\n }\n if (stopBtn) {\n stopBtn.onclick = (e) => { e.stopPropagation(); stopDevice(dev); };\n }\n // The tile itself selects when booted. Hover affordance + cursor\n // come from the .selectable class added in renderTile.\n if (dev.state === 'booted') {\n tile.onclick = () => selectDevice(dev);\n }\n });\n }\n\n // Tab switching.\n document.querySelectorAll('.device-tab').forEach((t) => {\n t.onclick = () => {\n deviceTab = t.dataset.deviceTab;\n renderDevices();\n };\n });\n\n // Devices we have asked to boot but haven't yet seen 'booted' for. Keyed\n // by AVD name (Android) or UDID (iOS) since the serial of an Android\n // emulator only exists once it comes online.\n const bootingDevices = new Set();\n function bootingKey(dev) {\n return dev.type === 'android'\n ? 'android:' + (dev.avdName || dev.name)\n : 'ios:' + dev.udid;\n }\n\n // The single device the user has tapped to drive the session. Cross-tab \u2014\n // selecting an iOS sim clears any prior Android selection and vice versa.\n let selectedDeviceKey = null;\n function isSelected(dev) {\n return selectedDeviceKey === bootingKey(dev);\n }\n\n function renderTile(dev, idx) {\n const isCloud = !!dev.cloud;\n const isBooting = bootingDevices.has(bootingKey(dev)) || dev.state === 'booting';\n const isBooted = dev.state === 'booted';\n // Shutdown AVD whose system image is in no SDK \u2014 cannot boot (Start disabled).\n const unbootable = !isCloud && dev.bootable === false && !isBooted && !isBooting;\n const selected = isBooted && isSelected(dev);\n const stateLabel = isCloud\n ? (dev.cloud.realDevice ? 'cloud \u00B7 real' : 'cloud \u00B7 sim')\n : isBooting ? 'booting\u2026'\n : isBooted ? 'live'\n : unbootable ? 'image missing'\n : 'shutdown';\n const stateClass = isCloud\n ? 'pill live'\n : isBooting ? 'pill booting'\n : isBooted ? 'pill live'\n : 'pill down';\n const stateIcon = isBooting\n ? '<span class=\"spinner\"></span>'\n : '<span class=\"led\"></span>';\n const baseMeta = isCloud\n ? (dev.osVersion + (dev.type === 'ios' ? ' \u00B7 iOS' : ' \u00B7 Android'))\n : ([dev.osVersion, dev.avdName].filter(Boolean).join(' \u00B7 ') || '\u2014');\n const meta = unbootable && dev.bootHint ? baseMeta + ' \u00B7 ' + dev.bootHint : baseMeta;\n const showUdid = !isCloud && isBooted;\n let actions = '';\n if (isCloud) {\n // No start/stop on cloud \u2014 the device is always available, the\n // session opens on Connect (step 3). The whole tile is the Use button.\n } else if (isBooting) {\n actions += '<button class=\"icon\" disabled>booting\u2026</button>';\n } else if (unbootable) {\n actions += '<button class=\"icon\" disabled title=\"' + escapeHtml(dev.bootHint || '') +\n '\">image missing</button>';\n } else if (dev.state === 'shutdown') {\n actions += '<button class=\"icon\" data-act=\"start\">\u25B6 Start</button>';\n } else if (isBooted) {\n actions += '<button class=\"icon\" data-act=\"stop\">\u25A0 Stop</button>';\n } else {\n actions += '<button class=\"icon\" disabled>' + escapeHtml(stateLabel) + '</button>';\n }\n const tileClasses = ['device-tile'];\n if (isBooting) tileClasses.push('booting');\n else if (isBooted) tileClasses.push('booted', 'selectable');\n if (selected) tileClasses.push('selected');\n return (\n '<div class=\"' + tileClasses.join(' ') + '\" ' +\n 'data-idx=\"' + idx + '\" data-kind=\"' + dev.type + '\">' +\n '<div class=\"check\">\u2713</div>' +\n '<div class=\"top\">' +\n '<span class=\"icon\">\uD83D\uDCF1</span>' +\n '<span class=\"name\">' + escapeHtml(dev.name) + '</span>' +\n '<span class=\"' + stateClass + '\">' + stateIcon + '<span>' +\n escapeHtml(stateLabel) + '</span></span>' +\n '</div>' +\n '<div class=\"meta\">' + escapeHtml(meta) + '</div>' +\n (showUdid ? '<div class=\"udid\">' + escapeHtml(dev.udid) + '</div>' : '') +\n (actions ? '<div class=\"actions\">' + actions + '</div>' : '') +\n '</div>'\n );\n }\n\n async function startDevice(dev) {\n const key = bootingKey(dev);\n setStatus('booting ' + dev.name + '\u2026', true);\n bootingDevices.add(key);\n renderDevices();\n try {\n const body = dev.type === 'android'\n ? { type: 'android', avdName: dev.avdName ?? dev.name }\n : { type: 'ios', udid: dev.udid };\n const r = await fetch('/api/devices/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n showToast(\"Booting \" + dev.name + \". This usually takes 20\u201360 s.\",\n 'info', { title: 'Device starting' });\n pollUntilBooted(dev);\n } catch (err) {\n bootingDevices.delete(key);\n renderDevices();\n showToast(err.message, 'error', { title: 'Failed to start' });\n } finally {\n setStatus('idle');\n }\n }\n\n /**\n * Refresh /api/devices on a 3 s cadence until the named device shows up\n * as 'booted' (or we hit the 90 s deadline). Keeps the spinner on the\n * tile up to date the whole way through.\n */\n function pollUntilBooted(dev) {\n const key = bootingKey(dev);\n const deadline = Date.now() + 90_000;\n const tick = async () => {\n if (!bootingDevices.has(key)) return;\n if (Date.now() > deadline) {\n bootingDevices.delete(key);\n renderDevices();\n showToast(\n dev.name + \" didn't finish booting within 90 s \u2014 click Refresh to recheck.\",\n 'error', { title: 'Boot timeout' });\n return;\n }\n try {\n const r = await fetch('/api/devices');\n const data = await r.json();\n lastDeviceData = data;\n const list = dev.type === 'android' ? data.android : data.ios;\n const found = list.find((d) => bootingKey(d) === key);\n if (found && found.state === 'booted') {\n bootingDevices.delete(key);\n // Auto-select the device the user just started \u2014 no manual click needed.\n // selectDevice() also re-renders (\u2713) and enables Next (gated on selection).\n selectDevice(found);\n showToast(dev.name + ' is up and ready.', 'success', { title: 'Device booted' });\n return;\n }\n renderDevices();\n } catch { /* network blip \u2014 try again */ }\n setTimeout(tick, 3000);\n };\n setTimeout(tick, 3000);\n }\n\n async function stopDevice(dev) {\n setStatus('stopping ' + dev.name + '\u2026', true);\n try {\n const r = await fetch('/api/devices/stop', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ type: dev.type, udid: dev.udid }),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n showToast(dev.name + ' is shutting down.', 'success', { title: 'Stopped' });\n setTimeout(loadDevices, 2000);\n } catch (err) {\n showToast(err.message, 'error', { title: 'Failed to stop' });\n } finally {\n setStatus('idle');\n }\n }\n\n /**\n * Tap on a tile = select that device. Cross-tab single-selection: tapping\n * an iOS sim clears any prior Android selection and vice versa. The user\n * then clicks Next to advance to step 3 (no auto-advance \u2014 they get to\n * see the checkmark first and re-select if needed).\n */\n // Captured when a cloud device tile is selected \u2014 used at connect time\n // to build the right cloud capabilities. null when the active selection\n // is a local emulator/sim.\n let selectedCloudDevice = null;\n\n function selectDevice(dev) {\n if (dev.state !== 'booted') return;\n selectedDeviceKey = bootingKey(dev);\n $('cap-platform').value = dev.type === 'ios' ? 'iOS' : 'Android';\n $('cap-device').value = dev.name;\n $('cap-version').value = dev.osVersion ?? '';\n if (dev.cloud) {\n selectedCloudDevice = {\n provider: dev.cloud.provider,\n platform: dev.type,\n deviceName: dev.name,\n osVersion: dev.osVersion ?? '',\n };\n $('cap-udid').value = '';\n // Cloud picks the device by name + version \u2014 drop any local-emulator-only\n // caps (appium:avd, \u2026) that were seeded from the local config.\n stripLocalOnlyExtras();\n } else {\n selectedCloudDevice = null;\n $('cap-udid').value = dev.udid;\n }\n clearAppIfPlatformMismatch($('cap-platform').value);\n updateBundleLabel();\n updateConnectSummary();\n renderDevices();\n }\n\n // While a (blocking) start/restart request is in flight, show a spinner pill\n // with a live elapsed-seconds counter so a slow boot doesn't look frozen.\n let appiumStartTimer = null;\n function setAppiumStarting(label) {\n $('appium-pill').className = 'pill booting';\n $('appium-pill-label').textContent = label + '\u2026 0s';\n $('appium-start-hint').style.display = '';\n $('btn-appium-recheck').disabled = true;\n $('btn-appium-restart').disabled = true;\n $('btn-appium-start').disabled = true;\n let secs = 0;\n if (appiumStartTimer) clearInterval(appiumStartTimer);\n appiumStartTimer = setInterval(() => {\n secs += 1;\n $('appium-pill-label').textContent = label + '\u2026 ' + secs + 's';\n }, 1000);\n }\n function clearAppiumStarting() {\n if (appiumStartTimer) { clearInterval(appiumStartTimer); appiumStartTimer = null; }\n $('appium-start-hint').style.display = 'none';\n $('btn-appium-recheck').disabled = false;\n $('btn-appium-restart').disabled = false;\n // btn-appium-start re-enable is decided by refreshAppiumPill (reachable?)\n }\n\n async function refreshAppiumPill() {\n const opts = readAppiumForm();\n const pill = $('appium-pill');\n const label = $('appium-pill-label');\n pill.className = 'pill down';\n label.textContent = 'checking\u2026';\n try {\n const r = await fetch('/api/status');\n const j = await r.json();\n // /api/status reports the server-side default; compare against the\n // form values to decide whether to trust it.\n const reachable = j.appiumReachable && j.appium.host === opts.host && j.appium.port === opts.port;\n if (reachable) {\n pill.className = 'pill live';\n label.textContent = 'reachable on ' + opts.host + ':' + opts.port +\n (j.appiumOurs ? ' (started by inspector)' : '');\n $('btn-appium-start').disabled = true;\n $('btn-appium-start').textContent = 'Already running';\n } else {\n // Probe directly via /api/appium/start with no spawn? Server doesn't\n // expose a probe-only endpoint. Best-effort: tell the server which\n // host:port we want, then re-query status. We do that via a Recheck\n // pre-step that sends the form values to the server.\n const probeR = await fetch('/api/appium/probe?host=' + encodeURIComponent(opts.host) +\n '&port=' + opts.port + '&path=' + encodeURIComponent(opts.path));\n if (probeR.ok) {\n const pj = await probeR.json();\n if (pj.reachable) {\n pill.className = 'pill live';\n label.textContent = 'reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = true;\n $('btn-appium-start').textContent = 'Already running';\n return;\n }\n }\n pill.className = 'pill down';\n label.textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n }\n } catch (err) {\n pill.className = 'pill down';\n label.textContent = 'check failed';\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n } finally {\n prereqsAppiumDone = true;\n maybeHidePrereqProgress();\n updateConnectSummary();\n }\n }\n\n async function startAppium() {\n const opts = readAppiumForm();\n $('btn-appium-start').textContent = 'Starting\u2026';\n setAppiumStarting('starting Appium');\n try {\n const r = await fetch('/api/appium/start', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(opts),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearAppiumStarting();\n await refreshAppiumPill();\n showToast('Appium server is running on ' + opts.host + ':' + opts.port, 'success', { title: 'Appium started' });\n } catch (e) {\n clearAppiumStarting();\n $('appium-pill').className = 'pill down';\n $('appium-pill-label').textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n $('btn-appium-start').disabled = false;\n $('btn-appium-start').textContent = 'Start Appium';\n showToast(e.message, 'error', { title: 'Failed to start Appium' });\n }\n }\n\n async function restartAppium() {\n const opts = readAppiumForm();\n const btn = $('btn-appium-restart');\n btn.textContent = 'Restarting\u2026';\n setAppiumStarting('restarting Appium');\n try {\n const r = await fetch('/api/appium/restart', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(opts),\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearAppiumStarting();\n await refreshAppiumPill();\n showToast('Appium restarted on ' + opts.host + ':' + opts.port, 'success',\n { title: 'Appium restarted' });\n } catch (e) {\n clearAppiumStarting();\n $('appium-pill').className = 'pill down';\n $('appium-pill-label').textContent = 'not reachable on ' + opts.host + ':' + opts.port;\n showToast(e.message, 'error', { title: 'Failed to restart Appium' });\n } finally {\n btn.textContent = 'Restart Appium';\n }\n }\n\n\n async function doConnect() {\n const form = readFormCaps();\n const extras = readExtras();\n let body;\n if (isCloudMode()) {\n // Cloud: hand off the typed shape; server reuses the same provider\n // class the test runner does (see src/providers/index.ts).\n const extraCaps = {};\n for (const row of extras || []) {\n const k = String(row.key || '').trim();\n if (k) extraCaps[k] = row.value;\n }\n body = {\n cloud: {\n provider: connectionMode,\n user: ($('cloud-user').value || '').trim(),\n key: ($('cloud-key').value || '').trim(),\n platform: form.platform === 'iOS' ? 'ios' : 'android',\n deviceName: form.device,\n osVersion: form.version,\n appUrl: form.app,\n appBundleId: form.bundle,\n capabilities: extraCaps,\n projectName: 'taqwright-inspector',\n },\n };\n } else {\n body = { appium: readAppiumForm(), capabilities: buildCaps(form, extras) };\n }\n\n $('btn-connect').disabled = true;\n $('btn-connect').textContent = 'Connecting\u2026';\n const targetLabel = isCloudMode()\n ? (connectionMode === 'browserstack' ? 'BrowserStack hub' : 'LambdaTest hub')\n : (body.appium.host + ':' + body.appium.port);\n // Let the user abort a slow connect. Aborting the fetch stops the client\n // waiting; the /api/connect/cancel POST tells the server to tear down any\n // session that still materializes (so it doesn't leak as \"Running\").\n const controller = new AbortController();\n let cancelled = false;\n showLoader(\n 'Connecting to ' + targetLabel,\n 'Opening a WebDriver session. Cloud sessions can take 30\u201390 s while the device is provisioned.',\n () => {\n cancelled = true;\n controller.abort();\n fetch('/api/connect/cancel', { method: 'POST' }).catch(() => {});\n },\n );\n try {\n const r = await fetch('/api/connect', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body), signal: controller.signal,\n });\n const j = await r.json();\n if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));\n clearToasts();\n showLoader('Loading device screen\u2026',\n 'Capturing the screenshot and UI hierarchy from the device.');\n showView('inspector');\n await fetchSnapshot();\n startAutoRefresh();\n hideLoader();\n onInspectorReady();\n } catch (e) {\n hideLoader();\n // User-initiated cancel \u2014 return to setup quietly, no error toast.\n if (!cancelled && e.name !== 'AbortError') {\n showToast(e.message, 'error', { title: 'Connect failed' });\n }\n } finally {\n $('btn-connect').disabled = false;\n $('btn-connect').textContent = 'Connect \u2192';\n }\n }\n\n // Disconnect handler (shown only when connected).\n $('btn-disconnect').onclick = async () => {\n const ok = await confirmModal({\n title: 'Disconnect session?',\n message: 'This ends the current device session and returns you to setup.',\n confirmLabel: 'Disconnect',\n icon: '\uD83D\uDD0C',\n danger: true,\n });\n if (!ok) return;\n stopAutoRefresh();\n applyRecordingState(false);\n stickyRelative = null;\n setStatus('disconnecting\u2026', true);\n try {\n await fetch('/api/disconnect', { method: 'POST' });\n } catch {}\n state.selected = null;\n state.nodeMap.clear();\n showView('setup');\n await bootstrap();\n };\n\n // \u2500\u2500\u2500 Guided tour + Help panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Spotlight coach-marks over the real controls + a static Help reference.\n // (No backticks anywhere in this block \u2014 the whole file is a template literal.)\n // Click a real inspector tab (used by the live inspector tour).\n function tourClickTab(name) {\n const t = document.querySelector('.tab[data-tab=\"' + name + '\"]');\n if (t) t.click();\n }\n // Live tour only: the Locators / Attributes panels are empty until an element\n // is selected, so the tour would spotlight a blank panel. If the user hasn't\n // selected anything yet, auto-select a representative node (one with an id /\n // text / content-desc, else the first node) so those steps show real content.\n function tourEnsureSelection() {\n if (state.selected) return;\n let pick = null;\n for (const [, el] of state.nodeMap) {\n if (!el || !el.getAttribute) continue;\n if (\n el.getAttribute('resource-id') ||\n el.getAttribute('text') ||\n el.getAttribute('content-desc') ||\n el.getAttribute('name') ||\n el.getAttribute('label')\n ) {\n pick = el;\n break;\n }\n }\n if (!pick) {\n for (const [, el] of state.nodeMap) {\n pick = el;\n break;\n }\n }\n if (pick) selectElement(pick);\n }\n // Switch the demo stage's mock right-hand tab (Record / Script / Locators / Attributes).\n function showDemoTab(name) {\n ['rec', 'script', 'loc', 'attrs'].forEach((k) => {\n const pane = $('demo-' + k);\n if (pane) pane.classList.toggle('hidden', k !== name);\n });\n document.querySelectorAll('#demo-tabs .tab').forEach((t) => {\n t.classList.toggle('active', t.getAttribute('data-demo-tab') === name);\n });\n }\n const SETUP_TOUR = [\n { sel: null, title: 'Welcome to codegen',\n body: 'This quick tour shows how to <b>connect a device</b>, <b>record</b> your actions, and <b>export</b> a runnable test.<br>Use Next / Back or the \u2190 \u2192 keys; press Esc to skip.' },\n { sel: '.conn-mode-toggle', title: 'Local or cloud',\n body: 'Choose <b>Local</b> for an emulator / simulator or USB device on this machine, or <b>Cloud</b> for BrowserStack / LambdaTest.' },\n { sel: '.card-env', before: function () { goToStep(1); }, title: 'Step 1 \u2014 Prerequisites',\n body: 'The <b>Environment</b> card runs a health check (adb, JDK, Android SDK, Appium drivers). Expand it to see any warnings.' },\n { sel: '.card-appium', before: function () { goToStep(1); }, title: 'Appium server',\n body: 'codegen talks to a local <b>Appium</b> server. If the pill is grey, click <b>Start Appium</b>; Next unlocks once it is green. (Cloud mode shows credentials here instead.)' },\n { sel: '#btn-devices-refresh', before: function () { goToStep(2); }, title: 'Step 2 \u2014 Pick a device',\n body: 'Switch the <b>Android / iOS</b> tabs and <b>\u21BB Refresh</b> the list. <b>Start</b> a shutdown emulator, or pick a running one / a cloud device.' },\n { sel: '#btn-app-browse', before: function () { goToStep(3); }, title: 'Step 3 \u2014 App & capabilities',\n body: 'Point at the app under test with <b>Browse\u2026</b>, then tweak or <b>+ Add</b> Appium capabilities.' },\n { sel: '#btn-connect', before: function () { goToStep(3); }, title: 'Connect',\n body: 'Hit <b>Connect \u2192</b> to open the session and enter the inspector.' },\n { sel: null, title: 'You are set',\n body: 'Connect to start inspecting and recording. You can reopen this tour any time with <b>? Help</b> in the header.' },\n ];\n // LIVE inspector tour \u2014 spotlights the REAL panes (used when connected).\n const INSPECTOR_TOUR_LIVE = [\n { sel: null, title: 'The inspector',\n body: 'You are connected. This is where you inspect the UI, drive the device, and record a test.' },\n { sel: '.hier-mode-toggle', title: 'Hierarchy',\n body: 'Browse the UI tree as <b>Tree</b> or raw <b>XML</b>, and filter with the search box. Clicking a node selects it and highlights it on the screen \u2014 handy for small or overlapping elements.' },\n { sel: '#screen-host', title: 'Live screen',\n body: 'A live mirror of the device. <b>Click any element</b> to <b>select</b> it \u2014 then inspect its Attributes / Locators or record an action on it. (See the <b>\u24D8 How to use</b> button above for more.)' },\n { sel: '.tabs', title: 'The four panels',\n body: '<b>Record</b> (capture actions), <b>Recorded script</b> (your test), <b>Locators</b> (ranked selectors), and <b>Attributes</b> for the selected element.' },\n { sel: '#btn-rec-toggle', before: function () { tourClickTab('record'); }, title: 'Record',\n body: 'Press <b>Start record</b>, select an element, then choose an action \u2014 Click, Type, Clear, gestures\u2026 The <b>Actions / Screen / Assertions</b> sub-tabs switch what you capture. Each step is appended live.' },\n { sel: '#tab-script', before: function () { tourClickTab('script'); }, title: 'Recorded script',\n body: 'Your test in <b>Taqwright</b> (runnable), or <b>Python</b> / <b>Java</b> (steps only). Use <b>\u2398 Copy</b>, <b>\u2193 Export</b> (saves into your tests folder), or Clear.' },\n { sel: '#tab-locators', before: function () { tourEnsureSelection(); tourClickTab('locators'); }, title: 'Locators',\n body: 'Ranked, uniqueness-verified selectors for the selected element \u2014 id, accessibility id, UIAutomator / NSPredicate / Class Chain, xpath. The <b>recommended</b> pick is on top; click any to copy.' },\n { sel: '#tab-attrs', before: function () { tourEnsureSelection(); tourClickTab('attrs'); }, title: 'Attributes',\n body: 'The selected element\\'s full attribute set (resource-id, class, text, content-desc, bounds\u2026) plus its xpath.' },\n { sel: '#btn-disconnect', before: function () { tourClickTab('record'); }, title: 'Done',\n body: 'When finished, <b>Disconnect</b> ends the session and returns to setup. Reopen this tour any time with <b>? Help</b>.' },\n ];\n // DEMO inspector tour \u2014 targets the mock #demo-stage (a Taqelah-demo login\n // screen) so the walkthrough has a realistic device to point at when NOT connected.\n const INSPECTOR_TOUR_DEMO = [\n { sel: null, title: 'The inspector (example)',\n body: 'This is a <b>demo</b> of the inspector using the Taqelah sample login screen \u2014 so you can see the layout before connecting a real device.' },\n { sel: '#demo-hier', before: function () { showDemoTab('rec'); }, title: 'Hierarchy',\n body: 'The UI element tree (this is a Jetpack Compose app, so nodes are <b>EditText</b> / <b>android.view.View</b>). Toggle <b>Tree</b> / raw <b>XML</b> and filter with the search box. Clicking a node selects it and highlights it on the screen.' },\n { sel: '#demo-screen', title: 'Live screen',\n body: 'A live mirror of the device \u2014 the Taqelah demo login. <b>Click any element</b> (here the <b>Username</b> field) to <b>select</b> it, then inspect its Attributes / Locators or record an action on it.' },\n { sel: '#demo-tabs', title: 'The four panels',\n body: '<b>Record</b> (capture actions), <b>Recorded script</b> (your test), <b>Locators</b> (ranked selectors), and <b>Attributes</b> for the selected element.' },\n { sel: '#demo-rec', before: function () { showDemoTab('rec'); }, title: 'Record',\n body: 'Press Start record, select an element, then choose an action \u2014 Click, Type, Clear, Long press, Scroll to, gestures\u2026 The <b>Actions / Screen / Assertions</b> sub-tabs switch what you capture. Each step is appended live.' },\n { sel: '#demo-script', before: function () { showDemoTab('script'); }, title: 'Recorded script',\n body: 'Your test in <b>Taqwright</b> (runnable), or <b>Python</b> / <b>Java</b> (steps only). Use <b>\u2398 Copy</b>, <b>\u2193 Export</b> (saves into your tests folder), or Clear.' },\n { sel: '#demo-loc', before: function () { showDemoTab('loc'); }, title: 'Locators',\n body: 'Ranked, uniqueness-verified selectors for the selected element. This field has <b>no id</b>, so taqwright recommends a <b>hint-based xpath</b> \u2014 others (UIAutomator, plain xpath) are offered too. Click any to copy.' },\n { sel: '#demo-attrs', before: function () { showDemoTab('attrs'); }, title: 'Attributes',\n body: 'The selected element\\'s full attribute set (resource-id, class, text, content-desc, bounds\u2026) plus its xpath.' },\n { sel: '#demo-disconnect', before: function () { showDemoTab('rec'); }, title: 'Done',\n body: 'On a real session, <b>Disconnect</b> ends it and returns to setup. Reopen this walkthrough any time with <b>? Help \u2192 Inspector tour</b>.' },\n ];\n\n let tourSteps = [];\n let tourIdx = 0;\n let tourActive = false;\n let tourOnDone = null;\n\n function tourSeen(key) {\n try {\n return !!localStorage.getItem(key);\n } catch {\n return true; // no storage \u2192 behave as already-seen (never nag)\n }\n }\n function markTourSeen(key) {\n try {\n localStorage.setItem(key, '1');\n } catch {\n /* ignore */\n }\n }\n\n function tourTarget(sel) {\n if (!sel) return null;\n const el = document.querySelector(sel);\n if (!el) return null;\n const r = el.getBoundingClientRect();\n if (r.width === 0 && r.height === 0) return null; // hidden / not laid out\n return el;\n }\n\n function startTour(steps, onDone) {\n if (tourActive || !steps || !steps.length) return;\n tourSteps = steps;\n tourOnDone = onDone || null;\n tourIdx = 0;\n tourActive = true;\n $('tour-overlay').classList.add('show');\n document.addEventListener('keydown', tourKey, true);\n window.addEventListener('resize', tourReposition);\n window.addEventListener('scroll', tourReposition, true);\n renderTourStep();\n }\n\n function endTour() {\n if (!tourActive) return;\n tourActive = false;\n $('tour-overlay').classList.remove('show');\n document.removeEventListener('keydown', tourKey, true);\n window.removeEventListener('resize', tourReposition);\n window.removeEventListener('scroll', tourReposition, true);\n const cb = tourOnDone;\n tourOnDone = null;\n if (cb) cb();\n }\n\n function renderTourStep() {\n const step = tourSteps[tourIdx];\n if (step.before) {\n try {\n step.before();\n } catch {\n /* navigation hook is best-effort */\n }\n }\n $('tour-title').textContent = step.title;\n $('tour-text').innerHTML = step.body;\n $('tour-progress').textContent = tourIdx + 1 + ' / ' + tourSteps.length;\n $('tour-back').disabled = tourIdx === 0;\n $('tour-next').textContent = tourIdx === tourSteps.length - 1 ? 'Done \u2713' : 'Next \u2192';\n const el = tourTarget(step.sel);\n if (el) el.scrollIntoView({ block: 'center', inline: 'center' });\n positionTour();\n }\n\n function positionTour() {\n const step = tourSteps[tourIdx];\n const spot = $('tour-spotlight');\n const pop = $('tour-pop');\n const el = tourTarget(step.sel);\n if (!el) {\n // No (visible) target \u2014 show the popover centered, no spotlight.\n spot.style.display = 'none';\n pop.style.transform = 'translate(-50%, -50%)';\n pop.style.left = '50%';\n pop.style.top = '50%';\n return;\n }\n const r = el.getBoundingClientRect();\n const pad = 6;\n spot.style.display = 'block';\n spot.style.left = r.left - pad + 'px';\n spot.style.top = r.top - pad + 'px';\n spot.style.width = r.width + pad * 2 + 'px';\n spot.style.height = r.height + pad * 2 + 'px';\n pop.style.transform = 'none';\n const popW = pop.offsetWidth || 320;\n const popH = pop.offsetHeight || 170;\n const gap = 14;\n let top = r.bottom + gap;\n if (top + popH > window.innerHeight - 8) top = Math.max(8, r.top - gap - popH);\n let left = r.left + r.width / 2 - popW / 2;\n left = Math.max(8, Math.min(left, window.innerWidth - popW - 8));\n pop.style.left = left + 'px';\n pop.style.top = top + 'px';\n }\n\n function tourReposition() {\n if (tourActive) positionTour();\n }\n function tourNext() {\n if (tourIdx >= tourSteps.length - 1) {\n endTour();\n return;\n }\n tourIdx++;\n renderTourStep();\n }\n function tourBack() {\n if (tourIdx > 0) {\n tourIdx--;\n renderTourStep();\n }\n }\n function tourKey(e) {\n if (!tourActive) return;\n if (e.key === 'Escape') {\n e.preventDefault();\n endTour();\n } else if (e.key === 'ArrowRight') {\n e.preventDefault();\n tourNext();\n } else if (e.key === 'ArrowLeft') {\n e.preventDefault();\n tourBack();\n }\n }\n\n function openHelp() {\n $('help-overlay').classList.add('show');\n document.addEventListener('keydown', helpKey, true);\n }\n function closeHelp() {\n $('help-overlay').classList.remove('show');\n document.removeEventListener('keydown', helpKey, true);\n }\n function helpKey(e) {\n if (e.key === 'Escape') {\n e.preventDefault();\n closeHelp();\n }\n }\n\n // First-run auto-start \u2014 called once the relevant page has finished loading\n // (so the spotlight never lands on a blank/loading area), not on a fixed timer.\n function maybeStartSetupTour() {\n if (tourActive || tourSeen('tw_tour_setup_seen')) return;\n if (!document.body.classList.contains('view-setup')) return;\n startTour(SETUP_TOUR, () => markTourSeen('tw_tour_setup_seen'));\n }\n // Called after the first device snapshot + tree have loaded (loader hidden).\n // First-timers get the inspector tour; everyone else gets a one-time screen\n // hint \u2014 never both at once.\n function onInspectorReady() {\n if (tourActive) return;\n if (!document.body.classList.contains('view-inspector')) return;\n if (!tourSeen('tw_tour_inspector_seen')) {\n startInspectorTour(() => markTourSeen('tw_tour_inspector_seen'));\n } else if (!tourSeen('tw_screen_hint_seen')) {\n openScreenHelp();\n markTourSeen('tw_screen_hint_seen');\n }\n }\n\n // The inspector tour runs against the mock #demo-stage (a Taqelah-demo login\n // screen) so it always has a realistic device to spotlight, connected or not.\n function showDemoStage() {\n const el = $('demo-stage');\n if (el) el.classList.add('show');\n }\n function hideDemoStage() {\n const el = $('demo-stage');\n if (el) el.classList.remove('show');\n }\n function startInspectorTour(onDone) {\n if (tourActive) return;\n // Connected \u2192 spotlight the REAL panes. Not connected \u2192 illustrate with the\n // mock demo device so there's still something to point at.\n if (document.body.classList.contains('view-inspector')) {\n startTour(INSPECTOR_TOUR_LIVE, onDone);\n return;\n }\n showDemoTab('rec');\n showDemoStage();\n startTour(INSPECTOR_TOUR_DEMO, function () {\n hideDemoStage();\n if (onDone) onDone();\n });\n }\n\n // \u2500\u2500\u2500 Screen \"how to use\" hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function openScreenHelp() {\n const el = $('screen-help-pop');\n if (el) el.classList.add('show');\n }\n function closeScreenHelp() {\n const el = $('screen-help-pop');\n if (el) el.classList.remove('show');\n }\n\n function initTutorial() {\n $('btn-help').onclick = openHelp;\n $('help-close').onclick = closeHelp;\n $('help-overlay').onclick = (e) => {\n if (e.target === $('help-overlay')) closeHelp();\n };\n $('help-tour-setup').onclick = () => {\n closeHelp();\n startTour(SETUP_TOUR);\n };\n $('help-tour-inspector').onclick = () => {\n closeHelp();\n startInspectorTour();\n };\n $('tour-next').onclick = tourNext;\n $('tour-back').onclick = tourBack;\n $('tour-skip').onclick = endTour;\n // Screen-pane help affordance.\n const shBtn = $('screen-help-btn');\n if (shBtn)\n shBtn.onclick = () => {\n const el = $('screen-help-pop');\n if (el && el.classList.contains('show')) closeScreenHelp();\n else openScreenHelp();\n };\n const shClose = $('screen-help-close');\n if (shClose) shClose.onclick = closeScreenHelp;\n const shOk = $('screen-help-ok2');\n if (shOk) shOk.onclick = closeScreenHelp;\n }\n\n initTutorial();\n bootstrap();\n})();\n</script>\n</body>\n</html>\n";
|