@critical-labs/qa-conductor 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/bin/qa-conductor-expose.mjs +113 -0
- package/lib/adapters/build-worktree.mjs +532 -0
- package/lib/adapters/exposure-tailscale.mjs +136 -0
- package/lib/adapters/provisioner-docker.mjs +116 -0
- package/lib/adapters/provisioner-process.mjs +653 -0
- package/lib/config.mjs +245 -0
- package/lib/docker.mjs +233 -0
- package/lib/exec.mjs +23 -0
- package/lib/exposure.mjs +193 -0
- package/lib/github.mjs +218 -0
- package/lib/identity.mjs +51 -0
- package/lib/net.mjs +56 -0
- package/lib/proxy.mjs +327 -0
- package/lib/request-guard.mjs +95 -0
- package/lib/server.mjs +721 -0
- package/lib/session.mjs +250 -0
- package/lib/verdict.mjs +33 -0
- package/package.json +47 -4
- package/public/bridge.js +324 -0
- package/public/harness.js +542 -0
- package/public/index.html +284 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>PR QA</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #14171a; --surface: #1e2328; --surface-2: #262c33;
|
|
10
|
+
--line: #2c333a; --line-strong: #3a434d;
|
|
11
|
+
--text: #e6e9ec; --text-dim: #9aa4ad; --text-faint: #6b757f;
|
|
12
|
+
--accent: #4e8f68; --accent-hover: #5da37a; --accent-soft: rgba(78,143,104,.15);
|
|
13
|
+
--danger: #c05b5b; --warn: #d9a441;
|
|
14
|
+
--focus: #7fc19e; --radius: 8px;
|
|
15
|
+
--font: 14px/1.45 system-ui, sans-serif; --mono: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box }
|
|
18
|
+
[hidden] { display: none !important }
|
|
19
|
+
html, body { height: 100% }
|
|
20
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: var(--font); display: flex; flex-direction: column }
|
|
21
|
+
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0 }
|
|
22
|
+
|
|
23
|
+
/* the harness opened at another origin */
|
|
24
|
+
#originNotice { margin: 0; padding: 8px 14px; background: rgba(217,164,65,.12); border-bottom: 1px solid var(--warn); color: var(--text) }
|
|
25
|
+
#originNotice a { color: var(--warn) }
|
|
26
|
+
|
|
27
|
+
/* header */
|
|
28
|
+
header { display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: var(--surface); border-bottom: 2px solid var(--accent) }
|
|
29
|
+
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; white-space: nowrap }
|
|
30
|
+
.glyph { width: 12px; height: 12px; border-radius: 3px; background: var(--accent); display: inline-block }
|
|
31
|
+
.pill { font: var(--mono); text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim); background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px }
|
|
32
|
+
.meta { color: var(--text-dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0 }
|
|
33
|
+
.meta .tag { font: var(--mono); color: var(--text-faint) }
|
|
34
|
+
.spacer { flex: 1 }
|
|
35
|
+
#sessionControls { display: flex; align-items: center; gap: 8px }
|
|
36
|
+
.split { display: flex; align-items: stretch; position: relative }
|
|
37
|
+
.split .caret { padding: 6px 6px; border-left: 0 }
|
|
38
|
+
.widths { display: flex; gap: 4px }
|
|
39
|
+
.menu { position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 8px; z-index: 20; white-space: nowrap; box-shadow: 0 6px 20px rgba(0,0,0,.35) }
|
|
40
|
+
.menu label { display: flex; gap: 6px; align-items: center; font-size: 13px; color: var(--text-dim); cursor: pointer }
|
|
41
|
+
.chip { font: var(--mono); color: var(--text-dim); background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; white-space: nowrap }
|
|
42
|
+
.chip.warn { color: var(--warn); border-color: var(--warn) }
|
|
43
|
+
|
|
44
|
+
/* buttons */
|
|
45
|
+
button, .btn { font: inherit; border-radius: 6px; padding: 6px 12px; cursor: pointer; border: 1px solid transparent; color: var(--text); background: transparent }
|
|
46
|
+
button:disabled { opacity: .5; cursor: default }
|
|
47
|
+
.primary { background: var(--accent); border-color: var(--accent); color: #fff }
|
|
48
|
+
.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover) }
|
|
49
|
+
.secondary { background: var(--surface-2); border-color: var(--line) }
|
|
50
|
+
.secondary:hover:not(:disabled) { border-color: var(--line-strong) }
|
|
51
|
+
.ghost { color: var(--text-dim); padding: 6px 8px }
|
|
52
|
+
.ghost:hover:not(:disabled) { color: var(--text); background: var(--surface-2) }
|
|
53
|
+
.danger { background: var(--danger); border-color: var(--danger); color: #fff }
|
|
54
|
+
.danger-ghost { color: var(--danger); border-color: var(--line) }
|
|
55
|
+
.danger-ghost.confirm { border-color: var(--danger); background: rgba(192,91,91,.12) }
|
|
56
|
+
.toggle.on { background: var(--accent-soft); border-color: var(--accent); color: var(--text) }
|
|
57
|
+
a.ghost { text-decoration: none; display: inline-flex; align-items: center }
|
|
58
|
+
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
|
|
59
|
+
|
|
60
|
+
/* dots + badges (one vocabulary) */
|
|
61
|
+
.dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; background: var(--text-faint); flex: none }
|
|
62
|
+
.dot.green { background: var(--accent) } .dot.amber { background: var(--warn) } .dot.grey { background: var(--text-faint) } .dot.red { background: var(--danger) }
|
|
63
|
+
.badge { display: inline-flex; align-items: center; gap: 6px; font: var(--mono); text-transform: uppercase; letter-spacing: .03em; color: var(--text-dim) }
|
|
64
|
+
/* a blocked reason is a sentence: keep its case */
|
|
65
|
+
.badge.blocked { color: var(--text-faint); text-transform: none; letter-spacing: 0 }
|
|
66
|
+
|
|
67
|
+
main { flex: 1; display: flex; flex-direction: column; min-height: 0 }
|
|
68
|
+
.view[hidden] { display: none }
|
|
69
|
+
#picker, #boot, #verdictDone { max-width: 780px; width: 100%; margin: 28px auto; padding: 0 16px }
|
|
70
|
+
h1 { font-size: 18px; display: flex; align-items: center; gap: 10px }
|
|
71
|
+
h1:focus { outline: none }
|
|
72
|
+
|
|
73
|
+
/* picker */
|
|
74
|
+
#prList { display: flex; flex-direction: column; gap: 8px }
|
|
75
|
+
.pr { display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 14px }
|
|
76
|
+
.pr .num { color: var(--text-faint); font: var(--mono) }
|
|
77
|
+
.pr .main { flex: 1; min-width: 0 }
|
|
78
|
+
.pr .title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
79
|
+
.pr .submeta { color: var(--text-faint); font: var(--mono); margin-top: 2px }
|
|
80
|
+
.pr .rowErr { color: var(--danger); font-size: 12px; margin-top: 4px }
|
|
81
|
+
.pr[aria-busy="true"] { opacity: .8 }
|
|
82
|
+
.conflict { background: var(--surface); border: 1px solid var(--warn); border-radius: var(--radius); padding: 14px; margin-bottom: 12px }
|
|
83
|
+
.conflict h2 { font-size: 15px; margin: 0 0 4px }
|
|
84
|
+
.conflict .times { color: var(--text-dim); font: var(--mono); margin-bottom: 10px }
|
|
85
|
+
.conflict .acts { display: flex; gap: 8px; flex-wrap: wrap }
|
|
86
|
+
.note { background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--warn); border-radius: var(--radius); padding: 10px 12px; margin-bottom: 12px; color: var(--text-dim) }
|
|
87
|
+
|
|
88
|
+
/* boot */
|
|
89
|
+
#bootSteps { list-style: none; padding: 0 }
|
|
90
|
+
#bootSteps li { padding: 7px 0; color: var(--text-faint); display: flex; align-items: baseline; gap: 8px }
|
|
91
|
+
#bootSteps li.now { color: var(--text) } #bootSteps li.done { color: var(--text-dim) }
|
|
92
|
+
#bootSteps li.now::before { content: '▸'; color: var(--warn) }
|
|
93
|
+
#bootSteps li.done::before { content: '✓'; color: var(--accent) }
|
|
94
|
+
#bootSteps li::before { content: '·'; color: var(--text-faint); width: 12px; display: inline-block }
|
|
95
|
+
.stepSub { color: var(--text-dim); font-size: 12px }
|
|
96
|
+
.stepSub a { color: var(--accent-hover) }
|
|
97
|
+
.stepTime { margin-left: auto; font: var(--mono); color: var(--text-faint) }
|
|
98
|
+
#bootError { background: var(--bg); border: 1px solid var(--line); border-left: 3px solid var(--danger); border-radius: var(--radius); padding: 10px; font: var(--mono); white-space: pre-wrap; max-height: 260px; overflow: auto; color: var(--text-dim) }
|
|
99
|
+
#bootError .errline { color: var(--danger); display: block; margin-bottom: 6px }
|
|
100
|
+
#bootActions, #bootBar { display: flex; gap: 8px; margin-top: 12px }
|
|
101
|
+
|
|
102
|
+
/* session panes */
|
|
103
|
+
#session { flex: 1; display: flex; flex-direction: column; min-height: 0 }
|
|
104
|
+
#tabs { display: flex; gap: 4px; padding: 6px 10px; background: var(--surface); border-bottom: 1px solid var(--line) }
|
|
105
|
+
.tab { font: var(--mono); text-transform: uppercase; color: var(--text-dim); background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: 4px 14px; position: relative }
|
|
106
|
+
.tab.on { background: var(--accent-soft); border-color: var(--accent); color: var(--text) }
|
|
107
|
+
.tab .tabBadge { position: absolute; top: -6px; right: -6px; background: var(--warn); color: #14171a; border-radius: 999px; font-size: 10px; padding: 0 5px }
|
|
108
|
+
#paneRow { flex: 1; display: flex; min-height: 0 }
|
|
109
|
+
.pane { flex: 1; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line) }
|
|
110
|
+
.pane:last-child { border-right: 0 }
|
|
111
|
+
.pane.diverged .paneBar { background: var(--accent-soft) }
|
|
112
|
+
.paneBar { display: flex; gap: 8px; align-items: center; padding: 4px 10px; background: var(--surface); border-bottom: 1px solid var(--line); font-size: 12px; color: var(--text-dim) }
|
|
113
|
+
.paneBar b { color: var(--text) }
|
|
114
|
+
.paneBar .url { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--mono) }
|
|
115
|
+
.paneBar .chip.unmatched { color: var(--warn); border-color: var(--warn); cursor: pointer }
|
|
116
|
+
.paneWrap { flex: 1; display: flex; justify-content: center; background: #0d0f11; min-height: 0 }
|
|
117
|
+
iframe { border: 0; height: 100%; width: 100%; background: #fff }
|
|
118
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
119
|
+
.flash { animation: flash .6s }
|
|
120
|
+
@keyframes flash { 0%,100% { box-shadow: none } 50% { box-shadow: inset 0 0 0 4px var(--warn) } }
|
|
121
|
+
.pulse { animation: pulse 1.2s infinite }
|
|
122
|
+
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .4 } }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* verdict drawer */
|
|
126
|
+
#verdictBar { border-top: 1px solid var(--line); background: var(--surface) }
|
|
127
|
+
#verdictToggle { width: 100%; text-align: center; color: var(--text-dim); background: var(--surface); border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 4px }
|
|
128
|
+
#verdictBody { padding: 10px 14px }
|
|
129
|
+
#verdictEdit { display: flex; gap: 10px; align-items: flex-start }
|
|
130
|
+
#notes { flex: 1; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font: inherit; resize: vertical; min-height: 40px }
|
|
131
|
+
#verdictPreview #previewBody { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px; font: var(--mono); white-space: pre-wrap; max-height: 200px; overflow: auto }
|
|
132
|
+
#previewLabelChange { color: var(--text-dim); font-size: 12px; margin: 8px 0 }
|
|
133
|
+
#verdictPreview .acts, #verdictDone .acts { display: flex; gap: 8px; margin-top: 8px }
|
|
134
|
+
#verdictDone .posted { margin: 8px 0 16px }
|
|
135
|
+
#verdictDone a { color: var(--accent-hover) }
|
|
136
|
+
|
|
137
|
+
dialog { background: var(--surface); color: var(--text); border: 1px solid var(--line-strong); border-radius: var(--radius); padding: 20px; max-width: 420px }
|
|
138
|
+
dialog::backdrop { background: rgba(0,0,0,.5) }
|
|
139
|
+
dialog h2 { margin-top: 0; font-size: 15px }
|
|
140
|
+
.kbd { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; font-size: 13px }
|
|
141
|
+
.kbd kbd { font: var(--mono); background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 4px; padding: 1px 6px }
|
|
142
|
+
|
|
143
|
+
/* compact <900px */
|
|
144
|
+
body.compact #tabs { display: flex }
|
|
145
|
+
body.compact #paneRow .pane { display: none; border-right: 0 }
|
|
146
|
+
body.compact #paneRow .pane.activeTab { display: flex }
|
|
147
|
+
body.compact .widths { display: none }
|
|
148
|
+
body.compact #picker, body.compact #boot, body.compact #verdictDone { margin: 16px auto }
|
|
149
|
+
body.compact #verdictBar { position: sticky; bottom: 0 }
|
|
150
|
+
body.compact #notes { min-height: 70px }
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<header>
|
|
155
|
+
<div class="brand"><span class="glyph"></span> PR-QA <span id="statusPill" class="pill">idle</span></div>
|
|
156
|
+
<div id="sessionMeta" class="meta"></div>
|
|
157
|
+
<span class="spacer"></span>
|
|
158
|
+
<div id="sessionControls" hidden>
|
|
159
|
+
<div class="split">
|
|
160
|
+
<button id="mirrorBtn" class="toggle on" title="Mirror interactions (m)">Mirror: on</button>
|
|
161
|
+
<button id="mirrorMenuBtn" class="ghost caret secondary" aria-haspopup="true" aria-expanded="false" title="Mirror options">⌄</button>
|
|
162
|
+
<div id="mirrorMenu" class="menu" hidden><label><input type="checkbox" id="scrollMirror" checked> Mirror scrolling <span class="chip">s</span></label></div>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="split">
|
|
165
|
+
<button id="resyncBtn" class="secondary" title="Reload right pane at left's URL (r)">Re-sync →</button>
|
|
166
|
+
<button id="resyncMenuBtn" class="ghost caret secondary" aria-haspopup="true" aria-expanded="false" title="Re-sync options">⌄</button>
|
|
167
|
+
<div id="resyncMenu" class="menu" hidden><button id="resyncLeft" class="ghost">← Reload left at right's URL</button></div>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="widths">
|
|
170
|
+
<button class="secondary" data-width="375">375</button>
|
|
171
|
+
<button class="secondary" data-width="768">768</button>
|
|
172
|
+
<button class="secondary on" data-width="0">Full</button>
|
|
173
|
+
</div>
|
|
174
|
+
<span id="idleChip" class="chip" title="Auto-end when idle">auto-end —</span>
|
|
175
|
+
<button id="teardownBtn" class="danger-ghost" title="End session (e)">End session</button>
|
|
176
|
+
</div>
|
|
177
|
+
</header>
|
|
178
|
+
<p id="originNotice" role="alert" hidden></p>
|
|
179
|
+
|
|
180
|
+
<div id="announcer" class="sr" role="status" aria-live="polite"></div>
|
|
181
|
+
<div id="announcerAssertive" class="sr" role="alert" aria-live="assertive"></div>
|
|
182
|
+
|
|
183
|
+
<main>
|
|
184
|
+
<section id="picker" class="view" hidden>
|
|
185
|
+
<h1 tabindex="-1">Open pull requests <button id="refreshBtn" class="ghost" title="Refresh">↻</button></h1>
|
|
186
|
+
<div id="conflictSlot"></div>
|
|
187
|
+
<div id="prList">Loading…</div>
|
|
188
|
+
</section>
|
|
189
|
+
|
|
190
|
+
<section id="boot" class="view" hidden>
|
|
191
|
+
<h1 id="bootHeading" tabindex="-1">Booting #<span id="bootPr"></span> <span id="bootTotal" class="chip"></span></h1>
|
|
192
|
+
<ol id="bootSteps">
|
|
193
|
+
<!-- labels are (re)set from LABELS in harness.js; ids are the session states -->
|
|
194
|
+
<li data-step="ensuring-image"><span class="stepLabel">Building</span> <span class="stepSub"></span><span class="stepTime"></span></li>
|
|
195
|
+
<li data-step="cloning"><span class="stepLabel">Preparing data</span><span class="stepTime"></span></li>
|
|
196
|
+
<li data-step="migrating"><span class="stepLabel">Migrating</span><span class="stepTime"></span></li>
|
|
197
|
+
<li data-step="starting"><span class="stepLabel">Starting</span><span class="stepTime"></span></li>
|
|
198
|
+
</ol>
|
|
199
|
+
<pre id="bootError" hidden></pre>
|
|
200
|
+
<div id="bootActions" hidden></div>
|
|
201
|
+
<div id="bootBar"><button id="cancelBoot" class="secondary">Cancel boot</button></div>
|
|
202
|
+
</section>
|
|
203
|
+
|
|
204
|
+
<div id="session" class="view" hidden>
|
|
205
|
+
<h1 id="sessionHeading" class="sr" tabindex="-1">QA session</h1>
|
|
206
|
+
<div id="tabs" hidden>
|
|
207
|
+
<button class="tab on" data-tab="base">BASE</button>
|
|
208
|
+
<button class="tab" data-tab="pr">PR</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div id="paneRow">
|
|
211
|
+
<div class="pane activeTab" data-pane="base">
|
|
212
|
+
<div class="paneBar">
|
|
213
|
+
<b>BASE</b><span class="dot amber" id="baseDot" title="signing in…"></span>
|
|
214
|
+
<span class="url" id="baseUrl">signing in…</span>
|
|
215
|
+
<button class="chip unmatched" id="baseUnmatched" hidden title="Unmatched replays — click to clear"></button>
|
|
216
|
+
<button class="ghost" data-act="copy" data-pane="base" title="Copy path">⧉</button>
|
|
217
|
+
<button class="ghost" data-act="reload" data-pane="base" title="Reload pane">↻</button>
|
|
218
|
+
<a class="ghost" id="baseOpen" target="_blank" rel="noopener" title="Open in new tab">↗</a>
|
|
219
|
+
</div>
|
|
220
|
+
<div class="paneWrap"><iframe id="baseFrame" title="base pane"></iframe></div>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="pane" data-pane="pr">
|
|
223
|
+
<div class="paneBar">
|
|
224
|
+
<b>PR</b><span class="dot amber" id="prDot" title="signing in…"></span>
|
|
225
|
+
<span class="url" id="prUrl">signing in…</span>
|
|
226
|
+
<button class="chip unmatched" id="prUnmatched" hidden title="Unmatched replays — click to clear"></button>
|
|
227
|
+
<button class="ghost" data-act="copy" data-pane="pr" title="Copy path">⧉</button>
|
|
228
|
+
<button class="ghost" data-act="reload" data-pane="pr" title="Reload pane">↻</button>
|
|
229
|
+
<a class="ghost" id="prOpen" target="_blank" rel="noopener" title="Open in new tab">↗</a>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="paneWrap"><iframe id="prFrame" title="pr pane"></iframe></div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<section id="verdictDone" class="view" hidden>
|
|
237
|
+
<h1 tabindex="-1">Verdict posted</h1>
|
|
238
|
+
<p class="posted">Posted to PR #<span id="doneP"></span> — <a id="doneLink" target="_blank" rel="noreferrer">view comment ↗</a></p>
|
|
239
|
+
<div class="acts">
|
|
240
|
+
<button id="doneEnd" class="primary">End session</button>
|
|
241
|
+
<button id="doneKeep" class="secondary">Keep session open</button>
|
|
242
|
+
<button id="doneBack" class="ghost">Back to pull requests</button>
|
|
243
|
+
</div>
|
|
244
|
+
</section>
|
|
245
|
+
</main>
|
|
246
|
+
|
|
247
|
+
<footer id="verdictBar" hidden>
|
|
248
|
+
<button id="verdictToggle" aria-expanded="false">Verdict ▔</button>
|
|
249
|
+
<div id="verdictBody" hidden>
|
|
250
|
+
<div id="verdictEdit">
|
|
251
|
+
<textarea id="notes" placeholder="QA notes (land on the PR comment)…"></textarea>
|
|
252
|
+
<button id="acceptBtn" class="primary">Accept</button>
|
|
253
|
+
<button id="rejectBtn" class="danger">Reject</button>
|
|
254
|
+
</div>
|
|
255
|
+
<div id="verdictPreview" hidden>
|
|
256
|
+
<pre id="previewBody"></pre>
|
|
257
|
+
<div id="previewLabelChange"></div>
|
|
258
|
+
<div class="acts">
|
|
259
|
+
<button id="postBtn" class="primary">Post to PR</button>
|
|
260
|
+
<button id="previewBack" class="ghost">Back</button>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</footer>
|
|
265
|
+
|
|
266
|
+
<dialog id="shortcuts">
|
|
267
|
+
<h2>Keyboard shortcuts</h2>
|
|
268
|
+
<div class="kbd">
|
|
269
|
+
<kbd>m</kbd><span>Toggle mirror</span>
|
|
270
|
+
<kbd>s</kbd><span>Toggle scroll mirroring</span>
|
|
271
|
+
<kbd>r</kbd><span>Re-sync right pane to left</span>
|
|
272
|
+
<kbd>1 / 2 / 3</kbd><span>Pane width 375 / 768 / full</span>
|
|
273
|
+
<kbd>v</kbd><span>Toggle verdict drawer</span>
|
|
274
|
+
<kbd>e</kbd><span>End session (starts confirm)</span>
|
|
275
|
+
<kbd>?</kbd><span>This overlay</span>
|
|
276
|
+
<kbd>Esc</kbd><span>Close drawer / overlay / cancel confirm</span>
|
|
277
|
+
</div>
|
|
278
|
+
<p class="stepSub" style="margin-top:14px">Shortcuts work when the harness chrome has focus. Click outside a pane to return focus from an iframe.</p>
|
|
279
|
+
<button id="shortcutsClose" class="secondary" style="margin-top:8px">Close</button>
|
|
280
|
+
</dialog>
|
|
281
|
+
|
|
282
|
+
<script src="/qa/harness.js"></script>
|
|
283
|
+
</body>
|
|
284
|
+
</html>
|