dsh-mobilecode 0.11.9 → 0.12.1

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/lib/client.js CHANGED
@@ -1,1610 +1,2455 @@
1
- /**
2
- * dsh-mobilecode — browser half. Runs inside the dsh web GUI.
3
- *
4
- * Renders the device pane, the DSH equivalent of mobilecode's embedded
5
- * device-preview UI:
6
- * - sidebar entry row toggling the pane (DOM-level injection, self-healing),
7
- * - directory input (remembers the last one) + platform pills from detect,
8
- * - per platform: run/stop the app + build status (iOS only: start/stop
9
- * the serve-sim preview server with its embedded iframe),
10
- * - Device 1 card (the live stream): 0.10.0 merged the Android
11
- * "Start server" into it (serve-avd retired; picker ⏻ boot → POST /boot),
12
- * - build status, error and expandable log tail,
13
- * - polls GET /api/dsh-mobilecode every ~2s while open.
14
- *
15
- * Bundle format: `window.__ModuleLoader__.load({id, factory})` (lazy CJS) —
16
- * the only client bundle format the web shell materializes.
17
- */
18
- window.__ModuleLoader__.load({
19
- id: "dsh-mobilecode",
20
- factory: (require) => {
21
- var module = { exports: {} };
22
- var exports = module.exports;
23
-
24
- const { createElement: h, useEffect, useMemo, useRef, useState, useSyncExternalStore } = require("react");
25
- const { createRoot } = require("react-dom/client");
26
-
27
- //#region styles
28
- const STYLE = `
29
- [data-dsh-mobilecode-entry] {
30
- display: flex; align-items: center; gap: 8px; width: 100%;
31
- padding: 9px 12px; margin: 2px 0; border: 0; border-radius: 8px;
32
- background: transparent; color: inherit; font: inherit; cursor: pointer;
33
- text-align: left;
34
- }
35
- [data-dsh-mobilecode-entry]:hover { background: rgba(128,128,128,.14); }
36
- [data-dsh-mobilecode-entry][data-active] { background: rgba(128,128,128,.22); }
37
- [data-dsh-mobilecode-entry] .mc-entry-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
38
- [data-dsh-mobilecode-entry] .mc-entry-label { font-size: 13px; line-height: 1.2; opacity: .92; }
39
- .dsh-mobilecode-view { position: fixed; top: 0; right: 0; bottom: 0; width: min(560px, 94vw); z-index: 999999; left: auto; }
40
- .dsh-mobilecode-view[hidden] { display: none !important; }
41
- .dsh-mobilecode-panel {
42
- position: absolute; inset: 0; display: flex; flex-direction: column;
43
- background: light-dark(#ffffff, #171a1f);
44
- color: light-dark(#1a1d23, #dfe3ea);
45
- border-left: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14));
46
- box-shadow: -10px 0 28px rgba(0,0,0,.22);
47
- }
48
- .dsh-mobilecode-resize { position: absolute; left: -6px; top: 0; bottom: 0; width: 12px; cursor: col-resize; z-index: 2; display: flex; align-items: center; justify-content: center; }
49
- .dsh-mobilecode-resize::before { content: ""; width: 3px; height: 48px; border-radius: 2px; background: light-dark(rgba(0,0,0,.3), rgba(255,255,255,.35)); transition: background .15s, height .15s; }
50
- .dsh-mobilecode-resize:hover::before, .dsh-mobilecode-resize[data-drag]::before { background: light-dark(rgba(0,0,0,.6), rgba(255,255,255,.75)); height: 72px; }
51
- .mc-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
52
- .mc-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
53
- .mc-title { font-size: 15px; font-weight: 700; margin: 0; letter-spacing: .2px; flex: 1; }
54
- .mc-close { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 6px; font-size: 13px; }
55
- .mc-close:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
56
- .mc-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; }
57
- .mc-row { display: flex; align-items: center; gap: 8px; }
58
- .mc-input { flex: 1; min-width: 0; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.08)); border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 7px; color: inherit; font: inherit; font-size: 12px; padding: 6px 9px; }
59
- .mc-input:focus { outline: 2px solid rgba(66,133,244,.35); border-color: rgba(66,133,244,.5); }
60
- .mc-btn { border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; transition: background .12s; white-space: nowrap; }
61
- .mc-btn:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
62
- .mc-btn[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); border-color: rgba(66,133,244,.45); color: #4285f4; }
63
- .mc-btn:disabled { opacity: .45; cursor: default; }
64
- .mc-btn.primary { background: #4285f4; border-color: #4285f4; color: #fff; }
65
- .mc-btn.primary:hover { background: #3b78e7; }
66
- .mc-btn.danger { color: #ef5350; }
67
- .mc-pills { display: flex; gap: 6px; flex-wrap: wrap; }
68
- .mc-pill { display: inline-flex; align-items: center; gap: 6px; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 5px 10px; border-radius: 999px; cursor: pointer; }
69
- .mc-pill:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
70
- .mc-pill[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); border-color: rgba(66,133,244,.45); color: #4285f4; }
71
- .mc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
72
- .mc-dot.on { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,.6); }
73
- .mc-dot.off { background: #9e9e9e; }
74
- .mc-dot.warn { background: #ff9800; }
75
- .mc-dot.err { background: #ef5350; }
76
- .mc-card { border: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
77
- .mc-card-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 700; }
78
- .mc-card-head .sp { flex: 1; }
79
- .mc-status { font-size: 12px; color: light-dark(#4a4f58, #b9bec7); display: flex; flex-direction: column; gap: 2px; }
80
- .mc-status b { font-weight: 600; color: inherit; }
81
- .mc-error { font-size: 12px; color: #ef5350; background: rgba(239,83,80,.1); border: 1px solid rgba(239,83,80,.3); border-radius: 7px; padding: 7px 9px; white-space: pre-wrap; word-break: break-all; }
82
- .mc-log-toggle { font-size: 11px; color: #4285f4; cursor: pointer; border: 0; background: transparent; padding: 2px 0; text-align: left; font: inherit; }
83
- .mc-log { max-height: 180px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.06)); border-radius: 7px; padding: 8px; margin: 0; font-size: 11px; line-height: 16px; white-space: pre-wrap; word-break: break-all; font-family: Consolas, "Cascadia Mono", monospace; }
84
- .mc-frame { width: 100%; height: 300px; border: 1px solid light-dark(rgba(0,0,0,.15), rgba(255,255,255,.18)); border-radius: 10px; background: #000; display: block; }
85
- .mc-empty { color: light-dark(#9aa0a8, #7c838d); font-size: 12px; padding: 8px 0; }
86
- .mc-footer { display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-top: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; font-size: 11px; color: light-dark(#6b7078, #9aa0a8); flex-wrap: wrap; }
87
- /* ── welcome modal ── */
88
- .mc-overlay { position: fixed; inset: 0; z-index: 1000000; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.45); backdrop-filter: blur(2px); padding: 24px; }
89
- .mc-modal { width: min(680px, 100%); max-height: 88vh; overflow: auto; background: light-dark(#ffffff, #1b1f26); color: light-dark(#1a1d23, #e3e7ee); border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)); border-radius: 14px; box-shadow: 0 18px 60px rgba(0,0,0,.4); display: flex; flex-direction: column; }
90
- .mc-modal-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
91
- .mc-modal-title { font-size: 16px; font-weight: 700; margin: 0; flex: 1; }
92
- .mc-modal-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 14px; font-size: 13px; line-height: 1.55; }
93
- .mc-modal-foot { padding: 12px 18px; border-top: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex: none; }
94
- .mc-modal-foot .sp { flex: 1; }
95
- .mc-modal h3 { margin: 0; font-size: 13px; }
96
- .mc-prompt { position: relative; }
97
- .mc-prompt pre { margin: 0; max-height: 220px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.07)); border: 1px solid light-dark(rgba(0,0,0,.14), rgba(255,255,255,.18)); border-radius: 8px; padding: 10px 12px; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-family: Consolas, "Cascadia Mono", monospace; }
98
- .mc-copy { position: absolute; top: 8px; right: 8px; }
99
- .mc-tabs { display: flex; gap: 4px; padding: 0 18px; border-bottom: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.12)); flex: none; }
100
- .mc-tab { border: 0; background: transparent; color: inherit; font: inherit; font-size: 13px; padding: 9px 12px; cursor: pointer; border-bottom: 2px solid transparent; opacity: .65; }
101
- .mc-tab[data-on] { opacity: 1; border-bottom-color: #4285f4; color: #4285f4; }
102
- .mc-check { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border: 1px solid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.13)); border-radius: 8px; font-size: 12px; line-height: 1.5; }
103
- .mc-check .mc-check-ico { flex: none; font-weight: 700; width: 18px; text-align: center; }
104
- .mc-check .mc-check-detail { flex: 1; min-width: 0; word-break: break-all; }
105
- .mc-check .mc-check-name { font-weight: 700; }
106
- .mc-spin { display: inline-block; width: 12px; height: 12px; border: 2px solid rgba(128,128,128,.35); border-top-color: #4285f4; border-radius: 50%; animation: mc-spin .8s linear infinite; vertical-align: -2px; }
107
- @keyframes mc-spin { to { transform: rotate(360deg); } }
108
- .mc-logline { font-family: Consolas, "Cascadia Mono", monospace; font-size: 11px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; background: light-dark(rgba(0,0,0,.05), rgba(255,255,255,.06)); border-radius: 8px; padding: 8px 10px; margin: 0; }
109
- .mc-hint { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); }
110
- /* ── DSH Settings page contribution ── */
111
- .mc-section { display: flex; flex-direction: column; gap: 4px; padding: 4px 6px 16px; }
112
- .mc-section-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 2px 0 10px; }
113
- .mc-section-head .mc-hint { flex: 1; min-width: 200px; }
114
- .mc-section .mc-tabs { padding: 0; margin-bottom: 4px; }
115
- /* ── live device stream ── */
116
- .mc-live { display: flex; flex-direction: column; gap: 8px; }
117
- .mc-live-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
118
- .mc-live-bar select { background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.18)); border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; max-width: 200px; }
119
- .mc-live-stage { position: relative; align-self: center; background: #000; border-radius: 10px; overflow: hidden; line-height: 0; box-shadow: inset 0 0 0 1px light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); }
120
- .mc-live-stage img { display: block; max-width: 100%; max-height: 60vh; width: auto; height: auto; cursor: crosshair; touch-action: none; user-select: none; }
121
- .mc-live-stage .mc-live-off { display: flex; align-items: center; justify-content: center; width: 260px; max-width: 100%; height: 460px; color: #9aa0a8; font-size: 13px; line-height: 1.5; text-align: center; }
122
- /* 0.9.1 co-op: two forced columns — side-by-side MUST survive a narrow drawer. */
123
- .mc-live-section { display: flex; flex-direction: column; gap: 10px; }
124
- .mc-live-section.coop { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 10px; align-items: start; }
125
- .mc-live-section.coop > .mc-card { min-width: 0; }
126
- /* 0.11.6: the co-op head must NEVER wrap to a second row — a wrapped
127
- head offsets one pane's stage ~38px lower than its sibling's, which
128
- read as "the bars in Device 1 make the heights differ". Children
129
- shrink instead (picker/select ellipsize). 0.11.7: the button must
130
- also TRACK its shrinking .mc-picker container — at intrinsic width
131
- it overflowed sideways under the ☰/⧉ buttons. */
132
- .mc-live-section.coop .mc-card-head { flex-wrap: nowrap; min-width: 0; }
133
- .mc-card-head .mc-picker { flex: 1 1 auto; min-width: 0; }
134
- .mc-card-head .mc-picker .mc-btn { width: 100%; min-width: 0; }
135
- /* No coop-specific height cap: both panes must share Device 1's 60vh rule so
136
- they match at every size, Fit included (0.11.2). */
137
- .mc-coop-select { width: auto; min-width: 0; flex: 0 1 auto; padding: 3px 6px; font-size: 12px; }
138
- .mc-live-nav { display: flex; align-items: center; justify-content: center; gap: 8px; }
139
- .mc-live-nav button { border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); background: light-dark(#fff, #20242b); color: inherit; border-radius: 999px; width: 40px; height: 32px; cursor: pointer; font-size: 14px; }
140
- .mc-live-nav button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
141
- .mc-live-cap { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); text-align: center; }
142
- .mc-device-list { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
143
- .mc-device-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.14)); border-radius: 8px; }
144
- .mc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; background: #9e9e9e; }
145
- .mc-dot[data-on="true"] { background: #4caf50; box-shadow: 0 0 6px rgba(76,175,80,.6); }
146
- .mc-serial { font-weight: 600; font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
147
- .mc-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: light-dark(rgba(0,0,0,.07), rgba(255,255,255,.12)); }
148
- .mc-conn-form { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
149
- .mc-conn-row { display: flex; align-items: center; gap: 6px; }
150
- .mc-input { flex: 1; min-width: 0; padding: 6px 8px; border: 1px solid light-dark(rgba(0,0,0,.2), rgba(255,255,255,.25)); border-radius: 6px; background: light-dark(#fff, #1f2228); color: inherit; font: inherit; font-size: 12px; }
151
- .mc-input-sm { flex: 0 0 72px; }
152
- .mc-qr { margin: 8px 0; padding: 8px; border: 1px dashed light-dark(rgba(0,0,0,.25), rgba(255,255,255,.3)); border-radius: 8px; }
153
- .mc-qr-text { user-select: all; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; margin: 4px 0 0; }
154
- .mc-warn { color: #b3261e; font-size: 12px; margin: 6px 0; }
155
- /* ── 0.7.0: toolbar pill, picker popover, device menu, sizes, frames ── */
156
- .mc-toolbar { display: flex; align-items: center; justify-content: center; gap: 2px; padding: 3px; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); border-radius: 999px; background: light-dark(rgba(0,0,0,.03), rgba(255,255,255,.05)); align-self: center; }
157
- .mc-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 0; background: transparent; color: inherit; border-radius: 999px; cursor: pointer; opacity: .8; }
158
- .mc-toolbar button:hover:not(:disabled) { background: light-dark(rgba(0,0,0,.08), rgba(255,255,255,.12)); opacity: 1; }
159
- .mc-toolbar button:disabled { opacity: .3; cursor: default; }
160
- .mc-toolbar svg { display: block; }
161
- .mc-live-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #4caf50; }
162
- .mc-picker { position: relative; }
163
- .mc-picker .mc-btn { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
- .mc-picker-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 240px; max-width: 320px; max-height: 320px; overflow: auto; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 6px; display: flex; flex-direction: column; gap: 6px; }
165
- .mc-picker-group { display: flex; flex-direction: column; gap: 2px; }
166
- .mc-picker-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: light-dark(#6b7078, #9aa0a8); padding: 2px 6px; }
167
- .mc-picker-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
168
- .mc-picker-row:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
169
- .mc-picker-row.on { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); }
170
- .mc-picker-hint { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); padding: 4px 8px; }
171
- .mc-picker-row .sp { flex: 1; }
172
- .mc-picker-row .mc-btn { padding: 2px 8px; font-size: 11px; }
173
- .mc-menu-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 180px; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 4px; display: flex; flex-direction: column; }
174
- .mc-menu-pop button { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
175
- .mc-menu-pop button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
176
- .mc-menu { position: relative; }
177
- .mc-live-sizesel { flex: 0 1 auto; width: 92px; }
178
- .mc-live-size { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
179
- .mc-seg { display: inline-flex; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.2)); border-radius: 8px; overflow: hidden; }
180
- .mc-seg button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 11px; padding: 4px 9px; cursor: pointer; opacity: .6; }
181
- .mc-seg button[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); color: #4285f4; opacity: 1; }
182
- .mc-live-stage.fit { width: min(100%, calc(60vh * var(--mc-ar-n, 0.45))); aspect-ratio: var(--mc-ar-n, 0.45); display: flex; align-items: center; justify-content: center; margin-inline: auto; }
183
- .mc-live-stage.fit img { width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
184
- .mc-live-stage.bezel { padding: 8px; background: #111; border-radius: 16px; }
185
- .mc-live-stage.device { padding: 12px; background: linear-gradient(145deg, #2c2f36, #17191d); border-radius: 26px; box-shadow: 0 10px 30px rgba(0,0,0,.45); }
186
- /* ── 0.7.0: compact conversation cards ── */
187
- .mc-card-compact { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)); border-radius: 10px; font-size: 12px; background: light-dark(rgba(0,0,0,.02), rgba(255,255,255,.03)); }
188
- .mc-card-compact .mc-card-kind { font-weight: 700; }
189
- .mc-badge.ok { background: rgba(76,175,80,.16); color: #4caf50; }
190
- .mc-card-open { font-size: 11px; color: #4285f4; cursor: pointer; border: 0; background: transparent; padding: 2px 4px; font: inherit; }
191
- /* ── 0.7.0: composer status capsule ── */
192
- .mc-capsule { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid rgba(76,175,80,.4); border-radius: 999px; background: rgba(76,175,80,.1); color: #4caf50; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
193
- .mc-capsule:hover { background: rgba(76,175,80,.18); }
194
- `;
195
- //#endregion
196
-
197
- const API_BASE = "/api/dsh-mobilecode";
198
- // Shown on both stream captions so a refresh visibly proves which client
199
- // bundle the browser is actually running. Kept in lockstep with
200
- // package.json by test/client.mjs.
201
- const MC_VERSION = "0.11.9";
202
- // The panel shows cards for platforms the host can actually drive; the
203
- // server reports its OS in info.os (iOS tooling is darwin-only).
204
- const fallbackPlatforms = (os) => (os === "darwin" ? ["ios", "android"] : ["android"]);
205
- const label = (p) => (p === "ios" ? "iOS" : "Android");
206
- // Shared by both panes so Device 1 and Device 2 scale identically (0.11.0).
207
- const stageSizeStyle = (m) => (m.mode === "pct"
208
- ? { width: m.value + "%", maxWidth: "100%", maxHeight: "60vh" }
209
- : m.mode === "px"
210
- ? { width: m.value + "px", maxWidth: "100%", maxHeight: "60vh" }
211
- : {});
212
- // Fit panes lock to a shared stage height so devices with different
213
- // aspect ratios still sit at the same height (0.11.3); presets stay
214
- // width-driven (S·240 / M·320 = device width px).
215
- const stageClassFor = (f, fit = false) => "mc-live-stage" + (fit ? " fit" : "") + (f !== "none" ? " " + f : "");
216
- const SIZE_PRESETS = [
217
- { label: "Fit", mode: { mode: "fit" } },
218
- { label: "100%", mode: { mode: "pct", value: 100 } },
219
- { label: "S · 240", mode: { mode: "px", value: 240 } },
220
- { label: "M · 320", mode: { mode: "px", value: 320 } },
221
- ];
222
- /** Size presets + px/% select + frame seg — shared by both stream panes
223
- * (0.11.2) so Device 2 gets the same controls as Device 1. */
224
- function StageControls({ sizeMode, setSizeMode, frame, setFrame }) {
225
- const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
226
- return h("div", { className: "mc-live-size" },
227
- h("span", { className: "mc-hint" }, "Size"),
228
- h("div", { className: "mc-seg" }, SIZE_PRESETS.map((p) => h("button", { key: p.label, "data-on": isCurrent(p.mode) || undefined, onClick: () => setSizeMode(p.mode) }, p.label))),
229
- h("select", { className: "mc-input mc-live-sizesel", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
230
- const v = e.target.value;
231
- if (v === "fit") setSizeMode({ mode: "fit" });
232
- else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
233
- else setSizeMode({ mode: "px", value: Number(v) });
234
- } },
235
- h("option", { value: "fit" }, "fit"),
236
- h("option", { value: "50%" }, "50%"),
237
- h("option", { value: "75%" }, "75%"),
238
- h("option", { value: "100%" }, "100%"),
239
- h("option", { value: "125%" }, "125%"),
240
- h("option", { value: "240" }, "S · 240px"),
241
- h("option", { value: "320" }, "M · 320px"),
242
- h("option", { value: "420" }, "L · 420px"),
243
- ),
244
- h("span", { className: "mc-hint" }, "Frame"),
245
- h("div", { className: "mc-seg" },
246
- h("button", { "data-on": frame === "none" || undefined, onClick: () => setFrame("none") }, "none"),
247
- h("button", { "data-on": frame === "bezel" || undefined, onClick: () => setFrame("bezel") }, "bezel"),
248
- h("button", { "data-on": frame === "device" || undefined, onClick: () => setFrame("device") }, "device"),
249
- ),
250
- );
251
- }
252
-
253
- // Shared SVG icon paths for the stream toolbars (0.11.5 — both panes
254
- // carry the same navigation controls; Device 2 used to lack them).
255
- const NAV_ICONS = {
256
- back: "M15 18l-6-6 6-6",
257
- home: "M3 10.5L12 3l9 7.5M5 9.5V21h14V9.5",
258
- recents: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5",
259
- screenshot: "M4 7h4l2-3h4l2 3h4v13H4zM12 17a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
260
- rotate: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6",
261
- };
262
- /** One icon toolbar row; items: {title, icon, run}. Used by both panes. */
263
- function ToolbarRow({ items }) {
264
- return h("div", { className: "mc-toolbar" },
265
- items.map((item) => h("button", { key: item.title, title: item.title, onClick: item.run },
266
- h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
267
- h("path", { d: item.icon }),
268
- ),
269
- )),
270
- );
271
- }
272
- /** Back/Home/Recents/(Screen)/Rotate built from nav icons (0.11.5). */
273
- const navToolbar = (control, capture, refresh) => [
274
- { title: "Back", icon: NAV_ICONS.back, run: () => control({ kind: "button", name: "back" }) },
275
- { title: "Home", icon: NAV_ICONS.home, run: () => control({ kind: "button", name: "home" }) },
276
- { title: "Recents", icon: NAV_ICONS.recents, run: () => control({ kind: "button", name: "recents" }) },
277
- ...(capture ? [{ title: "Screenshot", icon: NAV_ICONS.screenshot, run: () => capture() }] : []),
278
- { title: "Rotate", icon: NAV_ICONS.rotate, run: () => control({ kind: "rotate" }) },
279
- { title: "Refresh", icon: NAV_ICONS.rotate, run: refresh },
280
- ];
281
- /** ☰ device-actions popover; serial-bound, shared by both panes. */
282
- function DeviceMenu({ serial, onError }) {
283
- const [open, setOpen] = useState(false);
284
- const ACTION_ITEMS = [
285
- { action: "notifications", label: "Notifications" },
286
- { action: "quick_settings", label: "Quick settings" },
287
- { action: "collapse", label: "Collapse" },
288
- { action: "lock", label: "Lock" },
289
- { action: "wake", label: "Wake" },
290
- { action: "assistant", label: "Assistant" },
291
- ];
292
- const run = async (action) => {
293
- if (serial === "") return;
294
- try { await streamPost("/stream/device-action", { device: serial, action }); setOpen(false); }
295
- catch (err) { onError(err instanceof Error ? err.message : String(err)); }
296
- };
297
- return h("div", { className: "mc-menu" },
298
- h("button", { className: "mc-btn", disabled: serial === "", title: "Device actions", onClick: () => setOpen((v) => !v) }, "☰"),
299
- open && h("div", { className: "mc-menu-pop" },
300
- ACTION_ITEMS.map((item) => h("button", { key: item.action, onClick: () => run(item.action) }, item.label)),
301
- ),
302
- );
303
- }
304
-
305
- function PanelController() {
306
- let panelOpen = false;
307
- const listeners = new Set();
308
- return {
309
- getSnapshot: () => ({ panelOpen }),
310
- subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
311
- setOpen: (value) => { if (panelOpen === value) return; panelOpen = value; for (const fn of listeners) fn(); },
312
- toggle: () => { const value = !panelOpen; panelOpen = value; for (const fn of listeners) fn(); },
313
- };
314
- }
315
-
316
- const getInfo = async (directory) => {
317
- const url = location.origin + API_BASE + (directory ? "?directory=" + encodeURIComponent(directory) : "");
318
- const response = await fetch(url);
319
- if (!response.ok) throw new Error("HTTP " + response.status);
320
- return response.json();
321
- };
322
- const post = async (path, body) => {
323
- const response = await fetch(location.origin + API_BASE + path, {
324
- method: "POST",
325
- headers: { "content-type": "application/json" },
326
- body: JSON.stringify(body ?? {}),
327
- });
328
- if (!response.ok) throw new Error("HTTP " + response.status);
329
- return response.json();
330
- };
331
- const apiGet = async (path) => {
332
- const response = await fetch(location.origin + API_BASE + path);
333
- if (!response.ok) throw new Error("HTTP " + response.status);
334
- return response.json();
335
- };
336
- // POST that surfaces the server's JSON error copy (stream routes return
337
- // human-readable 4xx bodies the generic post() would flatten to "HTTP 409").
338
- const streamPost = async (path, body) => {
339
- const response = await fetch(location.origin + API_BASE + path, {
340
- method: "POST",
341
- headers: { "content-type": "application/json" },
342
- body: JSON.stringify(body ?? {}),
343
- });
344
- const text = await response.text();
345
- let json;
346
- try { json = JSON.parse(text); } catch { json = {}; }
347
- if (!response.ok) throw new Error(json.error || ("HTTP " + response.status));
348
- return json;
349
- };
350
- const copyText = async (text) => {
351
- try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
352
- };
353
-
354
- const readDir = () => { try { return localStorage.getItem("dsh-mobilecode-directory") ?? ""; } catch { return ""; } };
355
- const writeDir = (value) => { try { localStorage.setItem("dsh-mobilecode-directory", value); } catch { /* private mode */ } };
356
-
357
- function MobileCodePanel({ controller }) {
358
- const [directory, setDirectory] = useState(readDir);
359
- const [info, setInfo] = useState(null);
360
- const [error, setError] = useState("");
361
- const [busy, setBusy] = useState("");
362
- const [openLogs, setOpenLogs] = useState({});
363
- const [frameKey, setFrameKey] = useState(0);
364
- const [settingsOpen, setSettingsOpen] = useState(false);
365
-
366
- // Poll while the panel is open.
367
- const open = controller.getSnapshot().panelOpen;
368
- useEffect(() => {
369
- if (!open) return;
370
- let stopped = false;
371
- let timer;
372
- const poll = async () => {
373
- try {
374
- const current = await getInfo(readDir());
375
- if (!stopped) { setInfo(current); setError(""); }
376
- } catch (err) {
377
- if (!stopped) setError(err instanceof Error ? err.message : String(err));
378
- }
379
- if (!stopped) timer = setTimeout(poll, 2000);
380
- };
381
- poll();
382
- return () => { stopped = true; clearTimeout(timer); };
383
- }, [open]);
384
-
385
- const act = async (name, path, body) => {
386
- setBusy(name);
387
- setError("");
388
- try {
389
- const current = await post(path, { directory, ...body });
390
- setInfo(current);
391
- } catch (err) {
392
- setError(err instanceof Error ? err.message : String(err));
393
- } finally {
394
- setBusy("");
395
- }
396
- };
397
-
398
- const detect = async () => {
399
- setBusy("detect");
400
- setError("");
401
- try {
402
- const current = await getInfo(directory);
403
- setInfo(current);
404
- writeDir(directory);
405
- } catch (err) {
406
- setError(err instanceof Error ? err.message : String(err));
407
- } finally {
408
- setBusy("");
409
- }
410
- };
411
-
412
- const server = (platform) => info?.servers?.find((s) => s.platform === platform);
413
- const build = (platform) => info?.builds?.find((b) => b.platform === platform);
414
- const platforms = info?.platforms?.length > 0 ? info.platforms : fallbackPlatforms(info?.os);
415
-
416
- const statusClass = (status) =>
417
- status === "running" ? "on" : status === "starting" ? "warn" : status === "exited" ? "off" : "err";
418
-
419
- const toggleLog = (key) => setOpenLogs((prev) => ({ ...prev, [key]: !prev[key] }));
420
-
421
- const logsOf = (entries) => {
422
- const lines = [];
423
- for (const entry of entries ?? []) {
424
- lines.push(`[${entry.platform}] ${entry.status}${entry.step ? " — " + entry.step : ""}`);
425
- if (entry.error) lines.push(` error: ${entry.error}`);
426
- }
427
- return lines.join("\n");
428
- };
429
-
430
- return h("div", { className: "mc-panel" },
431
- h("div", { className: "mc-header" },
432
- h("h2", { className: "mc-title" }, "Devices"),
433
- h("button", {
434
- className: "mc-close",
435
- title: "Settings — doctor, PaddleOCR, AI prompt",
436
- onClick: () => setSettingsOpen(true),
437
- }, "⚙"),
438
- h("button", { className: "mc-close", onClick: () => controller.setOpen(false) }, "✕"),
439
- ),
440
- h("div", { className: "mc-body" },
441
- h("div", { className: "mc-row" },
442
- h("input", {
443
- className: "mc-input",
444
- placeholder: "Project directory (leave empty for the session default)",
445
- value: directory,
446
- onChange: (e) => setDirectory(e.target.value),
447
- onKeyDown: (e) => { if (e.key === "Enter") detect(); },
448
- }),
449
- h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: detect }, "Detect"),
450
- ),
451
- error !== "" && h("div", { className: "mc-error" }, error),
452
- h("div", { className: "mc-pills" },
453
- platforms.map((platform) => {
454
- // 0.10.0: Android's "server" IS the Device 1 stream — the pill
455
- // tracks attached devices, not a serve-avd process.
456
- const running = platform === "android"
457
- ? (info?.deviceCount ?? 0) > 0
458
- : server(platform)?.status === "running";
459
- return h("span", { key: platform, className: "mc-pill", "data-on": running ? "true" : undefined,
460
- title: platform === "android" ? (running ? "Android device attached — mirrored in Device 1" : "No Android device attached") : "Preview server running" },
461
- h("span", { className: "mc-dot " + (running ? "on" : "off") }),
462
- label(platform),
463
- );
464
- }),
465
- ),
466
- h(LiveStreamSection, null),
467
- platforms.map((platform) => {
468
- const srv = server(platform);
469
- const bld = build(platform);
470
- // 0.10.0 merge: "Start server" (serve-avd iframe) and "Device 1" were
471
- // two views of the same screen. The in-process stream won; the Android
472
- // card keeps project controls only. iOS keeps serve-sim (its only view).
473
- const preview = platform === "ios";
474
- return h("div", { key: platform, className: "mc-card" },
475
- h("div", { className: "mc-card-head" },
476
- h("span", null, label(platform)),
477
- h("span", { className: "sp" }),
478
- preview && (srv
479
- ? h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => act(platform + "-stop-server", "/stop", { platform }) },
480
- srv.status === "running" ? "Stop server" : "Server " + srv.status)
481
- : h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: () => act(platform + "-start-server", "/start", { platform }) }, "Start server")),
482
- !preview && h("span", { className: "mc-live-cap" }, "screen: Device 1 ↑"),
483
- ),
484
- preview && (srv?.url
485
- ? h("iframe", {
486
- key: frameKey,
487
- className: "mc-frame",
488
- src: srv.url,
489
- sandbox: "allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-downloads",
490
- title: label(platform) + " preview",
491
- })
492
- : srv?.status === "starting"
493
- ? h("div", { className: "mc-status" }, h("span", null, h("span", { className: "mc-dot warn" }), " Starting preview server…"))
494
- : null),
495
- h("div", { className: "mc-row" },
496
- h("button", {
497
- className: "mc-btn primary",
498
- disabled: busy !== "",
499
- onClick: () => { act(platform + "-run", "/run", { platform }); setFrameKey((k) => k + 1); },
500
- }, "Run app"),
501
- h("button", { className: "mc-btn danger", disabled: busy !== "", onClick: () => act(platform + "-stop", "/run/stop", { platform }) }, "Stop app"),
502
- ),
503
- bld
504
- ? h("div", { className: "mc-status" },
505
- h("span", null,
506
- h("span", { className: "mc-dot " + statusClass(bld.status) }),
507
- " Build: ", h("b", null, bld.status),
508
- bld.step ? " — " + bld.step : "",
509
- bld.target ? " · target " + bld.target : "",
510
- bld.appID ? " · " + bld.appID : "",
511
- ),
512
- bld.error !== undefined && bld.error !== "" && h("div", { className: "mc-error" }, bld.error),
513
- bld.log.length > 0 &&
514
- h("button", { className: "mc-log-toggle", onClick: () => toggleLog(platform + "-build") },
515
- openLogs[platform + "-build"] ? "Hide log" : "Show log (" + bld.log.length + ")"),
516
- openLogs[platform + "-build"] && h("pre", { className: "mc-log" }, bld.log.join("\n")),
517
- )
518
- : h("div", { className: "mc-empty" }, "Nothing built yet."),
519
- );
520
- }),
521
- info?.bundler &&
522
- h("div", { className: "mc-card" },
523
- h("div", { className: "mc-card-head" },
524
- h("span", null, "Metro"),
525
- h("span", { className: "mc-dot " + statusClass(info.bundler.status) }),
526
- h("span", { className: "sp" }),
527
- h("span", null, info.bundler.status + (info.bundler.url ? " · " + info.bundler.url : "")),
528
- ),
529
- info.bundler.log.length > 0 &&
530
- h("button", { className: "mc-log-toggle", onClick: () => toggleLog("bundler") },
531
- openLogs["bundler"] ? "Hide log" : "Show log (" + info.bundler.log.length + ")"),
532
- openLogs["bundler"] && h("pre", { className: "mc-log" }, info.bundler.log.join("\n")),
533
- ),
534
- info?.servers?.length === 0 && info?.builds?.length === 0 && (info?.deviceCount ?? 0) === 0 &&
535
- h("div", { className: "mc-empty" }, "Nothing running yet — pick a project directory and press Run app, or boot an AVD in Device 1 above."),
536
- ),
537
- h("div", { className: "mc-footer" },
538
- h("span", null, "dsh-mobilecode · polls every 2s"),
539
- info && info.platforms.length > 0 && h("span", null, "detected: " + info.platforms.map(label).join(", ")),
540
- ),
541
- settingsOpen && h(SettingsDialog, { onClose: () => setSettingsOpen(false) }),
542
- );
543
- }
544
-
545
- // 0.7.0: the conversation cards (tool.call.toolview) and the composer
546
- // capsule (conversation.input.dock) live outside our panel tree, so the
547
- // controller they need to open the panel is a module-level reference set
548
- // by apply() before the slots register. Auto-open fires exactly once per
549
- // page load (a settled boot/stream card opens the panel).
550
- let activeController = null;
551
- let autoOpenedOnce = false;
552
- /** Stable stand-in store so the capsule hooks are unconditional even before apply() runs. */
553
- const DUMMY_STORE = { subscribe: () => () => {}, getSnapshot: () => ({ panelOpen: true }) };
554
-
555
- /** Extract text from a tool-result block (string content or pages of {type,text}). */
556
- function blockText(block) {
557
- if (typeof block?.content === "string") return block.content;
558
- if (Array.isArray(block?.content)) return block.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
559
- return "";
560
- }
561
-
562
- /** Compact conversation card for tools that emit android-stream meta.
563
- * ToolCallBlock model (matches the rc.6 runtime): a settled result has
564
- * `kind: "tool-result"` + `isError`; a running block has neither, so it
565
- * resolves here to null and the runtime's own running UI shows. */
566
- function MobileCodeToolCard(props) {
567
- const block = props?.block ?? null;
568
- const toolName = props?.toolName ?? block?.toolName ?? "";
569
- const settled = block !== null && typeof block === "object" && "kind" in block;
570
- const error = settled && block.isError === true;
571
- // block.meta IS the host-projected presentationMeta envelope (see
572
- // dsh-android parseAndroidMeta). Nested calls that carry no meta fall
573
- // back to hydrating the serial from the durable text.
574
- const meta = settled && !error ? block?.meta ?? null : null;
575
- const kind = meta?.kind ?? null;
576
- const serial = meta?.device?.serial
577
- ?? (/(emulator-\d+|(?:RFC[0-9A-F]{7,}|[A-Za-z0-9]{6,}))/i.exec(blockText(block))?.[1] ?? "");
578
- // Auto-open the panel once when a stream settles (boot/stream start).
579
- useEffect(() => {
580
- if (settled && kind === "android-stream" && !autoOpenedOnce && activeController) {
581
- autoOpenedOnce = true;
582
- activeController.setOpen(true);
583
- }
584
- }, [settled, kind]);
585
- if (!settled || kind !== "android-stream") return null;
586
- const badge = error ? "failed" : "streaming";
587
- return h("div", { className: "mc-card-compact" },
588
- h("span", { className: "mc-card-kind" }, "Android stream"),
589
- serial !== "" && h("span", { className: "mc-serial" }, serial),
590
- h("span", { className: "mc-badge" + (error ? " err" : " ok") }, badge),
591
- h("span", { className: "sp" }),
592
- activeController && h("button", { className: "mc-card-open", onClick: () => activeController.setOpen(true) }, "⤢ open in panel"),
593
- );
594
- }
595
-
596
- /** Composer capsule: green "● <serial>" pill while a device streams and the panel is closed. */
597
- function MobileCodeStatusCapsule() {
598
- const [running, setRunning] = useState(false);
599
- const [status, setStatus] = useState(null);
600
- const store = activeController ?? DUMMY_STORE;
601
- const { panelOpen } = useSyncExternalStore(store.subscribe, store.getSnapshot);
602
- const poll = async () => {
603
- try {
604
- const r = await streamPost("/stream/status", {});
605
- setRunning(r.running === true);
606
- setStatus(r);
607
- } catch {
608
- setRunning(false);
609
- setStatus(null);
610
- }
611
- };
612
- useEffect(() => {
613
- poll();
614
- const timer = setInterval(() => { if (!panelOpen) poll(); }, 5000);
615
- return () => clearInterval(timer);
616
- }, [panelOpen]);
617
- if (!running || panelOpen) return null;
618
- return h("button", { className: "mc-capsule", onClick: () => activeController?.setOpen(true) },
619
- h("span", { className: "mc-dot on" }),
620
- h("span", null, status?.serial ?? "device stream"),
621
- );
622
- }
623
-
624
- /** Shared stream lifecycle (0.9.0 co-op): HMAC-granted frame URL with a
625
- * re-mint timer, plus tap/drag pointer mapping for the <img>. Used by
626
- * the full card and by the second co-op pane. */
627
- function useLiveStream({ serial, onError }) {
628
- const [streamUrl, setStreamUrl] = useState("");
629
- // Native aspect ratio (w/h) of the streamed frames, learned from the
630
- // first decoded MJPEG frame so the Fit stage has the device's real
631
- // shape — zero letterboxing regardless of pane/device mix (0.11.5).
632
- const [ar, setAr] = useState(null);
633
- const imgRef = useRef(null);
634
- const dragRef = useRef(null);
635
- const grantTimer = useRef(null);
636
- const serialRef = useRef(serial);
637
- serialRef.current = serial;
638
- const onErrorRef = useRef(onError);
639
- onErrorRef.current = onError;
640
-
641
- const grant = async (device) => {
642
- if (!device) return false;
643
- try {
644
- const r = await streamPost("/stream/grant", { device });
645
- // Absolute URL: the plugin card can be hosted under a different
646
- // origin/path than the API, and <img> src ignores fetch()'s base.
647
- setStreamUrl(location.origin + r.streamUrl);
648
- clearTimeout(grantTimer.current);
649
- // Re-mint a minute before the 10-minute capability expires.
650
- const ms = Math.max(30000, (r.expiresAt - Date.now()) - 60000);
651
- grantTimer.current = setTimeout(() => { grant(r.device).catch(() => {}); }, ms);
652
- return true;
653
- } catch (err) {
654
- onErrorRef.current(err instanceof Error ? err.message : String(err));
655
- setStreamUrl("");
656
- return false;
657
- }
658
- };
659
- const reset = () => { clearTimeout(grantTimer.current); setStreamUrl(""); };
660
- useEffect(() => () => clearTimeout(grantTimer.current), []);
661
- // Presence watcher (0.11.3): an MJPEG <img> does NOT fire error when
662
- // the device dies — it just freezes on the last frame with the Live
663
- // badge stuck. While a stream is up, poll device presence and drop
664
- // the stream if the serial vanished (⏻ off, crash, unplug).
665
- useEffect(() => {
666
- if (streamUrl === "") return;
667
- const watch = setInterval(async () => {
668
- try {
669
- const r = await streamPost("/stream/devices", {});
670
- if (!r.devices.some((d) => d.serial === serialRef.current)) {
671
- clearTimeout(grantTimer.current);
672
- setStreamUrl("");
673
- onErrorRef.current("device offline — stream stopped");
674
- }
675
- } catch { /* the next tick decides */ }
676
- }, 5000);
677
- return () => clearInterval(watch);
678
- }, [streamUrl]);
679
-
680
- const control = async (action) => {
681
- if (serialRef.current === "") return;
682
- try { await streamPost("/stream/control", { device: serialRef.current, action }); }
683
- catch (err) { onErrorRef.current(err instanceof Error ? err.message : String(err)); }
684
- };
685
- const norm = (clientX, clientY) => {
686
- const el = imgRef.current;
687
- if (!el) return null;
688
- const rect = el.getBoundingClientRect();
689
- if (rect.width === 0 || rect.height === 0) return null;
690
- return {
691
- x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
692
- y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
693
- };
694
- };
695
- // Gesture coalescing: remember the down point + timestamp; on up,
696
- // taps (<2% travel) vs drags keep the real duration (clamped 0..5s).
697
- const onDown = (e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = { ...p, t: Date.now() }; };
698
- const onUp = (e) => {
699
- const start = dragRef.current;
700
- dragRef.current = null;
701
- const end = norm(e.clientX, e.clientY);
702
- if (!start || !end) return;
703
- const durationMs = Math.max(0, Math.min(5000, Date.now() - start.t));
704
- if (Math.hypot(end.x - start.x, end.y - start.y) > 0.02) {
705
- control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y, durationMs });
706
- } else {
707
- control({ kind: "tap", x: end.x, y: end.y });
708
- }
709
- };
710
- // Aspect-ratio learner: MJPEG frames decode into the same <img>, so
711
- // naturalWidth/naturalHeight is live. Poll once a second while the
712
- // stream is up; stage boxes read --mc-ar-n from here (fit mode).
713
- useEffect(() => {
714
- if (streamUrl === "") return;
715
- const tick = setInterval(() => {
716
- const el = imgRef.current;
717
- if (el && el.naturalWidth > 0 && el.naturalHeight > 0) {
718
- const a = +(el.naturalWidth / el.naturalHeight).toFixed(4);
719
- setAr((prev) => (prev === a ? prev : a));
720
- }
721
- }, 1000);
722
- return () => clearInterval(tick);
723
- }, [streamUrl]);
724
-
725
- return { streamUrl, grant, reset, control, ar, imgProps: { ref: imgRef, draggable: false, onPointerDown: onDown, onPointerUp: onUp } };
726
- }
727
-
728
- /** Device 1 stream: picker-popover header, SVG toolbar pill, device
729
- * menu, quick sizes + frame styles, and tap/drag with duration. */
730
- function LiveDeviceCard({ coopOn, onToggleCoop, onSerial }) {
731
- const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
732
- const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
733
- const [devices, setDevices] = useState([]);
734
- const [avds, setAvds] = useState([]);
735
- const [serial, setSerial] = useState("");
736
- const [error, setError] = useState("");
737
- const [pickerOpen, setPickerOpen] = useState(false);
738
- const [still, setStill] = useState(null);
739
- const [view, setView] = useState("live"); // 'live' | 'still'
740
- // sizeMode/frame are per-pane (0.11.2); StageControls renders the
741
- // identical row in both cards, and Fit locks both to one height.
742
- const [booting, setBooting] = useState(""); // AVD name while POST /boot is in flight
743
- const [powering, setPowering] = useState(""); // serial while POST /off is in flight
744
- const live = useLiveStream({ serial, onError: setError });
745
- const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
746
- const control = live.control;
747
- const serialRef = useRef(serial);
748
- serialRef.current = serial;
749
- const autoPicked = useRef(false);
750
- useEffect(() => { if (onSerial) onSerial(serial); }, [serial]);
751
-
752
- const refreshDevices = async () => {
753
- try {
754
- const r = await streamPost("/stream/devices", {});
755
- setDevices(r.devices);
756
- setAvds(r.avds ?? []);
757
- // Prefer emulators for the welcome auto-pick: a physical
758
- // serial sorts first in adb order but has no ⏻ off control.
759
- // (Serial prefix, not the avd field — avdName() can fail.)
760
- const isEmu = (d) => d.serial.startsWith("emulator-");
761
- const first = r.devices.find((d) => d.streaming && isEmu(d))?.serial ?? r.devices.find(isEmu)?.serial
762
- ?? r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? "";
763
- const cur = serialRef.current;
764
- if (cur === "") {
765
- // Auto-stream once per page load (the welcome flow). After
766
- // an explicit ⏻ off, the card stays idle — it must NOT
767
- // silently grab the partner device (0.11.3).
768
- if (!autoPicked.current && first !== "") { autoPicked.current = true; setSerial(first); }
769
- } else if (!r.devices.some((d) => d.serial === cur)) {
770
- autoPicked.current = true;
771
- setSerial(""); live.reset();
772
- }
773
- } catch (err) {
774
- setError(err instanceof Error ? err.message : String(err));
775
- }
776
- };
777
-
778
- useEffect(() => {
779
- refreshDevices();
780
- const timer = setInterval(refreshDevices, 5000);
781
- return () => clearInterval(timer);
782
- }, []);
783
-
784
- // Auto-grant once a serial is known, no stream, and we're in live view.
785
- useEffect(() => { if (serial !== "" && live.streamUrl === "" && view === "live") grant(serial); }, [serial, live.streamUrl, view]);
786
-
787
- const capture = async () => {
788
- if (serial === "") return;
789
- try {
790
- const r = await streamPost("/stream/still", { device: serial });
791
- if (r.dataUrl) {
792
- setStill(r);
793
- setView("still");
794
- setError("");
795
- } else {
796
- setStill(null);
797
- setView("live");
798
- setError("still saved to " + (r.path ?? "a temp file") + " but too large to embed here");
799
- }
800
- } catch (err) {
801
- setError(err instanceof Error ? err.message : String(err));
802
- }
803
- };
804
-
805
- const pick = (device) => { autoPicked.current = true; setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
806
- // 0.10.0 merged "Start server": boot a configured AVD from the picker.
807
- // The route waits for boot completion; then we re-list and stream it.
808
- const boot = async (avd) => {
809
- setBooting(avd); setError("");
810
- try {
811
- const r = await streamPost("/boot", { avd });
812
- await refreshDevices();
813
- if (r.serial) pick(r.serial);
814
- } catch (err) {
815
- setError(err instanceof Error ? err.message : String(err));
816
- await refreshDevices();
817
- } finally {
818
- setBooting("");
819
- }
820
- };
821
- // 0.11.0 device power-off: graceful `adb emu kill` via POST /off.
822
- // If we just killed the streamed device, drop back to the picker.
823
- const powerOff = async (device) => {
824
- setPowering(device); setError("");
825
- try {
826
- await streamPost("/off", { serial: device });
827
- if (device === serial) { setSerial(""); live.reset(); }
828
- await refreshDevices();
829
- } catch (err) {
830
- setError(err instanceof Error ? err.message : String(err));
831
- await refreshDevices();
832
- } finally {
833
- setPowering("");
834
- }
835
- };
836
- const showLive = () => { setView("live"); if (serial !== "" && live.streamUrl === "") grant(serial); };
837
-
838
- const sizeStyle = stageSizeStyle(sizeMode);
839
- const stageClass = stageClassFor(frame, sizeMode.mode === "fit");
840
- const runningAvds = new Set(devices.map((d) => d.avd).filter(Boolean));
841
- const toolbar = navToolbar(control, capture, () => { if (serial !== "") grant(serial); });
842
-
843
- return h("div", { className: "mc-card mc-live" },
844
- h("div", { className: "mc-card-head" },
845
- h("span", null, "Device 1"),
846
- live.streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
847
- h("span", { className: "sp" }),
848
- h("div", { className: "mc-picker" },
849
- h("button", { className: "mc-btn", onClick: () => setPickerOpen((v) => !v) }, serial || "no device", " ▾"),
850
- pickerOpen && h("div", { className: "mc-picker-pop" },
851
- devices.length > 0
852
- ? h("div", { className: "mc-picker-group" },
853
- h("div", { className: "mc-picker-label" }, "Online devices"),
854
- devices.map((d) => h("div", { key: d.serial, className: "mc-picker-row" + (d.serial === serial ? " on" : ""), onClick: () => pick(d.serial) },
855
- h("span", null, d.kind === "emulator" ? "🖥" : "📱"),
856
- h("span", { className: "mc-serial" }, d.avd ? d.avd + " · " + d.serial : d.serial),
857
- d.streaming && h("span", { className: "mc-badge" }, "streaming"),
858
- d.kind === "emulator" && h("span", { className: "sp" }),
859
- d.kind === "emulator" && h("button", {
860
- className: "mc-btn", disabled: booting !== "" || powering !== "",
861
- title: "Shut down this emulator (adb emu kill)",
862
- onClick: (e) => { e.stopPropagation(); powerOff(d.serial); },
863
- }, powering === d.serial ? "stopping…" : "⏻ off"),
864
- )),
865
- )
866
- : h("div", { className: "mc-picker-hint" }, "No device attached"),
867
- // Running AVDs already have their row (with ⏻ off) above;
868
- // the boot list shows only the stopped ones.
869
- avds.filter((avd) => !runningAvds.has(avd)).length > 0 && h("div", { className: "mc-picker-group" },
870
- h("div", { className: "mc-picker-label" }, "AVDs"),
871
- avds.filter((avd) => !runningAvds.has(avd)).map((avd) => h("div", { key: avd, className: "mc-picker-row" },
872
- h("span", null, "▫ " + avd),
873
- h("span", { className: "sp" }),
874
- h("button", {
875
- className: "mc-btn", disabled: booting !== "" || powering !== "",
876
- title: "Boot this AVD (runs detached; it appears online when ready)",
877
- onClick: () => boot(avd),
878
- }, booting === avd ? "booting…" : "⏻ boot"),
879
- )),
880
- ),
881
- ),
882
- ),
883
- h(DeviceMenu, { serial, onError: setError }),
884
- // 0.9.0 co-op: side-by-side second pane (needs two online devices).
885
- devices.length >= 2 && h("button", { className: "mc-btn", "data-on": coopOn || undefined, title: "Co-op: watch Device 1 + Device 2 side by side", onClick: onToggleCoop }, "⧉"),
886
- ),
887
- error !== "" && h("div", { className: "mc-error" }, error),
888
- booting !== "" && h("div", { className: "mc-status" },
889
- h("span", null, h("span", { className: "mc-dot warn" }), " Booting " + booting + " — it appears here once online (about a minute).")),
890
- // 0.11.0 audit: no ghost affordances — the toolbar appears once a
891
- // device is selected (disabled-grey read as broken).
892
- serial !== "" && h(ToolbarRow, { items: toolbar }),
893
- h("div", { className: stageClass, style: sizeMode.mode === "fit" && live.ar != null ? { "--mc-ar-n": live.ar } : undefined },
894
- view === "still" && still?.dataUrl
895
- ? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
896
- : live.streamUrl !== ""
897
- ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "device screen", style: sizeStyle, onError: () => grant(serial) })
898
- : h("div", { className: "mc-live-off" }, serial === ""
899
- ? (devices.length === 0
900
- ? "No device attached — boot an AVD from the ▾ picker, or press Run app."
901
- : "Device was shut down — pick one from ▾, or ⏻ boot an AVD.")
902
- : "Connecting to " + serial + "..."),
903
- ),
904
- view === "still"
905
- ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
906
- h("a", { className: "mc-log-toggle", onClick: showLive }, "back to Live"))
907
- : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device · v" + MC_VERSION),
908
- h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
909
- );
910
- }
911
- /** Co-op second pane (0.9.0): a compact stream view for a second device —
912
- * device select, live image, tap/drag — and (0.11.2) its own Size/Frame
913
- * row, mirroring Device 1's controls. No toolbar/menu: those belong to
914
- * the full card. Defaults are identical, so the panes match out of the
915
- * box and can be adjusted independently. */
916
- function CoopPane({ excludeSerial }) {
917
- const [devices, setDevices] = useState([]);
918
- const [serial, setSerial] = useState("");
919
- const [sizeMode, setSizeMode] = useState({ mode: "fit" });
920
- const [frame, setFrame] = useState("none");
921
- const [error, setError] = useState("");
922
- const live = useLiveStream({ serial, onError: setError });
923
- const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
924
- // Serial mirror for the 5s poller + one-shot auto-pick guard: this
925
- // pane may steal a partner while nothing is picked, but it must NOT
926
- // silently switch to a third device after its own goes offline.
927
- const serialRef = useRef(serial);
928
- serialRef.current = serial;
929
- const autoPicked = useRef(false);
930
-
931
- const refresh = async () => {
932
- try {
933
- const r = await streamPost("/stream/devices", {});
934
- setDevices(r.devices);
935
- // Partner candidates: emulators only (serial prefix, not the
936
- // flaky avd field) — the co-op partner must be killable from
937
- // the picker, and never Device 1's own serial.
938
- const others = r.devices.filter((d) => d.serial !== excludeSerial && d.serial.startsWith("emulator-"));
939
- const cur = serialRef.current;
940
- if (cur !== "") {
941
- // Keep any valid pick — including a manually chosen physical
942
- // device; only emulators are eligible for auto-pick. A dead
943
- // or stolen pick clears to the placeholder (never re-grabs).
944
- if (cur !== excludeSerial && r.devices.some((d) => d.serial === cur)) return;
945
- autoPicked.current = true;
946
- setSerial(""); live.reset();
947
- return;
948
- }
949
- if (!autoPicked.current) {
950
- const next = (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
951
- if (next !== "") { autoPicked.current = true; setSerial(next); }
952
- }
953
- } catch {
954
- /* the header keeps the last device list */
955
- }
956
- };
957
-
958
- useEffect(() => {
959
- refresh();
960
- const timer = setInterval(refresh, 5000);
961
- return () => clearInterval(timer);
962
- }, [excludeSerial]);
963
-
964
- useEffect(() => { if (serial !== "" && live.streamUrl === "") grant(serial); }, [serial, live.streamUrl]);
965
-
966
- return h("div", { className: "mc-card mc-live mc-coop-pane" },
967
- h("div", { className: "mc-card-head" },
968
- h("span", null, "Device 2"),
969
- live.streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
970
- h("span", { className: "sp" }),
971
- h("select", { className: "mc-input mc-coop-select", value: serial, onChange: (e) => { setSerial(e.target.value); live.reset(); } },
972
- // An honest empty option: a select with no matching option
973
- // silently DISPLAYS devices[0] while state is still "" —
974
- // that made the probe read a phantom pick (0.11.4).
975
- !devices.some((d) => d.serial === serial) && h("option", { value: "" }, devices.length === 0 ? "no device" : "— pick device —"),
976
- devices.map((d) => h("option", { key: d.serial, value: d.serial }, d.serial + (d.serial === excludeSerial ? " (Device 1)" : d.streaming ? " ●" : ""))),
977
- ),
978
- h(DeviceMenu, { serial, onError: setError }),
979
- ),
980
- error !== "" && h("div", { className: "mc-error" }, error),
981
- // 0.11.5: Device 2 gets the same nav toolbar as Device 1
982
- // (back/home/recents/rotate/refresh) — full control of both players.
983
- serial !== "" && h(ToolbarRow, { items: navToolbar(live.control, undefined, () => { if (serial !== "") grant(serial); }) }),
984
- h("div", { className: stageClassFor(frame, sizeMode.mode === "fit"), style: sizeMode.mode === "fit" && live.ar != null ? { "--mc-ar-n": live.ar } : undefined },
985
- live.streamUrl !== ""
986
- ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "co-op device screen", style: stageSizeStyle(sizeMode), onError: () => grant(serial) })
987
- : h("div", { className: "mc-live-off" }, devices.filter((d) => d.serial !== excludeSerial).length === 0
988
- ? "No second device — boot another emulator."
989
- : serial === "" || !devices.some((d) => d.serial === serial)
990
- ? "Second device offline — pick one above."
991
- : "Connecting to " + serial + "..."),
992
- ),
993
- h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive this device · v" + MC_VERSION),
994
- h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
995
- );
996
- }
997
-
998
- /** Stream section: the classic single card, plus a co-op mode that docks
999
- * a second pane next to it for two-player testing. Each pane owns its
1000
- * Size/Frame knobs (0.11.2); the shared CSS base makes them match. */
1001
- function LiveStreamSection() {
1002
- const [coop, setCoop] = useState(false);
1003
- const [serialA, setSerialA] = useState("");
1004
- return h("div", { className: "mc-live-section" + (coop ? " coop" : "") },
1005
- h(LiveDeviceCard, { coopOn: coop, onToggleCoop: () => setCoop((v) => !v), onSerial: setSerialA }),
1006
- coop && h(CoopPane, { excludeSerial: serialA }),
1007
- );
1008
- }
1009
-
1010
- //#region setup UI (welcome + settings)
1011
-
1012
- /** PaddleOCR status card: fetch /ocr, Install button, log tail. */
1013
- function OcrCard({ compact }) {
1014
- const [status, setStatus] = useState(null);
1015
- const [error, setError] = useState("");
1016
- const [busy, setBusy] = useState(false);
1017
-
1018
- const refresh = async () => {
1019
- try { setStatus(await apiGet("/ocr")); } catch (err) { setError(err.message); }
1020
- };
1021
- useEffect(() => {
1022
- refresh();
1023
- const timer = setInterval(refresh, 3000); // follow a running install
1024
- return () => clearInterval(timer);
1025
- }, []);
1026
-
1027
- const install = async () => {
1028
- setBusy(true);
1029
- setError("");
1030
- try { await post("/ocr/install"); } catch (err) { setError(err.message); }
1031
- setBusy(false);
1032
- refresh();
1033
- };
1034
-
1035
- const state = status?.state ?? "idle";
1036
- const ok = status?.working === true;
1037
- const installing = state === "installing";
1038
- const failed = state === "failed";
1039
- const badge = ok ? "Ready" : failed ? "Failed" : installing ? "Installing…" : "Not installed";
1040
-
1041
- return h("div", { className: "mc-card" },
1042
- h("div", { className: "mc-card-head" },
1043
- h("span", null, "PaddleOCR"),
1044
- h("span", { className: "mc-dot " + (ok ? "on" : failed ? "err" : installing ? "warn" : "off") }),
1045
- h("b", null, badge),
1046
- h("span", { className: "sp" }),
1047
- !ok && !installing && h("button", { className: "mc-btn primary", disabled: busy, onClick: install }, "Install"),
1048
- ),
1049
- h("div", { className: "mc-status" },
1050
- ok
1051
- ? h("span", null, "device_screen reads text from screenshots with local OCR.")
1052
- : h("span", null, "Without it, device_screen returns the screenshot and UI tree but no OCR text — install to make the plugin work perfectly."),
1053
- h("span", { className: "mc-hint" }, "PaddleOCR 3.x · fully local · one-time install (~2 min, needs network once)"),
1054
- ),
1055
- error !== "" && h("div", { className: "mc-error" }, error),
1056
- (installing || failed) && (status?.log?.length > 0) && h("pre", { className: "mc-logline" }, status.log.join("\n")),
1057
- !compact && h("div", { className: "mc-status" },
1058
- h("span", null, ok ? "OCR is available to every AI via device_screen." : "Run the doctor after installing to confirm everything works."),
1059
- ),
1060
- );
1061
- }
1062
-
1063
- /** Doctor view: run /doctor checks, show ✓/✗ with detail, Fix buttons. */
1064
- function DoctorView({ onFixed }) {
1065
- const [checks, setChecks] = useState(null);
1066
- const [error, setError] = useState("");
1067
- const [busy, setBusy] = useState("");
1068
- const [fixResult, setFixResult] = useState("");
1069
-
1070
- const run = async () => {
1071
- setChecks(null);
1072
- setError("");
1073
- setFixResult("");
1074
- try { setChecks((await apiGet("/doctor")).checks); } catch (err) { setError(err.message); }
1075
- };
1076
- useEffect(() => { run(); }, []);
1077
-
1078
- const fix = async (id) => {
1079
- setBusy(id);
1080
- setFixResult("");
1081
- try {
1082
- const result = await post("/doctor/fix", { id });
1083
- setFixResult(result.message ?? "Fix requested.");
1084
- await new Promise((r) => setTimeout(r, 1500));
1085
- await run();
1086
- onFixed?.();
1087
- } catch (err) {
1088
- setError(err.message);
1089
- } finally {
1090
- setBusy("");
1091
- }
1092
- };
1093
-
1094
- return h("div", { className: "mc-body", style: { padding: "12px 0 0" } },
1095
- h("div", { className: "mc-row" },
1096
- h("button", { className: "mc-btn primary", disabled: checks === null, onClick: run }, "Re-run checks"),
1097
- checks !== null && h("span", { className: "mc-hint" },
1098
- checks.filter((c) => c.ok).length + "/" + checks.length + " checks passing"),
1099
- ),
1100
- error !== "" && h("div", { className: "mc-error" }, error),
1101
- fixResult !== "" && h("div", { className: "mc-status" }, fixResult),
1102
- checks === null && !error && h("div", { className: "mc-row" }, h("span", { className: "mc-spin" }), " Running checks…"),
1103
- checks?.map((check) =>
1104
- h("div", { key: check.name, className: "mc-check" },
1105
- h("span", { className: "mc-check-ico", style: { color: check.ok ? "#4caf50" : "#ef5350" } }, check.ok ? "✓" : "✗"),
1106
- h("span", { className: "mc-check-detail" },
1107
- h("span", { className: "mc-check-name" }, check.name),
1108
- " — ", check.detail,
1109
- check.fix && !check.ok && h("span", null,
1110
- " ", h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => fix(check.fix) },
1111
- busy === check.fix ? "Fixing…" : "Fix")),
1112
- ),
1113
- ),
1114
- ),
1115
- );
1116
- }
1117
-
1118
- /** Settings dialog: tabs for Doctor / PaddleOCR / AI prompt. */
1119
- /** Shared settings tabs (Doctor / PaddleOCR / AI Prompt / Connection) — used by both the ⚙ dialog and the DSH Settings page. */
1120
- function SettingsTabs() {
1121
- const [tab, setTab] = useState("doctor");
1122
- const [prompt, setPrompt] = useState("");
1123
- const [copied, setCopied] = useState(false);
1124
- const [conn, setConn] = useState(null);
1125
- const [connError, setConnError] = useState("");
1126
- const [wifiHost, setWifiHost] = useState("");
1127
- const [wifiPort, setWifiPort] = useState("5555");
1128
- const [pairCode, setPairCode] = useState("");
1129
- const [pairPort, setPairPort] = useState("37000");
1130
- const [connBusy, setConnBusy] = useState("");
1131
- const [connMsg, setConnMsg] = useState("");
1132
- const [qrText, setQrText] = useState("");
1133
- useEffect(() => {
1134
- apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
1135
- }, []);
1136
- useEffect(() => {
1137
- if (tab !== "connection") return;
1138
- setConn(null); setConnError("");
1139
- apiGet("/connection").then(setConn).catch((e) => setConnError(e instanceof Error ? e.message : String(e)));
1140
- }, [tab]);
1141
-
1142
- const refreshConn = () => {
1143
- setConnError("");
1144
- apiGet("/connection").then(setConn).catch((e) => setConnError(e instanceof Error ? e.message : String(e)));
1145
- };
1146
-
1147
- const doConnect = async () => {
1148
- const host = wifiHost.trim();
1149
- if (!host) { setConnMsg(""); setConnError("Enter the device IP first."); return; }
1150
- setConnBusy("connecting"); setConnError(""); setConnMsg("");
1151
- try {
1152
- const r = await streamPost("/connect", {
1153
- host,
1154
- port: parseInt(wifiPort, 10) || 5555,
1155
- pairing_code: pairCode.trim() || undefined,
1156
- pairing_port: parseInt(pairPort, 10) || 37000,
1157
- });
1158
- setConnMsg(`Connected ${r.serial}${r.paired ? " (paired)" : ""}.`);
1159
- setPairCode("");
1160
- refreshConn();
1161
- } catch (e) {
1162
- setConnError(e instanceof Error ? e.message : String(e));
1163
- } finally {
1164
- setConnBusy("");
1165
- }
1166
- };
1167
-
1168
- const doPairQr = async () => {
1169
- setConnBusy("qr"); setConnError(""); setConnMsg("");
1170
- try {
1171
- const r = await streamPost("/pair-qr", { timeout_ms: 60000 });
1172
- setQrText(r.qr_text ?? "");
1173
- if (r.status === "connected") setConnMsg(`Paired & connected ${r.serial}.`);
1174
- else if (r.status === "paired-not-connected") setConnMsg(`Paired with ${r.serial} but connect failed — try Connect with the same IP.`);
1175
- else setConnMsg("QR generated. Scan it with the phone (Settings → Connected devices → Pair by QR) and keep this dialog open — pairing completes automatically.");
1176
- refreshConn();
1177
- } catch (e) {
1178
- setConnError(e instanceof Error ? e.message : String(e));
1179
- } finally {
1180
- setConnBusy("");
1181
- }
1182
- };
1183
-
1184
- const copy = async () => {
1185
- const done = await copyText(prompt);
1186
- setCopied(done);
1187
- setTimeout(() => setCopied(false), 2000);
1188
- };
1189
-
1190
- const tabButton = (id, label) =>
1191
- h("button", { className: "mc-tab", "data-on": tab === id ? "true" : undefined, onClick: () => setTab(id) }, label);
1192
-
1193
- return [
1194
- h("div", { className: "mc-tabs" },
1195
- tabButton("doctor", "Doctor"),
1196
- tabButton("ocr", "PaddleOCR"),
1197
- tabButton("prompt", "AI Prompt"),
1198
- tabButton("connection", "Connection"),
1199
- ),
1200
- tab === "doctor" && h("div", { className: "mc-modal-body" }, h(DoctorView, { onFixed: () => {} })),
1201
- tab === "ocr" && h("div", { className: "mc-modal-body" }, h(OcrCard, {})),
1202
- tab === "prompt" && h("div", { className: "mc-modal-body" },
1203
- h("div", null,
1204
- h("h3", null, "Prompt for any AI"),
1205
- h("div", { className: "mc-hint" }, "Copy this into any AI model to tell it how to fully control this plugin."),
1206
- ),
1207
- h("div", { className: "mc-prompt" },
1208
- h("pre", null, prompt || "Loading…"),
1209
- h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy"),
1210
- ),
1211
- ),
1212
- tab === "connection" && h("div", { className: "mc-modal-body" },
1213
- h("div", null,
1214
- h("h3", null, "adb connection"),
1215
- connError !== "" && h("div", { className: "mc-warn" }, connError),
1216
- connMsg !== "" && h("div", { className: "mc-hint", style: { color: "#2f9e44" } }, connMsg), h("div", { className: "mc-conn-form" }, h("div", { className: "mc-conn-row" }, h("input", { className: "mc-input", placeholder: "device IP, e.g. 192.168.1.23", value: wifiHost, onChange: (e) => setWifiHost(e.target.value) }), h("input", { className: "mc-input mc-input-sm", placeholder: "port", value: wifiPort, onChange: (e) => setWifiPort(e.target.value) }), h("button", { className: "mc-btn", onClick: doConnect, disabled: connBusy !== "" }, connBusy === "connecting" ? "Connecting…" : "Connect"), ), h("div", { className: "mc-conn-row" }, h("input", { className: "mc-input", placeholder: "pairing code (first-time only)", value: pairCode, onChange: (e) => setPairCode(e.target.value) }), h("input", { className: "mc-input mc-input-sm", placeholder: "pair port", value: pairPort, onChange: (e) => setPairPort(e.target.value) }), h("button", { className: "mc-btn", onClick: doPairQr, disabled: connBusy !== "" }, connBusy === "qr" ? "Waiting for scan…" : "Pair by QR"), ), ), qrText !== "" && h("div", { className: "mc-qr" }, h("div", { className: "mc-hint" }, "WIFI:T:ADB — render as a QR code and scan with the phone (Settings → Connected devices → Pair by QR):"), h("pre", { className: "mc-qr-text" }, qrText), ),
1217
- conn && h("div", null,
1218
- h("div", { className: "mc-hint" }, `adb: ${conn.adb}`),
1219
- h("div", { className: "mc-device-list" },
1220
- conn.devices.length === 0 && h("div", { className: "mc-hint" }, "No devices attached. Plug a phone (USB debug) or boot an emulator."),
1221
- conn.devices.map((d) => h("div", { className: "mc-device-row", key: d.serial },
1222
- h("span", { className: "mc-dot", "data-on": d.state === "device" ? "true" : undefined }),
1223
- h("span", { className: "mc-serial" }, d.serial),
1224
- d.model && h("span", { className: "mc-hint" }, d.model),
1225
- h("span", { className: "mc-badge" }, d.wifi ? "Wi-Fi" : "USB"),
1226
- h("span", { className: "mc-hint" }, d.state),
1227
- )),
1228
- ),
1229
- h("div", { className: "mc-hint" }, "Wi-Fi serials look like 192.168.1.23:5555 — attach with `adb connect <ip>:5555`. A dropped Wi-Fi link is reconnected once automatically; read-only probes then replay, taps never do."),
1230
- ),
1231
- ),
1232
- ),
1233
- ];
1234
- }
1235
-
1236
- /** Settings dialog (⚙ in the Devices pane header). */
1237
- function SettingsDialog({ onClose }) {
1238
- return h("div", { className: "mc-overlay", onClick: (e) => { if (e.target === e.currentTarget) onClose(); } },
1239
- h("div", { className: "mc-modal" },
1240
- h("div", { className: "mc-modal-head" },
1241
- h("h2", { className: "mc-modal-title" }, "dsh-mobilecode · Settings"),
1242
- h("button", { className: "mc-close", onClick: onClose }, "✕"),
1243
- ),
1244
- h(SettingsTabs, {}),
1245
- h("div", { className: "mc-modal-foot" },
1246
- h("span", { className: "mc-hint" }, "Settings live in ~/.dsh/mobilecode/"),
1247
- h("span", { className: "sp" }),
1248
- h("button", { className: "mc-btn primary", onClick: onClose }, "Done"),
1249
- ),
1250
- ),
1251
- );
1252
- }
1253
-
1254
- /** The DSH Settings page contribution (registered into settings.section). */
1255
- function SettingsSection({ close }) {
1256
- return h("div", { className: "mc-section" },
1257
- h("div", { className: "mc-section-head" },
1258
- h("h2", { className: "mc-modal-title" }, "dsh-mobilecode"),
1259
- h("span", { className: "mc-hint" }, "Detect iOS/Android projects, run them on the Simulator/Emulator, and let AI agents control the device."),
1260
- close !== undefined && h("button", { className: "mc-btn", onClick: close }, "Close"),
1261
- ),
1262
- h(SettingsTabs, {}),
1263
- );
1264
- }
1265
-
1266
- /** First-run welcome modal: how-to + copyable prompt + PaddleOCR install. */
1267
- function WelcomeModal({ onClose }) {
1268
- const [prompt, setPrompt] = useState("");
1269
- const [copied, setCopied] = useState(false);
1270
- useEffect(() => {
1271
- apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
1272
- }, []);
1273
-
1274
- const copy = async () => {
1275
- const done = await copyText(prompt);
1276
- setCopied(done);
1277
- setTimeout(() => setCopied(false), 2000);
1278
- };
1279
-
1280
- const dismiss = async () => {
1281
- try { await post("/welcome/dismiss"); } catch { /* keep modal in-memory */ }
1282
- onClose();
1283
- };
1284
-
1285
- return h("div", { className: "mc-overlay" },
1286
- h("div", { className: "mc-modal" },
1287
- h("div", { className: "mc-modal-head" },
1288
- h("h2", { className: "mc-modal-title" }, "Welcome to dsh-mobilecode 🚀"),
1289
- h("button", { className: "mc-close", onClick: dismiss }, "✕"),
1290
- ),
1291
- h("div", { className: "mc-modal-body" },
1292
- h("div", null,
1293
- h("h3", null, "How to use it"),
1294
- h("div", { className: "mc-status" },
1295
- h("span", null, "1. Open the Devices pane (sidebar) and pick a project directory, or let an AI do it for you."),
1296
- h("span", null, "2. Press Run app (or ask the AI) — it builds, installs and launches on the Simulator/Emulator."),
1297
- h("span", null, "3. AI agents get device_detect / device_run / device_screen / device_input / device_log / device_status — full build, see, tap, log control."),
1298
- h("span", null, "4. The ⚙ Settings button (Devices pane) has the doctor, PaddleOCR and this prompt."),
1299
- ),
1300
- ),
1301
- h("div", { className: "mc-prompt" },
1302
- h("pre", null, prompt || "Loading…"),
1303
- h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy prompt"),
1304
- ),
1305
- h(OcrCard, { compact: true }),
1306
- h("div", { className: "mc-hint" },
1307
- "Tip: install PaddleOCR above so device_screen can read text from the screen — the plugin still works without it, just without OCR."),
1308
- ),
1309
- h("div", { className: "mc-modal-foot" },
1310
- h("span", { className: "mc-hint" }, "The ⚙ in the Devices pane opens Settings (Doctor / PaddleOCR / AI Prompt) anytime."),
1311
- h("span", { className: "sp" }),
1312
- h("button", { className: "mc-btn primary", onClick: dismiss }, "Got it — let's go"),
1313
- ),
1314
- ),
1315
- );
1316
- }
1317
- //#endregion
1318
-
1319
- //#region DOM mounts
1320
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><rect x="2" y="1.5" width="12" height="13" rx="2"/><path d="M4.5 4h7M4.5 6.5h7M4.5 9h4" stroke-linecap="round"/><circle cx="8" cy="11.8" r="0.8" fill="currentColor" stroke="none"/></svg>';
1321
-
1322
- function sidebarRoot() {
1323
- const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
1324
- if (column === null) return undefined;
1325
- const logoOwner = column.querySelector('[class*="logoRow"]')?.parentElement;
1326
- return logoOwner ?? (column.firstElementChild ?? undefined);
1327
- }
1328
-
1329
- function newSessionButton(root) {
1330
- const nested = root.querySelector('button[class*="newSession"]');
1331
- if (nested !== null) return nested;
1332
- for (const child of root.children) {
1333
- if (child.tagName === "BUTTON") return child;
1334
- }
1335
- return undefined;
1336
- }
1337
-
1338
- function createEntry(controller) {
1339
- const entry = document.createElement("button");
1340
- entry.type = "button";
1341
- entry.dataset.dshMobilecodeEntry = "";
1342
- entry.setAttribute("aria-label", "Devices (mobilecode)");
1343
- entry.setAttribute("title", "Devices — run the mobile app in the Simulator/Emulator");
1344
- entry.innerHTML = '<span class="mc-entry-icon">' + ICON + '</span><span class="mc-entry-label">Devices</span>';
1345
- entry.addEventListener("click", () => { controller.toggle(); });
1346
- return entry;
1347
- }
1348
-
1349
- function placeEntry(root, entry) {
1350
- const button = newSessionButton(root);
1351
- if (button === undefined) return false;
1352
- if (entry.parentElement !== root) {
1353
- const row = button.closest('[class*="logoRow"]');
1354
- const base = (row !== null && row.parentElement === root) ? row : button;
1355
- const family = Array.from(root.children).filter(
1356
- (el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-logcat-entry], [data-dsh-mobilecode-entry]"),
1357
- );
1358
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
1359
- root.insertBefore(entry, anchor);
1360
- }
1361
- return true;
1362
- }
1363
-
1364
- function mountSidebarEntry(controller) {
1365
- const entry = createEntry(controller);
1366
- let root;
1367
- let placed = false;
1368
- let rootObserver;
1369
-
1370
- const tryPlace = () => {
1371
- if (root !== undefined && !root.isConnected) {
1372
- rootObserver?.disconnect();
1373
- root = undefined;
1374
- placed = false;
1375
- }
1376
- if (placed) {
1377
- if (document.body.contains(entry)) return;
1378
- rootObserver?.disconnect();
1379
- root = undefined;
1380
- placed = false;
1381
- }
1382
- root ??= sidebarRoot();
1383
- if (root === undefined) return;
1384
- placed = placeEntry(root, entry);
1385
- if (placed) {
1386
- rootObserver = new MutationObserver(() => {
1387
- if (root === undefined || !root.isConnected) { placed = false; tryPlace(); return; }
1388
- if (!root.contains(entry)) placed = placeEntry(root, entry);
1389
- });
1390
- rootObserver.observe(root, { childList: true, subtree: true });
1391
- }
1392
- };
1393
-
1394
- const waitObserver = new MutationObserver(() => { tryPlace(); });
1395
- waitObserver.observe(document.body, { childList: true, subtree: true });
1396
-
1397
- const syncActive = () => {
1398
- if (controller.getSnapshot().panelOpen) entry.dataset.active = "true";
1399
- else delete entry.dataset.active;
1400
- };
1401
- const unsubscribe = controller.subscribe(syncActive);
1402
- syncActive();
1403
- tryPlace();
1404
-
1405
- return () => {
1406
- waitObserver.disconnect();
1407
- rootObserver?.disconnect();
1408
- unsubscribe();
1409
- entry.remove();
1410
- };
1411
- }
1412
-
1413
- function mountPanel(controller) {
1414
- let root;
1415
- let container;
1416
- let handle;
1417
- let drag = null;
1418
-
1419
- const MIN_WIDTH = 320;
1420
- const DEFAULT_WIDTH = 560;
1421
- const readWidth = () => {
1422
- try {
1423
- const w = Number(localStorage.getItem("dsh-mobilecode-width"));
1424
- if (Number.isFinite(w) && w >= MIN_WIDTH && w <= window.innerWidth - 40) return w;
1425
- } catch { /* private mode */ }
1426
- return Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80));
1427
- };
1428
-
1429
- const onMove = (e) => {
1430
- if (drag === null || container === undefined) return;
1431
- const width = Math.max(MIN_WIDTH, Math.min(window.innerWidth - 40, drag.startWidth + (drag.startX - e.clientX)));
1432
- container.style.width = width + "px";
1433
- };
1434
- const onUp = () => {
1435
- if (drag === null) return;
1436
- handle?.removeAttribute("data-drag");
1437
- try {
1438
- const w = container.style.width.replace("px", "");
1439
- localStorage.setItem("dsh-mobilecode-width", w);
1440
- } catch { /* private mode */ }
1441
- window.removeEventListener("mousemove", onMove);
1442
- window.removeEventListener("mouseup", onUp);
1443
- drag = null;
1444
- };
1445
- const onDown = (e) => {
1446
- e.preventDefault();
1447
- if (drag !== null) return;
1448
- const now = Date.now();
1449
- if (now - (handle?._lastClick ?? 0) < 350) {
1450
- handle._lastClick = 0;
1451
- container.style.width = Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80)) + "px";
1452
- try { localStorage.setItem("dsh-mobilecode-width", String(Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80)))); } catch { /* private */ }
1453
- return;
1454
- }
1455
- handle._lastClick = now;
1456
- drag = { startX: e.clientX, startWidth: container.getBoundingClientRect().width };
1457
- handle?.setAttribute("data-drag", "");
1458
- window.addEventListener("mousemove", onMove);
1459
- window.addEventListener("mouseup", onUp);
1460
- };
1461
-
1462
- const ensure = () => {
1463
- if (container !== undefined && container.isConnected) return;
1464
- root?.unmount();
1465
- root = undefined;
1466
- container?.remove();
1467
- handle = undefined;
1468
- container = document.createElement("div");
1469
- container.dataset.dshMobilecodeView = "";
1470
- container.className = "dsh-mobilecode-view";
1471
- container.style.cssText += "position:fixed;top:0;right:0;bottom:0;left:auto;z-index:999999;";
1472
- container.style.width = readWidth() + "px";
1473
- container.hidden = true;
1474
- handle = document.createElement("div");
1475
- handle.className = "dsh-mobilecode-resize";
1476
- handle.title = "Drag to resize (double-click to reset)";
1477
- handle.addEventListener("mousedown", onDown);
1478
- container.appendChild(handle);
1479
- const panel = document.createElement("div");
1480
- panel.className = "dsh-mobilecode-panel";
1481
- panel.style.cssText += "position:absolute;inset:0;display:flex;flex-direction:column;";
1482
- container.appendChild(panel);
1483
- document.body.appendChild(container);
1484
- root = createRoot(panel);
1485
- root.render(h(MobileCodePanel, { controller }));
1486
- };
1487
-
1488
- ensure();
1489
-
1490
- const applyOpen = () => {
1491
- if (container !== undefined) container.hidden = !controller.getSnapshot().panelOpen;
1492
- };
1493
- const unsubscribe = controller.subscribe(applyOpen);
1494
- applyOpen();
1495
-
1496
- return () => {
1497
- window.removeEventListener("mousemove", onMove);
1498
- window.removeEventListener("mouseup", onUp);
1499
- unsubscribe();
1500
- root?.unmount();
1501
- root = undefined;
1502
- container?.remove();
1503
- container = undefined;
1504
- handle = undefined;
1505
- };
1506
- }
1507
- //#endregion
1508
-
1509
- //#region entry
1510
- /** Required services (fiber inject waiting — the runtime must be up first). */
1511
- const inject = ["slots"];
1512
-
1513
- /**
1514
- * First-run welcome: ask the host whether to show it, and if so mount the
1515
- * modal over everything. The host remembers dismissal in settings.json.
1516
- */
1517
- function mountWelcome() {
1518
- let root;
1519
- let container;
1520
- const render = (show) => {
1521
- if (!show) return;
1522
- container = document.createElement("div");
1523
- container.dataset.dshMobilecodeWelcome = "";
1524
- document.body.appendChild(container);
1525
- root = createRoot(container);
1526
- root.render(h(WelcomeModal, { onClose: () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; } }));
1527
- };
1528
- apiGet("/welcome").then((w) => render(w.show === true)).catch(() => { /* host older than this feature — no welcome */ });
1529
- return () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; };
1530
- }
1531
-
1532
- /**
1533
- * Mount the device pane.
1534
- * @param ctx - client root context.
1535
- */
1536
- function apply(ctx) {
1537
- const style = document.createElement("style");
1538
- style.textContent = STYLE;
1539
- style.dataset.dshMobilecodeStyle = "";
1540
- document.head.appendChild(style);
1541
- const styleGuard = new MutationObserver(() => {
1542
- if (!document.head.contains(style)) document.head.appendChild(style);
1543
- });
1544
- styleGuard.observe(document.head, { childList: true });
1545
-
1546
- const controller = new PanelController();
1547
- activeController = controller;
1548
- const disposers = [];
1549
- try {
1550
- disposers.push(mountSidebarEntry(controller));
1551
- disposers.push(mountPanel(controller));
1552
- disposers.push(mountWelcome());
1553
- // DSH Settings page: register our own section (doctor / PaddleOCR / AI prompt),
1554
- // like the other installed plugins' settings pages.
1555
- if (typeof ctx.slots?.inject === "function") {
1556
- const disposeSlot = ctx.slots.inject("settings.section", () =>
1557
- ctx.slots.register({
1558
- name: "settings.section",
1559
- id: "mobilecode",
1560
- order: 200,
1561
- label: "MobileCode",
1562
- }, SettingsSection),
1563
- );
1564
- disposers.push(disposeSlot);
1565
- // 0.7.0: compact conversation card under a settled device_stream /
1566
- // device_boot result (opens the panel on "⤢ open in panel", and
1567
- // auto-opens it once when a stream first settles). One slot per
1568
- // tool name keyed like the reference port (openpencil shape):
1569
- // `key: toolName` scopes the card to that tool's calls, so other
1570
- // tools keep their default card.
1571
- for (const toolName of ["device_stream", "device_boot"]) {
1572
- const disposeCard = ctx.slots.inject("tool.call.toolview", () =>
1573
- ctx.slots.register({
1574
- name: "tool.call.toolview",
1575
- key: toolName,
1576
- }, MobileCodeToolCard),
1577
- );
1578
- disposers.push(disposeCard);
1579
- }
1580
- // 0.7.0: green "● <serial>" composer capsule while a device
1581
- // streams; clicking it opens the panel. Registered unconditionally
1582
- // so the capsule can appear even before any card auto-opens.
1583
- const disposeCapsule = ctx.slots.inject("conversation.input.dock", () =>
1584
- ctx.slots.register({
1585
- name: "conversation.input.dock",
1586
- id: "dsh-mobilecode-status",
1587
- order: 40,
1588
- }, MobileCodeStatusCapsule),
1589
- );
1590
- disposers.push(disposeCapsule);
1591
- }
1592
- } catch (error) {
1593
- console.warn("[dsh-mobilecode] mount failed:", error);
1594
- }
1595
- ctx.effect(() => () => {
1596
- styleGuard.disconnect();
1597
- for (const dispose of disposers.splice(0)) dispose();
1598
- activeController = null;
1599
- autoOpenedOnce = false;
1600
- style.remove();
1601
- }, "dsh-mobilecode: ui mounts");
1602
- }
1603
- //#endregion
1604
-
1605
- exports.apply = apply;
1606
- exports.inject = inject;
1607
- return module.exports;
1608
- }
1609
- });
1610
-
1
+ /**
2
+ * dsh-mobilecode — browser half. Runs inside the dsh web GUI.
3
+ *
4
+ * Renders the device pane, the DSH equivalent of mobilecode's embedded
5
+ * device-preview UI:
6
+ * - sidebar entry row toggling the pane (DOM-level injection, self-healing),
7
+ * - directory input (remembers the last one) + platform pills from detect,
8
+ * - per platform: run/stop the app + build status (iOS only: start/stop
9
+ * the serve-sim preview server with its embedded iframe),
10
+ * - Device 1 card (the live stream): 0.10.0 merged the Android
11
+ * "Start server" into it (serve-avd retired; picker ⏻ boot → POST /boot),
12
+ * - build status, error and expandable log tail,
13
+ * - polls GET /api/dsh-mobilecode every ~2s while open.
14
+ *
15
+ * Bundle format: `window.__ModuleLoader__.load({id, factory})` (lazy CJS) —
16
+ * the only client bundle format the web shell materializes.
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: "dsh-mobilecode",
20
+ factory: (require) => {
21
+ var module = { exports: {} };
22
+ var exports = module.exports;
23
+
24
+ const { createElement: h, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } = require("react");
25
+ const { createRoot } = require("react-dom/client");
26
+
27
+ //#region styles
28
+ const STYLE = `
29
+ /* ── design tokens ──
30
+ Declared on :root, not on our own containers. Two reasons: the compact
31
+ conversation cards (tool.call.toolview) and the composer capsule render
32
+ OUTSIDE .dsh-mobilecode-view, and the inline styles they use already read
33
+ var(--mc-border, #2a3340) — a token that was never declared anywhere, so the
34
+ dark-only literal fallback painted a navy border on white in light mode.
35
+ Everything is mc- prefixed, so nothing can collide with the host shell.
36
+
37
+ Every colour below is measured, not eyeballed. The accents clear 4.5:1 as
38
+ SMALL TEXT in both themes — --mc-accent paints the 14px label inside
39
+ .mc-log-toggle, .mc-card-open and every [data-on] state, so 3:1 would not
40
+ have been enough. That is why the light accent is a deep blue and the dark
41
+ one a light blue, and why --mc-on-accent flips to near-black in dark.
42
+
43
+ The hues are this palette's own, not a Material tonal ramp: accent 222deg
44
+ (indigo-leaning azure, to sit with the cool slate neutrals), ok 158, warn 28,
45
+ err 358. Each role's lightness is then SOLVED against the pair table in
46
+ test/tokens.mjs. The binding constraint is the role on its OWN alpha tint,
47
+ not the role on the page — a 12-18% tint of a colour sits well below the
48
+ page's luminance, so a value picked to clear 4.5:1 on the page alone fails on
49
+ its own chip. Change a hue and re-solve; do not hand-edit the hex — that
50
+ goes for the alphas too, because a tint and a line are both measured against
51
+ the surface they land on, not against the page.
52
+
53
+ --mc-field-border exists separately from --mc-border-strong because a text
54
+ field's boundary is the only thing that identifies it (WCAG 1.4.11, 3:1),
55
+ whereas a button's border is decorative — its label already identifies it.
56
+ Neutrals carry a slight cool cast; pure gray next to a blue accent reads
57
+ washed out. */
58
+ :root {
59
+ color-scheme: light dark;
60
+ --mc-bg: light-dark(#fdfdff, #171a1f);
61
+ --mc-bg-raised: light-dark(#ffffff, #1b1f26);
62
+ --mc-bg-pop: light-dark(#ffffff, #20242b);
63
+ --mc-bg-inset: light-dark(rgba(16,24,40,.05), rgba(226,232,240,.07));
64
+ --mc-hover: light-dark(rgba(16,24,40,.06), rgba(226,232,240,.11));
65
+ --mc-hover-soft: rgba(100,116,139,.14);
66
+ --mc-active: rgba(100,116,139,.22);
67
+ --mc-border: light-dark(rgba(16,24,40,.13), rgba(226,232,240,.14));
68
+ --mc-border-strong: light-dark(rgba(16,24,40,.18), rgba(226,232,240,.2));
69
+ --mc-field-border: light-dark(rgba(16,24,40,.48), rgba(226,232,240,.48));
70
+ --mc-text: light-dark(#1a1d23, #dfe3ea);
71
+ --mc-text-strong: light-dark(#1a1d23, #e3e7ee);
72
+ --mc-text-dim: light-dark(#4a4f58, #b9bec7);
73
+ --mc-text-mute: light-dark(#5f646c, #9aa0a8);
74
+ --mc-text-faint: light-dark(#6e737b, #7c838d);
75
+ --mc-accent: light-dark(#245ada, #7d9eea);
76
+ --mc-accent-hover: light-dark(#1e4cb7, #9cb5ef);
77
+ --mc-accent-weak: light-dark(rgba(36,90,218,.12), rgba(125,158,234,.18));
78
+ --mc-accent-line: light-dark(rgba(36,90,218,.75), rgba(125,158,234,.75));
79
+ --mc-on-accent: light-dark(#ffffff, #10151c);
80
+ --mc-ok: light-dark(#186649, #30cf95);
81
+ --mc-ok-weak: light-dark(rgba(24,102,73,.12), rgba(48,207,149,.16));
82
+ --mc-ok-hover: light-dark(rgba(24,102,73,.22), rgba(48,207,149,.26));
83
+ --mc-ok-line: light-dark(rgba(24,102,73,.85), rgba(48,207,149,.85));
84
+ --mc-ok-glow: light-dark(rgba(24,102,73,.45), rgba(48,207,149,.6));
85
+ /* 4.5:1, not 3.0. This role paints TEXT (the preview-gallery freshness
86
+ note), so it is floored like --mc-err rather than like a dot
87
+ background. Hue 28.1 and sat 85.7 are unchanged; L was solved down
88
+ until the ratio cleared the text floor with headroom. */
89
+ --mc-warn: light-dark(#aa570d, #ed7812);
90
+ --mc-err: light-dark(#c61e23, #ea7276);
91
+ --mc-err-weak: light-dark(rgba(198,30,35,.1), rgba(234,114,118,.14));
92
+ --mc-err-line: light-dark(rgba(198,30,35,.75), rgba(234,114,118,.75));
93
+ --mc-idle: light-dark(#8a9099, #9aa0a8);
94
+ --mc-grip: light-dark(rgba(16,24,40,.47), rgba(226,232,240,.47));
95
+ --mc-grip-hover: light-dark(rgba(16,24,40,.7), rgba(226,232,240,.75));
96
+ --mc-scrim: rgba(15,23,42,.45);
97
+ --mc-spin-track: rgba(100,116,139,.35);
98
+ --mc-focus: light-dark(#245ada, #7d9eea);
99
+ --mc-stage: #000;
100
+ --mc-stage-fg: #ffffff; /* the stage is dark in BOTH themes, so its content is fixed */
101
+ --mc-bezel: #111;
102
+ --mc-bevel: linear-gradient(145deg, #2c2f36, #17191d);
103
+ --mc-shadow-panel: -10px 0 28px rgba(15,23,42,.22);
104
+ --mc-shadow-modal: 0 18px 60px rgba(15,23,42,.4);
105
+ --mc-shadow-pop: 0 8px 30px rgba(15,23,42,.35);
106
+ --mc-shadow-device: 0 10px 30px rgba(15,23,42,.45);
107
+ /* Space scale. Padding/margin/gap had drifted to 16 distinct lengths —
108
+ 1,2,3,4,5,6,7,8,9,10,12,14,16,18,24 — including four pairs one pixel
109
+ apart, which is a ladder rather than a scale. Everything now lands on
110
+ 2/4/6/8/10/12/16/24; radius stays a separate axis. */
111
+ --mc-s1: 2px; --mc-s2: 4px; --mc-s3: 6px; --mc-s4: 8px; --mc-s5: 10px;
112
+ --mc-s6: 12px; --mc-s7: 16px; --mc-s9: 24px;
113
+ --mc-r-xs: 2px; --mc-r-sm: 6px; --mc-r-ctl: 7px; --mc-r-md: 8px;
114
+ --mc-r-lg: 10px; --mc-r-xl: 14px; --mc-r-pill: 999px;
115
+ /* the phone shell is deliberately rounder than --mc-r-xl so the bezel reads
116
+ as a physical casing rather than a card; 26px is a shape, not a step */
117
+ --mc-r-bezel: 26px;
118
+ /* --mc-sans is inherit, not a system stack of our own. The panel lives
119
+ inside the DSH shell, which already picks the family; declaring
120
+ -apple-system/"Segoe UI" here overrode it and was also the single most
121
+ recognisable generic-AI font tell in this stylesheet. --mc-mono stays a
122
+ real stack because log and identifier alignment is a functional need. */
123
+ --mc-sans: inherit;
124
+ --mc-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
125
+ /* One scale, four roles, ~1.2 ratio. All rem, so it follows the host root
126
+ size and the user's browser font setting instead of being pinned in px.
127
+ The previous layer had seven steps at 10/11/12/13/14/15/16px — a 1px
128
+ ladder, not a scale, and muddy: nothing read as clearly subordinate.
129
+ Hierarchy here comes from weight and colour as much as size. */
130
+ --mc-fs-caption: 0.75rem; /* 12px @16 — micro labels only: badges, picker section labels */
131
+ --mc-fs-label: 0.875rem; /* 14px @16 — controls and secondary reading text: hints, captions, logs */
132
+ --mc-fs-body: 1rem; /* 16px @16 — reading text: status, errors, prose */
133
+ --mc-fs-title: 1.25rem; /* 20px @16 — panel and modal titles */
134
+ /* Two weights, two roles. 700 is headings only; 600 is every other
135
+ emphasis. Several small labels used to be 700 next to a 700 heading. */
136
+ --mc-fw-medium: 600;
137
+ --mc-fw-bold: 700;
138
+ --mc-lh-tight: 1.2; /* titles and single-line chrome */
139
+ --mc-lh-body: 1.5; /* reading text */
140
+ --mc-lh-code: 1.6; /* monospace blocks */
141
+ /* One duration, one curve, used by every transition. The panel had .12s in
142
+ one rule and .15s in another with no easing declared at all — the browser
143
+ default 'ease' is symmetric, which reads as a sluggish start on a 150ms
144
+ UI transition. This curve is ease-out-quart: fast out of the gate, long
145
+ soft landing. No bounce or elastic anywhere. */
146
+ --mc-dur: 150ms;
147
+ --mc-ease: cubic-bezier(.2, 0, 0, 1);
148
+ /* The panel floats above the whole shell and a modal floats above the panel.
149
+ Named so the two numbers are not magic in three separate places. */
150
+ /* stacking tiers: local handle < popover < panel < modal. The panel sits far
151
+ above the rest because it has to clear the DSH host's own chrome. */
152
+ --mc-z-handle: 2;
153
+ --mc-z-pop: 50;
154
+ --mc-z-panel: 999999;
155
+ --mc-z-modal: 1000000;
156
+ }
157
+
158
+ [data-dsh-mobilecode-entry] {
159
+ display: flex; align-items: center; gap: var(--mc-s4); width: 100%;
160
+ padding: var(--mc-s4) var(--mc-s6); margin: var(--mc-s1) 0; border: 0; border-radius: var(--mc-r-md);
161
+ background: transparent; color: inherit; font: inherit; cursor: pointer;
162
+ text-align: left;
163
+ }
164
+ [data-dsh-mobilecode-entry]:hover { background: var(--mc-hover-soft); }
165
+ [data-dsh-mobilecode-entry][data-active] { background: var(--mc-active); }
166
+ [data-dsh-mobilecode-entry] .mc-entry-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; flex: none; }
167
+ [data-dsh-mobilecode-entry] .mc-entry-label { font-size: var(--mc-fs-title); line-height: var(--mc-lh-tight); opacity: .92; }
168
+ .dsh-mobilecode-view { position: fixed; top: 0; right: 0; bottom: 0; width: min(560px, 94vw); z-index: var(--mc-z-panel); left: auto; }
169
+ .dsh-mobilecode-view[hidden] { display: none !important; }
170
+ .dsh-mobilecode-panel {
171
+ position: absolute; inset: 0; display: flex; flex-direction: column;
172
+ background: var(--mc-bg);
173
+ color: var(--mc-text);
174
+ border-left: 1px solid var(--mc-border);
175
+ box-shadow: var(--mc-shadow-panel);
176
+ }
177
+ .dsh-mobilecode-resize { position: absolute; left: -6px; top: 0; bottom: 0; width: 12px; cursor: col-resize; z-index: var(--mc-z-handle); display: flex; align-items: center; justify-content: center; }
178
+ .dsh-mobilecode-resize::before { content: ""; width: 3px; height: 48px; border-radius: var(--mc-r-xs); background: var(--mc-grip); transition: background var(--mc-dur) var(--mc-ease), transform var(--mc-dur) var(--mc-ease); }
179
+ .dsh-mobilecode-resize:hover::before, .dsh-mobilecode-resize[data-drag]::before { background: var(--mc-grip-hover); transform: scaleY(1.5); }
180
+ .mc-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; font-family: var(--mc-sans); font-kerning: normal; }
181
+ /* The drawer is dragged between 320px and innerWidth-40, so its width has
182
+ nothing to do with the viewport: a 1600px window can hold a 340px pane.
183
+ This wrapper is the query container the narrow-pane rules resolve against.
184
+ The modal is a SIBLING of it on purpose — container-type implies layout
185
+ containment, which would make this element the containing block for the
186
+ fixed-position .mc-overlay and trap the dialog inside the drawer. */
187
+ .mc-panel-col { container-type: inline-size; display: flex; flex-direction: column; flex: 1; min-height: 0; }
188
+ .mc-header { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s5) var(--mc-s6); border-bottom: 1px solid var(--mc-border); flex: none; }
189
+ .mc-title { font-size: var(--mc-fs-title); font-weight: var(--mc-fw-bold); margin: 0; letter-spacing: -0.01em; flex: 1; line-height: var(--mc-lh-tight); }
190
+ .mc-close { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: var(--mc-s2); padding: var(--mc-s2) var(--mc-s4); border-radius: var(--mc-r-sm); font-size: var(--mc-fs-label); }
191
+ .mc-close:hover { background: var(--mc-hover); }
192
+ .mc-body { flex: 1; min-height: 0; overflow: auto; padding: var(--mc-s6) var(--mc-s6); display: flex; flex-direction: column; gap: var(--mc-s6); }
193
+ .mc-row { display: flex; align-items: center; gap: var(--mc-s4); }
194
+ .mc-input { flex: 1; min-width: 0; background: var(--mc-bg-inset); border: 1px solid var(--mc-field-border); border-radius: var(--mc-r-ctl); color: inherit; font: inherit; font-size: var(--mc-fs-label); padding: var(--mc-s3) var(--mc-s4); }
195
+ /* :focus, not :focus-visible, and deliberately: a text field shows a caret the
196
+ moment it is clicked, so its ring has to appear then too. Every other control
197
+ here uses :focus-visible, because a mouse click on a button is not a state a
198
+ sighted keyboard user needs painted. */
199
+ .mc-input:focus { outline: 2px solid var(--mc-focus); outline-offset: 1px; border-color: var(--mc-focus); }
200
+ .mc-btn { border: 1px solid var(--mc-border-strong); background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); padding: var(--mc-s3) var(--mc-s5); border-radius: var(--mc-r-ctl); cursor: pointer; transition: background var(--mc-dur) var(--mc-ease); white-space: nowrap; }
201
+ .mc-btn:hover { background: var(--mc-hover); }
202
+ .mc-btn[data-on] { background: var(--mc-accent-weak); border-color: var(--mc-accent-line); color: var(--mc-accent); }
203
+ .mc-btn:disabled { opacity: .45; cursor: default; }
204
+ .mc-btn.primary { background: var(--mc-accent); border-color: var(--mc-accent); color: var(--mc-on-accent); }
205
+ .mc-btn.primary:hover { background: var(--mc-accent-hover); }
206
+ .mc-btn.danger { color: var(--mc-err); }
207
+ .mc-pills { display: flex; gap: var(--mc-s3); flex-wrap: wrap; }
208
+ .mc-pill { display: inline-flex; align-items: center; gap: var(--mc-s3); border: 1px solid var(--mc-border-strong); background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); padding: var(--mc-s2) var(--mc-s5); border-radius: var(--mc-r-pill); cursor: pointer; }
209
+ .mc-pill:hover { background: var(--mc-hover); }
210
+ .mc-pill[data-on] { background: var(--mc-accent-weak); border-color: var(--mc-accent-line); color: var(--mc-accent); }
211
+ .mc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; background: var(--mc-idle); }
212
+ .mc-dot.on { background: var(--mc-ok); box-shadow: 0 0 6px var(--mc-ok-glow); }
213
+ .mc-dot.off { background: var(--mc-idle); }
214
+ .mc-dot.warn { background: var(--mc-warn); }
215
+ .mc-dot.err { background: var(--mc-err); }
216
+ .mc-card { border: 1px solid var(--mc-border); border-radius: var(--mc-r-lg); padding: var(--mc-s5) var(--mc-s6); display: flex; flex-direction: column; gap: var(--mc-s4); }
217
+ .mc-card-head { display: flex; align-items: center; gap: var(--mc-s4); font-size: var(--mc-fs-title); font-weight: var(--mc-fw-bold); line-height: var(--mc-lh-tight); }
218
+ .mc-card-head .sp { flex: 1; }
219
+ .mc-status { font-size: var(--mc-fs-body); color: var(--mc-text-dim); display: flex; flex-direction: column; gap: var(--mc-s1); }
220
+ .mc-status b { font-weight: var(--mc-fw-medium); color: inherit; }
221
+ .mc-error { font-size: var(--mc-fs-body); color: var(--mc-err); background: var(--mc-err-weak); border: 1px solid var(--mc-err-line); border-radius: var(--mc-r-ctl); padding: var(--mc-s4) var(--mc-s4); white-space: pre-wrap; word-break: break-all; }
222
+ .mc-log-toggle { color: var(--mc-accent); cursor: pointer; border: 0; background: transparent; padding: var(--mc-s1) 0; text-align: left; font: inherit; font-size: var(--mc-fs-label); min-height: 24px; }
223
+ .mc-log-toggle, .mc-card-open { transition: color var(--mc-dur) var(--mc-ease); }
224
+ .mc-log-toggle:hover, .mc-card-open:hover { color: var(--mc-accent-hover); text-decoration: underline; }
225
+ .mc-log, .mc-logline { max-height: 180px; overflow: auto; background: var(--mc-bg-inset); border-radius: var(--mc-r-md); padding: var(--mc-s4) var(--mc-s5); margin: 0; font-size: var(--mc-fs-label); line-height: var(--mc-lh-code); white-space: pre-wrap; word-break: break-all; font-family: var(--mc-mono); font-variant-numeric: tabular-nums; }
226
+ .mc-frame { width: 100%; height: clamp(180px, 38vh, 300px); border: 1px solid var(--mc-border); border-radius: var(--mc-r-lg); background: var(--mc-stage); display: block; }
227
+ .mc-empty { color: var(--mc-text-faint); font-size: var(--mc-fs-body); padding: var(--mc-s4) 0; }
228
+ .mc-footer { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s3) var(--mc-s6); border-top: 1px solid var(--mc-border); flex: none; font-size: var(--mc-fs-label); color: var(--mc-text-mute); flex-wrap: wrap; }
229
+ /* ── welcome modal ── */
230
+ .mc-overlay { position: fixed; inset: 0; z-index: var(--mc-z-modal); display: flex; align-items: center; justify-content: center; background: var(--mc-scrim); backdrop-filter: blur(2px); padding: var(--mc-s9); }
231
+ .mc-modal { width: min(680px, 100%); max-height: 88vh; overflow: auto; background: var(--mc-bg-raised); color: var(--mc-text-strong); border: 1px solid var(--mc-border); border-radius: var(--mc-r-xl); box-shadow: var(--mc-shadow-modal); display: flex; flex-direction: column; }
232
+ .mc-modal-head { display: flex; align-items: center; gap: var(--mc-s5); padding: var(--mc-s6) var(--mc-s7); border-bottom: 1px solid var(--mc-border); flex: none; }
233
+ .mc-modal-title { font-size: var(--mc-fs-title); font-weight: var(--mc-fw-bold); margin: 0; flex: 1; line-height: var(--mc-lh-tight); }
234
+ .mc-modal-body { padding: var(--mc-s7) var(--mc-s7); display: flex; flex-direction: column; gap: var(--mc-s6); font-size: var(--mc-fs-body); line-height: var(--mc-lh-body); }
235
+ .mc-modal-foot { padding: var(--mc-s6) var(--mc-s7); border-top: 1px solid var(--mc-border); display: flex; align-items: center; gap: var(--mc-s4); flex-wrap: wrap; flex: none; }
236
+ .mc-modal-foot .sp { flex: 1; }
237
+ .mc-modal h3 { margin: 0; font-size: var(--mc-fs-title); }
238
+ .mc-prompt { position: relative; }
239
+ .mc-prompt pre { margin: 0; max-height: 220px; overflow: auto; background: var(--mc-bg-inset); border: 1px solid var(--mc-border); border-radius: var(--mc-r-md); padding: var(--mc-s5) var(--mc-s6); font-size: var(--mc-fs-label); line-height: var(--mc-lh-code); white-space: pre-wrap; word-break: break-word; font-family: var(--mc-mono); }
240
+ .mc-copy { position: absolute; top: 8px; right: 8px; }
241
+ .mc-tabs { display: flex; gap: var(--mc-s2); padding: 0 var(--mc-s7); border-bottom: 1px solid var(--mc-border); flex: none; }
242
+ .mc-tab { border: 0; background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); padding: var(--mc-s4) var(--mc-s6); cursor: pointer; border-bottom: 2px solid transparent; opacity: .65; transition: opacity var(--mc-dur) var(--mc-ease); }
243
+ .mc-tab[data-on] { opacity: 1; border-bottom-color: var(--mc-accent); color: var(--mc-accent); }
244
+ /* :not([data-on]) keeps the selected tab at full opacity — both selectors would
245
+ otherwise have equal specificity and the hover rule would win. */
246
+ .mc-tab:not([data-on]):hover { opacity: .85; }
247
+ .mc-check { display: flex; align-items: flex-start; gap: var(--mc-s4); padding: var(--mc-s4) var(--mc-s5); border: 1px solid var(--mc-border); border-radius: var(--mc-r-md); font-size: var(--mc-fs-body); line-height: var(--mc-lh-body); }
248
+ .mc-check .mc-check-ico { flex: none; font-weight: var(--mc-fw-medium); width: 18px; text-align: center; }
249
+ .mc-check .mc-check-detail { flex: 1; min-width: 0; word-break: break-all; }
250
+ .mc-check .mc-check-name { font-weight: var(--mc-fw-medium); }
251
+ .mc-spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--mc-spin-track); border-top-color: var(--mc-accent); border-radius: 50%; animation: mc-spin .8s linear infinite; vertical-align: -2px; }
252
+ @keyframes mc-spin { to { transform: rotate(360deg); } }
253
+ .mc-hint { font-size: var(--mc-fs-label); color: var(--mc-text-mute); }
254
+ /* ── DSH Settings page contribution ── */
255
+ .mc-section { display: flex; flex-direction: column; gap: var(--mc-s2); padding: var(--mc-s2) var(--mc-s3) var(--mc-s7); }
256
+ .mc-section-head { display: flex; align-items: center; gap: var(--mc-s5); flex-wrap: wrap; padding: var(--mc-s1) 0 var(--mc-s5); }
257
+ .mc-section-head .mc-hint { flex: 1; min-width: 200px; }
258
+ .mc-section .mc-tabs { padding: 0; margin-bottom: var(--mc-s2); }
259
+ /* ── live device stream ── */
260
+ .mc-live { display: flex; flex-direction: column; gap: var(--mc-s4); }
261
+ .mc-live-stage { position: relative; align-self: center; background: var(--mc-stage); border-radius: var(--mc-r-lg); overflow: hidden; line-height: 0; box-shadow: inset 0 0 0 1px var(--mc-border); }
262
+ .mc-live-stage img { display: block; max-width: 100%; max-height: 60vh; width: auto; height: auto; cursor: crosshair; touch-action: none; user-select: none; }
263
+ .mc-live-stage .mc-live-off { display: flex; align-items: center; justify-content: center; width: 260px; max-width: 100%; height: 460px; color: var(--mc-text-mute); font-size: var(--mc-fs-body); line-height: var(--mc-lh-body); text-align: center; }
264
+ /* 0.9.1 co-op: two forced columns — side-by-side MUST survive a narrow drawer. */
265
+ .mc-live-section { display: flex; flex-direction: column; gap: var(--mc-s5); }
266
+ .mc-live-section.coop { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: var(--mc-s5); align-items: start; }
267
+ .mc-live-section.coop > .mc-card { min-width: 0; }
268
+ /* 0.11.6: the co-op head must NEVER wrap to a second row — a wrapped
269
+ head offsets one pane's stage ~38px lower than its sibling's, which
270
+ read as "the bars in Device 1 make the heights differ". Children
271
+ shrink instead (picker/select ellipsize). 0.11.7: the button must
272
+ also TRACK its shrinking .mc-picker container — at intrinsic width
273
+ it overflowed sideways under the ☰/⧉ buttons. */
274
+ .mc-live-section.coop .mc-card-head { flex-wrap: nowrap; min-width: 0; }
275
+ .mc-card-head .mc-picker { flex: 1 1 auto; min-width: 0; }
276
+ .mc-card-head .mc-picker > .mc-btn { width: 100%; min-width: 0; }
277
+ /* No coop-specific height cap: both panes must share Device 1's 60vh rule so
278
+ they match at every size, Fit included (0.11.2). */
279
+ .mc-coop-select { width: auto; min-width: 0; flex: 0 1 auto; padding: var(--mc-s2) var(--mc-s3); font-size: var(--mc-fs-label); }
280
+ .mc-live-cap { font-size: var(--mc-fs-label); color: var(--mc-text-mute); text-align: center; }
281
+ .mc-device-list { display: flex; flex-direction: column; gap: var(--mc-s2); margin: var(--mc-s4) 0; }
282
+ .mc-device-row { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s3) var(--mc-s4); border: 1px solid var(--mc-border); border-radius: var(--mc-r-md); }
283
+ .mc-dot[data-on="true"] { background: var(--mc-ok); box-shadow: 0 0 6px var(--mc-ok-glow); }
284
+ .mc-serial { font-weight: var(--mc-fw-medium); font-family: var(--mc-mono); font-size: var(--mc-fs-label); font-variant-numeric: tabular-nums; }
285
+ .mc-badge { font-size: var(--mc-fs-caption); padding: var(--mc-s1) var(--mc-s4); border-radius: var(--mc-r-pill); background: var(--mc-bg-inset); }
286
+ .mc-conn-form { display: flex; flex-direction: column; gap: var(--mc-s3); margin: var(--mc-s4) 0; }
287
+ /* Content-driven reflow, not a breakpoint: the row wraps as soon as the text
288
+ field can no longer be given 140px, which is what happens at ~320px inside
289
+ the 680px modal (there the IP field used to squeeze to ~84px). */
290
+ .mc-conn-row { display: flex; align-items: center; gap: var(--mc-s3); flex-wrap: wrap; }
291
+ .mc-conn-row .mc-input:not(.mc-input-sm) { flex: 1 1 140px; }
292
+ .mc-conn-row .mc-input-sm { flex: 0 1 72px; }
293
+ .mc-conn-row .mc-btn { flex: 0 0 auto; }
294
+ .mc-qr { margin: var(--mc-s4) 0; padding: var(--mc-s4); border: 1px dashed var(--mc-border-strong); border-radius: var(--mc-r-md); }
295
+ .mc-qr-text { user-select: all; font-family: var(--mc-mono); font-size: var(--mc-fs-label); word-break: break-all; margin: var(--mc-s2) 0 0; }
296
+ .mc-warn { color: var(--mc-err); font-size: var(--mc-fs-body); margin: var(--mc-s3) 0; }
297
+ /* ── 0.7.0: toolbar pill, picker popover, device menu, sizes, frames ── */
298
+ .mc-toolbar { display: flex; align-items: center; justify-content: center; gap: var(--mc-s1); row-gap: var(--mc-s1); flex-wrap: wrap; max-width: 100%; padding: var(--mc-s2); border: 1px solid var(--mc-border-strong); border-radius: var(--mc-r-pill); background: var(--mc-bg-inset); align-self: center; }
299
+ .mc-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 0; background: transparent; color: inherit; border-radius: var(--mc-r-pill); cursor: pointer; opacity: .8; }
300
+ .mc-toolbar button:hover:not(:disabled) { background: var(--mc-hover); opacity: 1; }
301
+ .mc-toolbar button:disabled { opacity: .3; cursor: default; }
302
+ /* A press state, because these two families start work that takes a second or
303
+ more: without it the click looks unregistered for the whole round trip. It is
304
+ a transform rather than a colour so it reads the same on a primary button, a
305
+ quiet one and an icon button — and transform is compositor-only, so the press
306
+ lands on the next frame instead of easing in. Deliberately left off the
307
+ selection controls (.mc-tab, .mc-seg button): their [data-on] state IS the
308
+ feedback and it lands on the same frame, so a press state would be a second
309
+ answer to the same question. */
310
+ .mc-btn:active:not(:disabled), .mc-toolbar button:active:not(:disabled) { transform: translateY(1px); }
311
+ .mc-toolbar svg { display: block; }
312
+ .mc-live-badge { display: inline-flex; align-items: center; gap: var(--mc-s2); font-size: var(--mc-fs-caption); font-weight: var(--mc-fw-medium); color: var(--mc-ok); }
313
+ .mc-picker { position: relative; }
314
+ .mc-picker .mc-btn { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
315
+ .mc-picker-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: var(--mc-z-pop); min-width: 240px; max-width: 320px; max-height: 320px; overflow: auto; background: var(--mc-bg-pop); color: inherit; border: 1px solid var(--mc-border-strong); border-radius: var(--mc-r-lg); box-shadow: var(--mc-shadow-pop); padding: var(--mc-s3); display: flex; flex-direction: column; gap: var(--mc-s3); }
316
+ .mc-picker-group { display: flex; flex-direction: column; gap: var(--mc-s1); }
317
+ .mc-picker-label { font-size: var(--mc-fs-caption); text-transform: uppercase; letter-spacing: .06em; color: var(--mc-text-mute); padding: var(--mc-s1) var(--mc-s3); }
318
+ .mc-picker-row { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s3) var(--mc-s4); border: 1px solid transparent; border-radius: var(--mc-r-ctl); background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); text-align: left; }
319
+ .mc-picker-row:hover { background: var(--mc-hover); }
320
+ .mc-picker-row.on { background: var(--mc-accent-weak); }
321
+ .mc-picker-hint { font-size: var(--mc-fs-label); color: var(--mc-text-mute); padding: var(--mc-s2) var(--mc-s4); }
322
+ .mc-picker-row .sp { flex: 1; }
323
+ .mc-picker-row .mc-btn { padding: var(--mc-s1) var(--mc-s4); font-size: var(--mc-fs-label); }
324
+ /* The row itself is inert: its ACTION is this button, which is why the row
325
+ carries no cursor, no role and no tabindex. It stretches to the row's full
326
+ height, because moving the action off the row would otherwise have shrunk
327
+ the target from the whole row down to the height of the text in it. */
328
+ .mc-pick { display: flex; align-items: center; gap: var(--mc-s4); flex: 1 1 auto; align-self: stretch; min-width: 0; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); text-align: left; cursor: pointer; border-radius: var(--mc-r-ctl); }
329
+ /* A long AVD name plus its serial is ~250px of 14px mono inside a 240-320px
330
+ popover, so it truncates instead of widening the popover. */
331
+ .mc-picker-row .mc-serial { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
332
+ .mc-menu-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: var(--mc-z-pop); min-width: 180px; background: var(--mc-bg-pop); color: inherit; border: 1px solid var(--mc-border-strong); border-radius: var(--mc-r-lg); box-shadow: var(--mc-shadow-pop); padding: var(--mc-s2); display: flex; flex-direction: column; }
333
+ .mc-menu-pop button { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s4) var(--mc-s5); border: 0; border-radius: var(--mc-r-ctl); background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); cursor: pointer; text-align: left; }
334
+ .mc-menu-pop button:hover { background: var(--mc-hover); }
335
+ .mc-menu { position: relative; }
336
+ .mc-live-sizesel { flex: 0 1 auto; width: 92px; }
337
+ .mc-live-size { display: flex; align-items: center; gap: var(--mc-s3); flex-wrap: wrap; }
338
+ .mc-seg { display: inline-flex; border: 1px solid var(--mc-border-strong); border-radius: var(--mc-r-md); overflow: hidden; }
339
+ .mc-seg button { border: 0; background: transparent; color: inherit; font: inherit; font-size: var(--mc-fs-label); padding: var(--mc-s2) var(--mc-s4); cursor: pointer; opacity: .6; transition: opacity var(--mc-dur) var(--mc-ease); }
340
+ .mc-seg button[data-on] { background: var(--mc-accent-weak); color: var(--mc-accent); opacity: 1; }
341
+ .mc-seg button:not([data-on]):hover { opacity: .8; }
342
+ .mc-live-stage.fit { width: min(100%, calc(60vh * var(--mc-ar-n, 0.45))); aspect-ratio: var(--mc-ar-n, 0.45); display: flex; align-items: center; justify-content: center; margin-inline: auto; }
343
+ .mc-live-stage.fit img { width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
344
+ .mc-live-stage.bezel { padding: var(--mc-s4); background: var(--mc-bezel); border-radius: var(--mc-r-xl); }
345
+ .mc-live-stage.device { padding: var(--mc-s6); background: var(--mc-bevel); border-radius: var(--mc-r-bezel); box-shadow: var(--mc-shadow-device); }
346
+ /* ── 0.7.0: compact conversation cards ── */
347
+ .mc-card-compact { display: flex; align-items: center; gap: var(--mc-s4); padding: var(--mc-s4) var(--mc-s6); border: 1px solid var(--mc-border); border-radius: var(--mc-r-lg); font-size: var(--mc-fs-label); background: var(--mc-bg-inset); }
348
+ .mc-card-compact .mc-card-kind { font-weight: var(--mc-fw-medium); }
349
+ .mc-badge.ok { background: var(--mc-ok-weak); color: var(--mc-ok); }
350
+ .mc-card-open { color: var(--mc-accent); cursor: pointer; border: 0; background: transparent; padding: var(--mc-s1) var(--mc-s2); font: inherit; font-size: var(--mc-fs-label); }
351
+ /* ── 0.7.0: composer status capsule ── */
352
+ .mc-capsule { display: inline-flex; align-items: center; gap: var(--mc-s3); padding: var(--mc-s3) var(--mc-s6); border: 1px solid var(--mc-ok-line); border-radius: var(--mc-r-pill); background: var(--mc-ok-weak); color: var(--mc-ok); font: inherit; font-size: var(--mc-fs-label); font-weight: var(--mc-fw-medium); cursor: pointer; }
353
+ .mc-capsule:hover { background: var(--mc-ok-hover); }
354
+ /* ── visible keyboard focus everywhere, and a focusable file input ── */
355
+ .mc-visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
356
+ [data-dsh-mobilecode-entry]:focus-visible,
357
+ .mc-panel button:focus-visible, .mc-panel select:focus-visible,
358
+ .mc-modal button:focus-visible, .mc-modal select:focus-visible,
359
+ .mc-section button:focus-visible, .mc-section select:focus-visible,
360
+ .dsh-mobilecode-resize:focus-visible {
361
+ outline: 2px solid var(--mc-focus);
362
+ outline-offset: 2px;
363
+ }
364
+ .mc-live-stage img:focus-visible { outline: 3px solid var(--mc-focus); outline-offset: -3px; }
365
+ .mc-log:focus-visible, .mc-logline:focus-visible { outline: 2px solid var(--mc-focus); outline-offset: 2px; }
366
+ /* The dialog takes focus when it opens so the Tab trap has somewhere to start.
367
+ It is not a control and cannot be activated, so a ring around the whole panel
368
+ would be noise — the ring belongs on the control the user tabs to next. */
369
+ .mc-modal:focus { outline: none; }
370
+ /* ── adaptation layer ──
371
+ The pane is a right-hand drawer: 560px on a desktop, the full viewport on a
372
+ phone. It is also reachable from the DSH Settings page, which is a normal
373
+ document column. Both contexts have to hold up, so the rules below are
374
+ either content-driven (flex-basis / clamp) or scoped to a context that
375
+ genuinely changes the layout — not decorative breakpoints.
376
+
377
+ The drawer's width is independent of the window (320px .. innerWidth-40), so
378
+ the panel rules key on the PANE through .mc-panel-col, not on the viewport.
379
+ Only the modal — fixed to the viewport — and the Settings section — in the
380
+ host's own column — use @media, because neither has the drawer as an
381
+ ancestor and a container query could never match them. A browser without
382
+ @container support drops those two blocks and gets the desktop layout it had
383
+ before: degraded, never broken. */
384
+ @container (max-width: 640px) {
385
+ /* The only panel rule that genuinely depends on the pane width. The padding
386
+ rules that used to sit here were no-ops: .mc-body, .mc-header and
387
+ .mc-footer already declare var(--mc-s6) horizontally in their base rules. */
388
+ .mc-live-stage .mc-live-off { width: 100%; min-width: 200px; height: 320px; }
389
+ }
390
+ @container (max-width: 380px) {
391
+ .mc-body { padding-left: var(--mc-s5); padding-right: var(--mc-s5); }
392
+ .mc-toolbar button { width: 34px; height: 30px; }
393
+ }
394
+ @media (max-width: 640px) {
395
+ .mc-modal-body { padding-left: var(--mc-s6); padding-right: var(--mc-s6); }
396
+ .mc-modal-head, .mc-modal-foot { padding-left: var(--mc-s6); padding-right: var(--mc-s6); }
397
+ .mc-tabs { padding-left: var(--mc-s6); padding-right: var(--mc-s6); overflow-x: auto; scrollbar-width: none; }
398
+ .mc-tabs::-webkit-scrollbar { display: none; }
399
+ .mc-section .mc-tabs { padding-left: 0; padding-right: 0; }
400
+ .mc-overlay { padding: var(--mc-s6); }
401
+ .mc-modal { max-height: 92vh; }
402
+ }
403
+ /* Short viewports (phone landscape, split-screen): the stage is the biggest
404
+ consumer of height, so cap it before anything else gives way. */
405
+ @media (max-height: 560px) {
406
+ .mc-live-stage img { max-height: 44vh; }
407
+ .mc-live-stage .mc-live-off { height: 260px; }
408
+ .mc-modal { max-height: 94vh; }
409
+ }
410
+ /* Touch input: reach the 44px target the pointer now demands, and stop relying
411
+ on :hover for anything load-bearing. Applies to hybrid touch laptops too,
412
+ which is why it keys off pointer capability rather than viewport width. */
413
+ @media (pointer: coarse) {
414
+ /* Promote the micro role ITSELF rather than the three classes that consume it,
415
+ so the scale stays closed (still four roles) and a future fourth consumer
416
+ cannot opt out by accident. 12px uppercase tracked text is under the 14px
417
+ floor /harden sets for touch; a fine pointer keeps the compact badge
418
+ density, which is where the small pill look belongs. */
419
+ :root { --mc-fs-caption: var(--mc-fs-label); }
420
+ .mc-panel button, .mc-panel select, .mc-panel input,
421
+ .mc-modal button, .mc-modal select, .mc-modal input,
422
+ .mc-section button, .mc-section select, .mc-section input,
423
+ .mc-picker-row, .mc-menu-pop button, [data-dsh-mobilecode-entry] {
424
+ min-height: 44px;
425
+ }
426
+ .mc-toolbar button { width: 44px; height: 44px; }
427
+ .mc-toolbar { border-radius: var(--mc-r-xl); }
428
+ .mc-seg button { padding: var(--mc-s2) var(--mc-s6); }
429
+ .mc-tab, .mc-pill, .mc-log-toggle { min-height: 44px; }
430
+ /* A 44px nested button would make every picker row ~56px tall and halve the
431
+ number of devices visible in the popover; the ROW still carries the 44px. */
432
+ .mc-picker-row .mc-btn { min-height: 32px; }
433
+ /* Both controls stay 32px so the popover keeps its row density, but their
434
+ hit AREA reaches 44px: the row's padding is 6px, so inset -6px 0 lands
435
+ exactly on the row's bounds and stays inside them. The rows sit 2px
436
+ apart, so an overshoot would put two devices under one finger. .mc-pick
437
+ is stretched to the row's height, so the same -6px reaches the same 44px.
438
+ WCAG 2.5.8 measures what responds to the pointer, not what is painted. */
439
+ .mc-picker-row .mc-btn, .mc-pick { position: relative; }
440
+ .mc-picker-row .mc-btn::after, .mc-pick::after { content: ""; position: absolute; inset: -6px 0; }
441
+ .dsh-mobilecode-resize { width: 24px; left: -12px; }
442
+ .dsh-mobilecode-resize::before { width: 5px; height: 64px; }
443
+ }
444
+ @media (prefers-reduced-motion: reduce) {
445
+ .dsh-mobilecode-view *, .mc-modal * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
446
+ .mc-spin { animation: none; border-top-color: currentColor; opacity: .6; }
447
+ }
448
+ @media print {
449
+ .dsh-mobilecode-view, .mc-overlay, [data-dsh-mobilecode-entry] { display: none !important; }
450
+ }
451
+ /* The narrowest tier, and the last block in the stylesheet on purpose: it has
452
+ to outrank the pointer:coarse sizing above, which widens the handle to 24px
453
+ and pushes it to left:-12px. At this width mountPanel floors the drawer at
454
+ MIN_WIDTH, so the pane sits flush against the viewport edge and a handle
455
+ hanging off the left would be off-screen entirely. The modal gutter tightens
456
+ here too — its 640px tier lives in the block above, and one tier per query
457
+ beats a second @media (max-width: 380px) further up the sheet. */
458
+ @media (max-width: 380px) {
459
+ .mc-modal-body { padding-left: var(--mc-s5); padding-right: var(--mc-s5); }
460
+ .dsh-mobilecode-resize { left: 0; width: 16px; }
461
+ .dsh-mobilecode-resize::before { width: 4px; height: 48px; }
462
+ }
463
+ `;
464
+ //#endregion
465
+
466
+ const API_BASE = "/api/dsh-mobilecode";
467
+ // Shown on both stream captions so a refresh visibly proves which client
468
+ // bundle the browser is actually running. Kept in lockstep with
469
+ // package.json by test/client.mjs.
470
+ const MC_VERSION = "0.12.1";
471
+ // The panel shows cards for platforms the host can actually drive; the
472
+ // server reports its OS in info.os (iOS tooling is darwin-only).
473
+ const fallbackPlatforms = (os) => (os === "darwin" ? ["ios", "android"] : ["android"]);
474
+ const label = (p) => (p === "ios" ? "iOS" : "Android");
475
+ // Shared by both panes so Device 1 and Device 2 scale identically (0.11.0).
476
+ const stageSizeStyle = (m) => (m.mode === "pct"
477
+ ? { width: m.value + "%", maxWidth: "100%", maxHeight: "60vh" }
478
+ : m.mode === "px"
479
+ ? { width: m.value + "px", maxWidth: "100%", maxHeight: "60vh" }
480
+ : {});
481
+ // Fit panes lock to a shared stage height so devices with different
482
+ // aspect ratios still sit at the same height (0.11.3); presets stay
483
+ // width-driven (S·240 / M·320 = device width px).
484
+ const stageClassFor = (f, fit = false) => "mc-live-stage" + (fit ? " fit" : "") + (f !== "none" ? " " + f : "");
485
+ const SIZE_PRESETS = [
486
+ { label: "Fit", mode: { mode: "fit" } },
487
+ { label: "100%", mode: { mode: "pct", value: 100 } },
488
+ { label: "S · 240", mode: { mode: "px", value: 240 } },
489
+ { label: "M · 320", mode: { mode: "px", value: 320 } },
490
+ ];
491
+ /** Size presets + px/% select + frame seg — shared by both stream panes
492
+ * (0.11.2) so Device 2 gets the same controls as Device 1. */
493
+ function StageControls({ sizeMode, setSizeMode, frame, setFrame }) {
494
+ const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
495
+ return h("div", { className: "mc-live-size" },
496
+ h("span", { className: "mc-hint" }, "Size"),
497
+ h("div", { className: "mc-seg", role: "group", "aria-label": "Stage size preset" }, SIZE_PRESETS.map((p) => h("button", { key: p.label, type: "button", "data-on": isCurrent(p.mode) || undefined, "aria-pressed": isCurrent(p.mode), onClick: () => setSizeMode(p.mode) }, p.label))),
498
+ h("select", { className: "mc-input mc-live-sizesel", "aria-label": "Stage size", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
499
+ const v = e.target.value;
500
+ if (v === "fit") setSizeMode({ mode: "fit" });
501
+ else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
502
+ else setSizeMode({ mode: "px", value: Number(v) });
503
+ } },
504
+ h("option", { value: "fit" }, "fit"),
505
+ h("option", { value: "50%" }, "50%"),
506
+ h("option", { value: "75%" }, "75%"),
507
+ h("option", { value: "100%" }, "100%"),
508
+ h("option", { value: "125%" }, "125%"),
509
+ h("option", { value: "240" }, "S · 240px"),
510
+ h("option", { value: "320" }, "M · 320px"),
511
+ h("option", { value: "420" }, "L · 420px"),
512
+ ),
513
+ h("span", { className: "mc-hint" }, "Frame"),
514
+ h("div", { className: "mc-seg", role: "group", "aria-label": "Stage frame" },
515
+ h("button", { type: "button", "data-on": frame === "none" || undefined, "aria-pressed": frame === "none", onClick: () => setFrame("none") }, "none"),
516
+ h("button", { type: "button", "data-on": frame === "bezel" || undefined, "aria-pressed": frame === "bezel", onClick: () => setFrame("bezel") }, "bezel"),
517
+ h("button", { type: "button", "data-on": frame === "device" || undefined, "aria-pressed": frame === "device", onClick: () => setFrame("device") }, "device"),
518
+ ),
519
+ );
520
+ }
521
+
522
+ // Shared SVG icon paths for the stream toolbars (0.11.5 — both panes
523
+ // carry the same navigation controls; Device 2 used to lack them).
524
+ const NAV_ICONS = {
525
+ back: "M15 18l-6-6 6-6",
526
+ home: "M3 10.5L12 3l9 7.5M5 9.5V21h14V9.5",
527
+ recents: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5",
528
+ screenshot: "M4 7h4l2-3h4l2 3h4v13H4zM12 17a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
529
+ rotate: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6",
530
+ refresh: "M4 12a8 8 0 0 1 13.7-5.6M20 5v5h-5M20 12a8 8 0 0 1-13.7 5.6M4 19v-5h5",
531
+ };
532
+ /** One icon toolbar row; items: {title, icon, run}. Used by both panes. */
533
+ function ToolbarRow({ items }) {
534
+ return h("div", { className: "mc-toolbar", role: "toolbar", "aria-label": "Device navigation" },
535
+ items.map((item) => h("button", { key: item.title, type: "button", title: item.title, "aria-label": item.title, onClick: item.run },
536
+ h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
537
+ h("path", { d: item.icon }),
538
+ ),
539
+ )),
540
+ );
541
+ }
542
+ /** Back/Home/Recents/(Screen)/Rotate built from nav icons (0.11.5). */
543
+ const navToolbar = (control, capture, refresh) => [
544
+ { title: "Back", icon: NAV_ICONS.back, run: () => control({ kind: "button", name: "back" }) },
545
+ { title: "Home", icon: NAV_ICONS.home, run: () => control({ kind: "button", name: "home" }) },
546
+ { title: "Recents", icon: NAV_ICONS.recents, run: () => control({ kind: "button", name: "recents" }) },
547
+ ...(capture ? [{ title: "Screenshot", icon: NAV_ICONS.screenshot, run: () => capture() }] : []),
548
+ { title: "Rotate", icon: NAV_ICONS.rotate, run: () => control({ kind: "rotate" }) },
549
+ { title: "Refresh", icon: NAV_ICONS.refresh, run: refresh },
550
+ ];
551
+ /** ☰ device-actions popover; serial-bound, shared by both panes. */
552
+ function DeviceMenu({ serial, onError }) {
553
+ const [open, setOpen] = useState(false);
554
+ const ACTION_ITEMS = [
555
+ { action: "notifications", label: "Notifications" },
556
+ { action: "quick_settings", label: "Quick settings" },
557
+ { action: "collapse", label: "Collapse" },
558
+ { action: "lock", label: "Lock" },
559
+ { action: "wake", label: "Wake" },
560
+ { action: "assistant", label: "Assistant" },
561
+ ];
562
+ const run = async (action) => {
563
+ if (serial === "") return;
564
+ try { await streamPost("/stream/device-action", { device: serial, action }); setOpen(false); }
565
+ catch (err) { onError(err instanceof Error ? err.message : String(err)); }
566
+ };
567
+ return h("div", { className: "mc-menu" },
568
+ h("button", {
569
+ className: "mc-btn", type: "button", disabled: serial === "",
570
+ title: "Device actions", "aria-label": "Device actions",
571
+ "aria-haspopup": "menu", "aria-expanded": open,
572
+ onClick: () => setOpen((v) => !v),
573
+ }, "☰"),
574
+ open && h("div", { className: "mc-menu-pop", role: "menu", "aria-label": "Device actions" },
575
+ ACTION_ITEMS.map((item) => h("button", { key: item.action, type: "button", role: "menuitem", onClick: () => run(item.action) }, item.label)),
576
+ ),
577
+ );
578
+ }
579
+
580
+ function PanelController() {
581
+ let panelOpen = false;
582
+ // The snapshot MUST be referentially stable: useSyncExternalStore compares
583
+ // the value it gets back on every render and re-renders forever on a fresh
584
+ // object (React then throws "Maximum update depth exceeded"). One cached
585
+ // object, replaced only when the state actually changes.
586
+ let snapshot = { panelOpen };
587
+ const listeners = new Set();
588
+ const emit = () => { snapshot = { panelOpen }; for (const fn of listeners) fn(); };
589
+ return {
590
+ getSnapshot: () => snapshot,
591
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
592
+ setOpen: (value) => { if (panelOpen === value) return; panelOpen = value; emit(); },
593
+ toggle: () => { panelOpen = !panelOpen; emit(); },
594
+ };
595
+ }
596
+
597
+ const getInfo = async (directory) => {
598
+ const url = location.origin + API_BASE + (directory ? "?directory=" + encodeURIComponent(directory) : "");
599
+ const response = await fetch(url);
600
+ if (!response.ok) throw new Error("HTTP " + response.status);
601
+ return response.json();
602
+ };
603
+ const post = async (path, body) => {
604
+ const response = await fetch(location.origin + API_BASE + path, {
605
+ method: "POST",
606
+ headers: { "content-type": "application/json" },
607
+ body: JSON.stringify(body ?? {}),
608
+ });
609
+ if (!response.ok) throw new Error("HTTP " + response.status);
610
+ return response.json();
611
+ };
612
+ const apiGet = async (path) => {
613
+ const response = await fetch(location.origin + API_BASE + path);
614
+ if (!response.ok) throw new Error("HTTP " + response.status);
615
+ return response.json();
616
+ };
617
+ // POST that surfaces the server's JSON error copy (stream routes return
618
+ // human-readable 4xx bodies the generic post() would flatten to "HTTP 409").
619
+ const streamPost = async (path, body) => {
620
+ const response = await fetch(location.origin + API_BASE + path, {
621
+ method: "POST",
622
+ headers: { "content-type": "application/json" },
623
+ body: JSON.stringify(body ?? {}),
624
+ });
625
+ const text = await response.text();
626
+ let json;
627
+ try { json = JSON.parse(text); } catch { json = {}; }
628
+ if (!response.ok) throw new Error(json.error || ("HTTP " + response.status));
629
+ return json;
630
+ };
631
+ const copyText = async (text) => {
632
+ try { await navigator.clipboard.writeText(text); return true; } catch { return false; }
633
+ };
634
+
635
+ const readDir = () => { try { return localStorage.getItem("dsh-mobilecode-directory") ?? ""; } catch { return ""; } };
636
+ const writeDir = (value) => { try { localStorage.setItem("dsh-mobilecode-directory", value); } catch { /* private mode */ } };
637
+
638
+ /**
639
+ * Keyboard activation for clickable non-buttons. Rows that carry their own
640
+ * <button> (the picker's ⏻ off) cannot themselves be a <button> — nesting
641
+ * buttons is invalid — so they get role=button + Enter/Space instead.
642
+ */
643
+ const activatable = (run) => ({
644
+ role: "button",
645
+ tabIndex: 0,
646
+ onClick: run,
647
+ onKeyDown: (event) => {
648
+ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); run(); }
649
+ },
650
+ });
651
+
652
+ /**
653
+ * Dialog behaviour shared by every modal: Escape closes, focus moves into
654
+ * the dialog, Tab is trapped inside it, and focus returns to whatever
655
+ * opened it (WCAG 2.4.3 Focus Order / 2.1.2 No Keyboard Trap).
656
+ */
657
+ function useDialogA11y(onClose) {
658
+ const ref = useRef(null);
659
+ const closeRef = useRef(onClose);
660
+ closeRef.current = onClose;
661
+ useEffect(() => {
662
+ const node = ref.current;
663
+ if (!node) return undefined;
664
+ const previous = document.activeElement;
665
+ const focusables = () => Array.from(node.querySelectorAll(
666
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
667
+ )).filter((el) => el.offsetWidth > 0 || el.offsetHeight > 0);
668
+ (focusables()[0] ?? node).focus();
669
+ const onKey = (event) => {
670
+ if (event.key === "Escape") { event.stopPropagation(); closeRef.current(); return; }
671
+ if (event.key !== "Tab") return;
672
+ const items = focusables();
673
+ if (items.length === 0) { event.preventDefault(); return; }
674
+ const first = items[0];
675
+ const last = items[items.length - 1];
676
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === node)) { event.preventDefault(); last.focus(); }
677
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
678
+ };
679
+ node.addEventListener("keydown", onKey);
680
+ return () => {
681
+ node.removeEventListener("keydown", onKey);
682
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
683
+ };
684
+ }, []);
685
+ return ref;
686
+ }
687
+
688
+ // A memo boundary, because the panel re-renders on every keystroke in its
689
+ // own directory field while this section's only prop comes from the POLLED
690
+ // payload — so without it the ref list re-rendered on every character typed.
691
+ const RefCompareSection = memo(function RefCompareSection({ directory }) {
692
+ const [project, setProject] = useState(() => {
693
+ const base = String(directory || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
694
+ return base || "trachtenberg";
695
+ });
696
+ const [screen, setScreen] = useState("practice");
697
+ const [stateId, setStateId] = useState("default");
698
+ const [refs, setRefs] = useState([]);
699
+ const [activeId, setActiveId] = useState(null);
700
+ const [opacity, setOpacity] = useState(55);
701
+ const [swipe, setSwipe] = useState(50);
702
+ const [error, setError] = useState("");
703
+ const [busy, setBusy] = useState("");
704
+ const [pair, setPair] = useState(null);
705
+ const [pairVersion, setPairVersion] = useState(0);
706
+ const fileRef = useRef(null);
707
+
708
+ const loadRefs = async (proj) => {
709
+ try {
710
+ const data = await apiGet("/refs?project=" + encodeURIComponent(proj ?? project));
711
+ setRefs(data.records ?? []);
712
+ setActiveId(data.active?.id ?? null);
713
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
714
+ };
715
+ const loadPair = async () => {
716
+ try {
717
+ const data = await apiGet("/refs/compare?project=" + encodeURIComponent(project) +
718
+ "&screen=" + encodeURIComponent(screen) + "&stateId=" + encodeURIComponent(stateId));
719
+ setPair(data);
720
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
721
+ };
722
+ useEffect(() => { loadRefs(project); }, [project]);
723
+ useEffect(() => { loadPair(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, [project, screen, stateId, activeId, pairVersion]);
724
+
725
+ const run = async (name, fn) => {
726
+ setBusy(name); setError("");
727
+ try { await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
728
+ finally { setBusy(""); }
729
+ };
730
+ const importFile = async (file) => {
731
+ if (!file) return;
732
+ const data = await new Promise((resolve, reject) => {
733
+ const reader = new FileReader();
734
+ reader.onload = () => resolve(reader.result);
735
+ reader.onerror = () => reject(reader.error);
736
+ reader.readAsDataURL(file);
737
+ });
738
+ await run("import", async () => {
739
+ await streamPost("/refs/import", { project, screen, stateId, filename: file.name, data, provenance: "manual-import", intendedUse: "compare" });
740
+ await loadRefs(project); setPairVersion((v) => v + 1);
741
+ });
742
+ };
743
+ const capture = async () => {
744
+ await run("capture", async () => {
745
+ await streamPost("/refs/capture", { project, screen, stateId });
746
+ await loadRefs(project); setPairVersion((v) => v + 1);
747
+ });
748
+ };
749
+ const setActive = async (id) => {
750
+ await run("active", async () => {
751
+ await streamPost("/refs/active", { id });
752
+ setActiveId(id); setPairVersion((v) => v + 1);
753
+ });
754
+ };
755
+ const remove = async (id) => {
756
+ await run("remove", async () => {
757
+ await streamPost("/refs/remove", { id });
758
+ await loadRefs(project); setPairVersion((v) => v + 1);
759
+ });
760
+ };
761
+ const [diff, setDiff] = useState(null);
762
+ // Two ceilings, two different reasons. The DECODE ceiling is the browser's:
763
+ // the platform already decoded the bitmap by the time we see naturalWidth,
764
+ // so this only stops us from then allocating a canvas + ImageData copy of
765
+ // an absurd image. The TRANSPORT ceiling is the one that actually bites —
766
+ // /refs/diff caps its request body at 64 MiB (lib/index.js readBody) and a
767
+ // frame travels as base64 RGBA, which inflates by 4/3, so TWO
768
+ // full-resolution frames must fit in 64 MiB ⇒ ~6 MP each. Checking here
769
+ // means a doomed comparison fails before a ~100 MB string is built and
770
+ // posted, instead of after the server rejects the body.
771
+ const MAX_DECODE_PIXELS = 40_000_000;
772
+ const MAX_FULLRES_PIXELS = 6_000_000;
773
+ const decodeFrame = async (src, maxDim = 480) => {
774
+ const img = new Image();
775
+ img.crossOrigin = "anonymous";
776
+ await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error("image decode failed: " + src)); img.src = src; });
777
+ const px = img.naturalWidth * img.naturalHeight;
778
+ const mp = (px / 1_000_000).toFixed(1);
779
+ if (px > MAX_DECODE_PIXELS) throw new Error(`image is ${mp} MP — over the ${MAX_DECODE_PIXELS / 1_000_000} MP decode ceiling`);
780
+ if (maxDim === Infinity && px > MAX_FULLRES_PIXELS) throw new Error(`full-resolution diff needs a frame under ${MAX_FULLRES_PIXELS / 1_000_000} MP (this one is ${mp} MP) — two native frames must fit the 64 MB comparison body; use the downsampled design-reference comparison instead`);
781
+ const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
782
+ const w = Math.max(1, Math.round(img.naturalWidth * scale));
783
+ const hgt = Math.max(1, Math.round(img.naturalHeight * scale));
784
+ const canvas = document.createElement("canvas");
785
+ canvas.width = w; canvas.height = hgt;
786
+ const ctx2d = canvas.getContext("2d", { willReadFrequently: true });
787
+ ctx2d.drawImage(img, 0, 0, w, hgt);
788
+ return { width: w, height: hgt, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, rgba: ctx2d.getImageData(0, 0, w, hgt).data };
789
+ };
790
+ const runDiff = async (fullRes) => {
791
+ if (!refSrc || !capSrc) { setError("need both a reference and a capture to diff"); return; }
792
+ await run("diff", async () => {
793
+ // Regression needs NATIVE frames: decode both at full resolution and
794
+ // require equal original dimensions — the server re-checks them against
795
+ // the stored records before any "passed" is possible. Different aspects
796
+ // stay triage-only (downsampled, needs_review) — never a silent stretch.
797
+ const sameSize = fullRes === true;
798
+ const [a, b] = sameSize
799
+ ? await Promise.all([decodeFrame(refSrc, Infinity), decodeFrame(capSrc, Infinity)])
800
+ : await Promise.all([decodeFrame(refSrc, 480), decodeFrame(capSrc, 480)]);
801
+ if (a.width !== b.width || a.height !== b.height) throw new Error("frame sizes differ (" + a.width + "x" + a.height + " vs " + b.width + "x" + b.height + ") — aspects differ; only downsampled triage applies, record a transform for aligned comparison");
802
+ // Chunked, measured: the per-byte version allocates a single-character
803
+ // string for every byte and leaves btoa() a multi-million-link cons
804
+ // rope to flatten first. Identical output, 407 ms -> 37 ms on a
805
+ // 1080x2400 frame (10.4 MB of RGBA). 8 KiB slices also stay far under
806
+ // the argument-count limit String.fromCharCode.apply() would hit.
807
+ const toB64 = (f) => {
808
+ const bytes = f.rgba.subarray ? f.rgba : Uint8Array.from(f.rgba);
809
+ const parts = [];
810
+ for (let i = 0; i < bytes.length; i += 8192) parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 8192)));
811
+ return btoa(parts.join(""));
812
+ };
813
+ const r = await streamPost("/refs/diff", {
814
+ a: { width: a.width, height: a.height, rgba: toB64(a) },
815
+ b: { width: b.width, height: b.height, rgba: toB64(b) },
816
+ mode: sameSize ? "regression" : "design-reference", project, screen, stateId,
817
+ referenceId: pair?.reference?.id, captureId: pair?.capture?.id,
818
+ });
819
+ setDiff(r);
820
+ });
821
+ };
822
+
823
+ const imgUrl = (record) => record ? API_BASE + "/refs/image?id=" + encodeURIComponent(record.id) : null;
824
+ const refSrc = imgUrl(pair?.reference ?? refs.find((r) => r.id === activeId));
825
+ const capSrc = imgUrl(pair?.capture);
826
+ // native regression only makes sense between IDENTICAL source sizes
827
+ const sizeMatch = !!pair?.reference && !!pair?.capture &&
828
+ pair.reference.width === pair.capture.width && pair.reference.height === pair.capture.height;
829
+ const box = { position: "relative", width: "100%", aspectRatio: "9/19.5", background: "var(--mc-stage)", borderRadius: "var(--mc-r-md)", overflow: "hidden" };
830
+ const fit = { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain" };
831
+
832
+ return h("div", { className: "mc-card", style: { borderTop: "1px solid var(--mc-border)" } },
833
+ h("div", { className: "mc-card-head" }, h("span", null, "Reference compare"),
834
+ h("span", { className: "sp" }), h("span", { style: { opacity: 0.6, fontSize: "var(--mc-fs-label)" } }, busy || "slice")),
835
+ h("div", { className: "mc-row" },
836
+ h("input", { className: "mc-input", "aria-label": "Reference project", value: project, placeholder: "project", onChange: (e) => setProject(e.target.value), style: { flex: 1 } }),
837
+ ),
838
+ h("div", { className: "mc-row" },
839
+ h("input", { className: "mc-input", "aria-label": "Reference screen", value: screen, placeholder: "screen", onChange: (e) => setScreen(e.target.value), style: { flex: 1 } }),
840
+ h("input", { className: "mc-input", "aria-label": "Reference state", value: stateId, placeholder: "state", onChange: (e) => setStateId(e.target.value), style: { flex: 1 } }),
841
+ ),
842
+ h("div", { className: "mc-row" },
843
+ h("button", { className: "mc-btn", type: "button", onClick: () => fileRef.current?.click() },
844
+ busy !== "" ? "Importing…" : "Import reference"),
845
+ h("input", {
846
+ ref: fileRef, type: "file", className: "mc-visually-hidden", tabIndex: -1, "aria-hidden": "true",
847
+ accept: "image/png,image/jpeg,image/webp",
848
+ onChange: (e) => { importFile(e.target.files?.[0]); e.target.value = ""; },
849
+ }),
850
+ h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: capture }, "Capture screen"),
851
+ h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc, title: "reduced-resolution structure triage — needs_review only, can never pass regression", onClick: () => runDiff(false) }, "Diff (triage)"),
852
+ h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc || !sizeMatch, title: sizeMatch ? "full-resolution native comparison — regression verdict" : "source sizes differ — a native regression comparison is invalid between different configurations", onClick: () => runDiff(true) }, "Regression (full-res)"),
853
+ ),
854
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
855
+ h("div", { style: { position: "relative" } },
856
+ (refSrc || capSrc)
857
+ ? h("div", { style: box },
858
+ refSrc && h("img", { src: refSrc, style: fit, alt: "reference" }),
859
+ capSrc && h("img", { src: capSrc, alt: "captured screen", style: { ...fit, clipPath: "inset(0 " + (100 - swipe) + "% 0 0)", opacity: opacity / 100 } },
860
+ /* capture overlaid on reference */),
861
+ h("div", { style: { position: "absolute", left: swipe + "%", top: 0, bottom: 0, width: 1, background: "var(--mc-stage-fg)" } }),
862
+ )
863
+ : h("div", { className: "mc-empty" }, "Import a reference and capture the screen to compare."),
864
+ (refSrc || capSrc) && h("div", { className: "mc-row", style: { marginTop: "var(--mc-s3)", gap: "var(--mc-s4)" } },
865
+ h("label", { style: { display: "flex", alignItems: "center", gap: "var(--mc-s2)", fontSize: "var(--mc-fs-label)" } },
866
+ "opacity", h("input", { type: "range", min: 0, max: 100, value: opacity, onChange: (e) => setOpacity(Number(e.target.value)) })),
867
+ h("label", { style: { display: "flex", alignItems: "center", gap: "var(--mc-s2)", fontSize: "var(--mc-fs-label)" } },
868
+ "swipe", h("input", { type: "range", min: 0, max: 100, value: swipe, onChange: (e) => setSwipe(Number(e.target.value)) })),
869
+ ),
870
+ ),
871
+ h("div", { className: "mc-status" }, h("span", { style: { fontSize: "var(--mc-fs-label)", opacity: 0.7 } },
872
+ "reference " + (refSrc ? "✓" : "—") + " · capture " + (capSrc ? "✓" : "—") +
873
+ (pair?.reference && pair?.capture ? " · sources " + pair.reference.width + "x" + pair.reference.height + " vs " + pair.capture.width + "x" + pair.capture.height + (sizeMatch ? " (full-res regression available)" : " (triage only)") : "") +
874
+ " · persisted in ~/.dsh/mobilecode/reference (survives reloads)")),
875
+ diff && h("div", { style: { border: "1px solid var(--mc-border)", borderRadius: "var(--mc-r-md)", padding: "var(--mc-s4)", fontSize: "var(--mc-fs-label)" } },
876
+ h("div", null, h("b", null, "diff · " + (diff.mode ?? "—") + " · " + (diff.comparisonSpace ?? "—") + " · " + diff.checkStatus),
877
+ diff.diff ? h("span", { style: { opacity: 0.7 } }, " " + diff.diff.changePct + "% changed (" + diff.diff.changedPixels + "/" + diff.diff.totalPixels + " px @ threshold L1>" + diff.diff.threshold + "; " + diff.diff.clusterFilter.droppedPixels + " px in sub-minimum clusters counted in verdict)")
878
+ : h("span", { style: { opacity: 0.7 } }, " " + (diff.reason ?? ""))),
879
+ (diff.diff?.clusters ?? []).slice(0, 4).map((c, i) => h("div", { key: c.x + "," + c.y, style: { opacity: 0.85 } },
880
+ "region cmp " + c.x + "," + c.y + " " + c.width + "×" + c.height + " (" + c.pixels + " px)" +
881
+ (diff.regionsA?.[i] ? " · ref-original " + diff.regionsA[i].x + "," + diff.regionsA[i].y + " " + diff.regionsA[i].width + "×" + diff.regionsA[i].height : "") +
882
+ (diff.regionsB?.[i] ? " · cap-original " + diff.regionsB[i].x + "," + diff.regionsB[i].y + " " + diff.regionsB[i].width + "×" + diff.regionsB[i].height : ""))),
883
+ (diff.env?.masks ?? []).length > 0 && h("div", { style: { opacity: 0.85 } }, "masks: " + diff.env.masks.map((m) => m.reason).join(" | ")),
884
+ h("div", { style: { opacity: 0.6 } }, diff.note ?? ""),
885
+ (diff.limitations ?? []).map((l) => h("div", { key: l, style: { opacity: 0.6 } }, "· " + l)),
886
+ diff.evidenceFile && h("div", { style: { opacity: 0.6 } }, "evidence: " + diff.evidenceFile)),
887
+ refs.length > 0 && h("div", { style: { marginTop: "var(--mc-s3)", display: "flex", flexWrap: "wrap", gap: "var(--mc-s3)" }, role: "group", "aria-label": "Stored references" },
888
+ refs.map((record) => h("span", {
889
+ key: record.id,
890
+ title: (record.provenance ?? "") + " · " + record.importedAt,
891
+ "aria-pressed": record.id === activeId,
892
+ ...activatable(() => setActive(record.id)),
893
+ style: {
894
+ border: "1px solid " + (record.id === activeId ? "var(--mc-accent)" : "var(--mc-border)"),
895
+ borderRadius: "var(--mc-r-sm)", padding: "var(--mc-s1) var(--mc-s4)", fontSize: "var(--mc-fs-label)", cursor: "pointer",
896
+ background: record.id === activeId ? "var(--mc-accent-weak)" : "transparent",
897
+ },
898
+ },
899
+ (record.kind === "capture" ? "📷 " : "🖼 ") + record.screen + "/" + record.stateId,
900
+ record.id === activeId && h("button", {
901
+ type: "button",
902
+ "aria-label": "Delete reference " + record.screen + "/" + record.stateId,
903
+ style: { border: 0, background: "none", cursor: "pointer", marginLeft: "var(--mc-s3)", color: "var(--mc-err)" },
904
+ onClick: (e) => { e.stopPropagation(); remove(record.id); },
905
+ }, "✕"))),
906
+ ),
907
+ );
908
+ });
909
+
910
+ // Same boundary as RefCompareSection above, for the same reason: its one prop
911
+ // is the polled directory, so typing in the panel's own field must not walk
912
+ // the gallery again.
913
+ const PreviewGallerySection = memo(function PreviewGallerySection({ directory }) {
914
+ const [entries, setEntries] = useState([]);
915
+ const [warnings, setWarnings] = useState([]);
916
+ const [gradle, setGradle] = useState(false);
917
+ const [busy, setBusy] = useState("");
918
+ const [error, setError] = useState("");
919
+ const [note, setNote] = useState("");
920
+
921
+ const load = async (dir) => {
922
+ const target = dir ?? directory;
923
+ if (!target) { setEntries([]); setGradle(false); return; }
924
+ try {
925
+ const data = await apiGet("/preview/list?directory=" + encodeURIComponent(target));
926
+ setGradle(!!data.gradle);
927
+ setEntries(data.entries ?? []);
928
+ setWarnings(data.warnings ?? []);
929
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
930
+ };
931
+ useEffect(() => { load(); }, [directory]);
932
+
933
+ const renderOne = async (entry) => {
934
+ setBusy(entry.fqClass + "." + entry.method); setError(""); setNote("");
935
+ try {
936
+ const r = await streamPost("/preview/render", { directory, class: entry.fqClass, method: entry.method });
937
+ if (r.status === "passed") setNote("rendered " + entry.method + (r.provenance?.stale ? " (still stale?)" : ""));
938
+ else setError(entry.method + ": " + (r.failureKind ?? r.status) + " — " + (r.tail ?? []).slice(-2).join(" | "));
939
+ await load();
940
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
941
+ finally { setBusy(""); }
942
+ };
943
+
944
+ return h("div", { className: "mc-card" },
945
+ h("div", { className: "mc-card-head" },
946
+ h("span", null, "Preview gallery"),
947
+ h("span", { className: "sp" }),
948
+ h("button", { className: "mc-btn", onClick: () => load() }, "Refresh"),
949
+ ),
950
+ !gradle && h("div", { className: "mc-empty" }, "Pick a Gradle project directory to discover @PreviewTest composables."),
951
+ gradle && entries.length === 0 && h("div", { className: "mc-empty" }, "No @PreviewTest previews yet — apply the screenshot adapter (docs/design/render-adapter-decision.md)."),
952
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
953
+ note !== "" && h("div", { className: "mc-status", role: "status" }, h("span", { style: { fontSize: "var(--mc-fs-label)" } }, note)),
954
+ warnings.map((w) => h("div", { className: "mc-error", key: w, role: "alert" }, "discovery: " + w)),
955
+ entries.length > 0 && h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "var(--mc-s4)" } },
956
+ entries.map((entry) => h("div", { key: entry.fqClass + "." + entry.method, style: { border: "1px solid var(--mc-border)", borderRadius: "var(--mc-r-md)", padding: "var(--mc-s3)", fontSize: "var(--mc-fs-label)" } },
957
+ entry.rendered
958
+ ? h("img", { src: API_BASE + "/preview/image?directory=" + encodeURIComponent(directory) + "&class=" + encodeURIComponent(entry.fqClass) + "&method=" + encodeURIComponent(entry.method), alt: entry.previewName, style: { width: "100%", borderRadius: "var(--mc-r-sm)", display: "block" } })
959
+ // 150 is a component dimension, not a scale step: it stands in for a rendered
960
+ // frame whose height nobody knows yet, so it belongs to no ladder.
961
+ : h("div", { style: { height: 150, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 } }, "not rendered"),
962
+ h("div", { style: { marginTop: "var(--mc-s2)", fontWeight: 600 } }, entry.previewName),
963
+ entry.rendered && entry.freshness !== "fresh_tracked_inputs" && h("span", { style: { color: "var(--mc-warn)" } },
964
+ entry.freshness === "unknown" ? "freshness unknown (no manifest)" : entry.freshness === "last_run_failed" ? "last render failed" : "stale — tracked inputs changed"),
965
+ h("button", { className: "mc-btn", disabled: busy !== "", style: { width: "100%", marginTop: "var(--mc-s2)" }, onClick: () => renderOne(entry) },
966
+ busy === entry.fqClass + "." + entry.method ? "rendering…" : "Render"),
967
+ )),
968
+ ),
969
+ entries.length > 0 && h("div", { className: "mc-status" },
970
+ h("span", { style: { fontSize: "var(--mc-fs-label)", opacity: 0.7 } }, "host-rendered (layoutlib) — NOT device-verified · failed/stale renders never labeled current")),
971
+ );
972
+ });
973
+
974
+ function MobileCodePanel({ controller }) {
975
+ const [directory, setDirectory] = useState(readDir);
976
+ const [info, setInfo] = useState(null);
977
+ const [error, setError] = useState("");
978
+ const [busy, setBusy] = useState("");
979
+ const [openLogs, setOpenLogs] = useState({});
980
+ const [frameKey, setFrameKey] = useState(0);
981
+ const [settingsOpen, setSettingsOpen] = useState(false);
982
+
983
+ // Poll while the panel is open. The pane is NOT unmounted when it closes —
984
+ // mountPanel only sets container.hidden — so this has to SUBSCRIBE: reading
985
+ // the flag once at render left the 2 s poll hammering the host API forever
986
+ // behind a hidden pane.
987
+ const open = useSyncExternalStore(controller.subscribe, controller.getSnapshot).panelOpen;
988
+ const lastPayload = useRef("");
989
+ useEffect(() => {
990
+ if (!open) return;
991
+ let stopped = false;
992
+ let timer;
993
+ const poll = async () => {
994
+ try {
995
+ const current = await getInfo(readDir());
996
+ if (!stopped) {
997
+ // A tick that changes nothing must cost nothing. The host
998
+ // answers with a freshly parsed object every time, so React's
999
+ // Object.is bailout never fired and the WHOLE panel subtree —
1000
+ // live stages included — re-rendered every 2 s for as long as
1001
+ // the pane stayed open. Identical bytes in means identical
1002
+ // output out; same guard as the device poll in LiveDeviceCard.
1003
+ // Measured by test/render.mjs: 7 component bodies per tick
1004
+ // before this, 0 after.
1005
+ const payload = JSON.stringify(current);
1006
+ if (payload !== lastPayload.current) {
1007
+ lastPayload.current = payload;
1008
+ setInfo(current);
1009
+ setError("");
1010
+ }
1011
+ }
1012
+ } catch (err) {
1013
+ if (!stopped) setError(err instanceof Error ? err.message : String(err));
1014
+ }
1015
+ if (!stopped) timer = setTimeout(poll, 2000);
1016
+ };
1017
+ poll();
1018
+ return () => { stopped = true; clearTimeout(timer); };
1019
+ }, [open]);
1020
+
1021
+ const act = async (name, path, body) => {
1022
+ setBusy(name);
1023
+ setError("");
1024
+ try {
1025
+ const current = await post(path, { directory, ...body });
1026
+ setInfo(current);
1027
+ } catch (err) {
1028
+ setError(err instanceof Error ? err.message : String(err));
1029
+ } finally {
1030
+ setBusy("");
1031
+ }
1032
+ };
1033
+
1034
+ const detect = async () => {
1035
+ setBusy("detect");
1036
+ setError("");
1037
+ try {
1038
+ const current = await getInfo(directory);
1039
+ setInfo(current);
1040
+ writeDir(directory);
1041
+ } catch (err) {
1042
+ setError(err instanceof Error ? err.message : String(err));
1043
+ } finally {
1044
+ setBusy("");
1045
+ }
1046
+ };
1047
+
1048
+ const server = (platform) => info?.servers?.find((s) => s.platform === platform);
1049
+ const build = (platform) => info?.builds?.find((b) => b.platform === platform);
1050
+ const platforms = info?.platforms?.length > 0 ? info.platforms : fallbackPlatforms(info?.os);
1051
+
1052
+ const statusClass = (status) =>
1053
+ status === "running" ? "on" : status === "starting" ? "warn" : status === "exited" ? "off" : "err";
1054
+
1055
+ const toggleLog = (key) => setOpenLogs((prev) => ({ ...prev, [key]: !prev[key] }));
1056
+
1057
+ const logsOf = (entries) => {
1058
+ const lines = [];
1059
+ for (const entry of entries ?? []) {
1060
+ lines.push(`[${entry.platform}] ${entry.status}${entry.step ? " — " + entry.step : ""}`);
1061
+ if (entry.error) lines.push(` error: ${entry.error}`);
1062
+ }
1063
+ return lines.join("\n");
1064
+ };
1065
+
1066
+ return h("div", { className: "mc-panel" },
1067
+ h("div", { className: "mc-panel-col" },
1068
+ h("div", { className: "mc-header" },
1069
+ h("h2", { className: "mc-title" }, "Devices"),
1070
+ h("button", {
1071
+ className: "mc-close",
1072
+ "aria-label": "Settings — doctor, PaddleOCR, AI prompt",
1073
+ title: "Settings — doctor, PaddleOCR, AI prompt",
1074
+ onClick: () => setSettingsOpen(true),
1075
+ }, "⚙"),
1076
+ h("button", { className: "mc-close", "aria-label": "Close the Devices pane", onClick: () => controller.setOpen(false) }, "✕"),
1077
+ ),
1078
+ h("div", { className: "mc-body" },
1079
+ h("div", { className: "mc-row" },
1080
+ h("input", {
1081
+ className: "mc-input",
1082
+ "aria-label": "Project directory",
1083
+ placeholder: "Project directory (leave empty for the session default)",
1084
+ value: directory,
1085
+ onChange: (e) => setDirectory(e.target.value),
1086
+ onKeyDown: (e) => { if (e.key === "Enter") detect(); },
1087
+ }),
1088
+ h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: detect }, "Detect"),
1089
+ ),
1090
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
1091
+ h("div", { className: "mc-pills" },
1092
+ platforms.map((platform) => {
1093
+ // 0.10.0: Android's "server" IS the Device 1 stream — the pill
1094
+ // tracks attached devices, not a serve-avd process.
1095
+ const running = platform === "android"
1096
+ ? (info?.deviceCount ?? 0) > 0
1097
+ : server(platform)?.status === "running";
1098
+ return h("span", { key: platform, className: "mc-pill", "data-on": running ? "true" : undefined,
1099
+ title: platform === "android" ? (running ? "Android device attached — mirrored in Device 1" : "No Android device attached") : "Preview server running" },
1100
+ h("span", { className: "mc-dot " + (running ? "on" : "off") }),
1101
+ label(platform),
1102
+ );
1103
+ }),
1104
+ ),
1105
+ h(LiveStreamSection, { active: open }, null),
1106
+ platforms.map((platform) => {
1107
+ const srv = server(platform);
1108
+ const bld = build(platform);
1109
+ // 0.10.0 merge: "Start server" (serve-avd iframe) and "Device 1" were
1110
+ // two views of the same screen. The in-process stream won; the Android
1111
+ // card keeps project controls only. iOS keeps serve-sim (its only view).
1112
+ const preview = platform === "ios";
1113
+ return h("div", { key: platform, className: "mc-card" },
1114
+ h("div", { className: "mc-card-head" },
1115
+ h("span", null, label(platform)),
1116
+ h("span", { className: "sp" }),
1117
+ preview && (srv
1118
+ ? h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => act(platform + "-stop-server", "/stop", { platform }) },
1119
+ srv.status === "running" ? "Stop server" : "Server " + srv.status)
1120
+ : h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: () => act(platform + "-start-server", "/start", { platform }) }, "Start server")),
1121
+ !preview && h("span", { className: "mc-live-cap" }, "screen: Device 1 ↑"),
1122
+ ),
1123
+ preview && (srv?.url
1124
+ ? h("iframe", {
1125
+ key: frameKey,
1126
+ className: "mc-frame",
1127
+ src: srv.url,
1128
+ sandbox: "allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-downloads",
1129
+ title: label(platform) + " preview",
1130
+ })
1131
+ : srv?.status === "starting"
1132
+ ? h("div", { className: "mc-status", role: "status" }, h("span", null, h("span", { className: "mc-dot warn" }), " Starting preview server…"))
1133
+ : null),
1134
+ h("div", { className: "mc-row" },
1135
+ h("button", {
1136
+ className: "mc-btn primary",
1137
+ disabled: busy !== "",
1138
+ onClick: () => { act(platform + "-run", "/run", { platform }); setFrameKey((k) => k + 1); },
1139
+ }, "Run app"),
1140
+ h("button", { className: "mc-btn danger", disabled: busy !== "", onClick: () => act(platform + "-stop", "/run/stop", { platform }) }, "Stop app"),
1141
+ ),
1142
+ bld
1143
+ ? h("div", { className: "mc-status", role: "status" },
1144
+ h("span", null,
1145
+ h("span", { className: "mc-dot " + statusClass(bld.status) }),
1146
+ " Build: ", h("b", null, bld.status),
1147
+ bld.step ? " — " + bld.step : "",
1148
+ bld.target ? " · target " + bld.target : "",
1149
+ bld.appID ? " · " + bld.appID : "",
1150
+ ),
1151
+ bld.error !== undefined && bld.error !== "" && h("div", { className: "mc-error", role: "alert" }, bld.error),
1152
+ bld.log.length > 0 &&
1153
+ h("button", {
1154
+ className: "mc-log-toggle",
1155
+ "aria-expanded": openLogs[platform + "-build"] === true,
1156
+ "aria-controls": "mc-log-" + platform,
1157
+ onClick: () => toggleLog(platform + "-build"),
1158
+ }, openLogs[platform + "-build"] ? "Hide log" : "Show log (" + bld.log.length + ")"),
1159
+ // Scrollable, so it has to be reachable by keyboard (WCAG 2.1.1): Safari does not
1160
+ // make an overflow box focusable on its own, and a keyboard-only user cannot scroll
1161
+ // what they cannot focus. Deliberately NOT role="log": the lines are re-joined and
1162
+ // re-rendered whole on every 2s poll, so a polite live region would re-announce the
1163
+ // entire log each tick. An accessible name is enough to make it identifiable.
1164
+ openLogs[platform + "-build"] && h("pre", { className: "mc-log", id: "mc-log-" + platform, tabIndex: 0, "aria-label": "Build log for " + platform }, bld.log.join("\n")),
1165
+ )
1166
+ : h("div", { className: "mc-empty" }, "Nothing built yet."),
1167
+ );
1168
+ }),
1169
+ info?.bundler &&
1170
+ h("div", { className: "mc-card" },
1171
+ h("div", { className: "mc-card-head" },
1172
+ h("span", null, "Metro"),
1173
+ h("span", { className: "mc-dot " + statusClass(info.bundler.status) }),
1174
+ h("span", { className: "sp" }),
1175
+ h("span", null, info.bundler.status + (info.bundler.url ? " · " + info.bundler.url : "")),
1176
+ ),
1177
+ info.bundler.log.length > 0 &&
1178
+ h("button", {
1179
+ className: "mc-log-toggle",
1180
+ "aria-expanded": openLogs["bundler"] === true,
1181
+ "aria-controls": "mc-log-bundler",
1182
+ onClick: () => toggleLog("bundler"),
1183
+ }, openLogs["bundler"] ? "Hide log" : "Show log (" + info.bundler.log.length + ")"),
1184
+ openLogs["bundler"] && h("pre", { className: "mc-log", id: "mc-log-bundler", tabIndex: 0, "aria-label": "Metro bundler log" }, info.bundler.log.join("\n")),
1185
+ ),
1186
+ info?.servers?.length === 0 && info?.builds?.length === 0 && (info?.deviceCount ?? 0) === 0 &&
1187
+ h("div", { className: "mc-empty" }, "Nothing running yet — pick a project directory and press Run app, or boot an AVD in Device 1 above."),
1188
+ h(RefCompareSection, { directory: info?.directory ?? "" }),
1189
+ h(PreviewGallerySection, { directory: info?.directory ?? "" }),
1190
+ ),
1191
+ h("div", { className: "mc-footer" },
1192
+ h("span", null, "dsh-mobilecode · polls every 2s"),
1193
+ info && info.platforms.length > 0 && h("span", null, "detected: " + info.platforms.map(label).join(", ")),
1194
+ ),
1195
+ ),
1196
+ settingsOpen && h(SettingsDialog, { onClose: () => setSettingsOpen(false) }),
1197
+ );
1198
+ }
1199
+
1200
+ // 0.7.0: the conversation cards (tool.call.toolview) and the composer
1201
+ // capsule (conversation.input.dock) live outside our panel tree, so the
1202
+ // controller they need to open the panel is a module-level reference set
1203
+ // by apply() before the slots register. Auto-open fires exactly once per
1204
+ // page load (a settled boot/stream card opens the panel).
1205
+ let activeController = null;
1206
+ let autoOpenedOnce = false;
1207
+ /** Stable stand-in store so the capsule hooks are unconditional even before apply() runs. */
1208
+ const DUMMY_SNAPSHOT = { panelOpen: true };
1209
+ const DUMMY_STORE = { subscribe: () => () => {}, getSnapshot: () => DUMMY_SNAPSHOT };
1210
+
1211
+ /** Extract text from a tool-result block (string content or pages of {type,text}). */
1212
+ function blockText(block) {
1213
+ if (typeof block?.content === "string") return block.content;
1214
+ if (Array.isArray(block?.content)) return block.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
1215
+ return "";
1216
+ }
1217
+
1218
+ /** Compact conversation card for tools that emit android-stream meta.
1219
+ * ToolCallBlock model (matches the rc.6 runtime): a settled result has
1220
+ * `kind: "tool-result"` + `isError`; a running block has neither, so it
1221
+ * resolves here to null and the runtime's own running UI shows. */
1222
+ function MobileCodeToolCard(props) {
1223
+ const block = props?.block ?? null;
1224
+ const toolName = props?.toolName ?? block?.toolName ?? "";
1225
+ const settled = block !== null && typeof block === "object" && "kind" in block;
1226
+ const error = settled && block.isError === true;
1227
+ // block.meta IS the host-projected presentationMeta envelope (see
1228
+ // dsh-android parseAndroidMeta). Nested calls that carry no meta fall
1229
+ // back to hydrating the serial from the durable text.
1230
+ const meta = settled && !error ? block?.meta ?? null : null;
1231
+ const kind = meta?.kind ?? null;
1232
+ const serial = meta?.device?.serial
1233
+ ?? (/(emulator-\d+|(?:RFC[0-9A-F]{7,}|[A-Za-z0-9]{6,}))/i.exec(blockText(block))?.[1] ?? "");
1234
+ // Auto-open the panel once when a stream settles (boot/stream start).
1235
+ useEffect(() => {
1236
+ if (settled && kind === "android-stream" && !autoOpenedOnce && activeController) {
1237
+ autoOpenedOnce = true;
1238
+ activeController.setOpen(true);
1239
+ }
1240
+ }, [settled, kind]);
1241
+ if (!settled || kind !== "android-stream") return null;
1242
+ const badge = error ? "failed" : "streaming";
1243
+ return h("div", { className: "mc-card-compact" },
1244
+ h("span", { className: "mc-card-kind" }, "Android stream"),
1245
+ serial !== "" && h("span", { className: "mc-serial" }, serial),
1246
+ h("span", { className: "mc-badge" + (error ? " err" : " ok") }, badge),
1247
+ h("span", { className: "sp" }),
1248
+ activeController && h("button", { className: "mc-card-open", onClick: () => activeController.setOpen(true) }, "⤢ open in panel"),
1249
+ );
1250
+ }
1251
+
1252
+ /** Composer capsule: green "● <serial>" pill while a device streams and the panel is closed. */
1253
+ function MobileCodeStatusCapsule() {
1254
+ const [running, setRunning] = useState(false);
1255
+ const [status, setStatus] = useState(null);
1256
+ const store = activeController ?? DUMMY_STORE;
1257
+ const { panelOpen } = useSyncExternalStore(store.subscribe, store.getSnapshot);
1258
+ useEffect(() => {
1259
+ // The capsule is hidden while the pane is open, so stop the timer outright
1260
+ // rather than waking every 5 s only to decide not to poll.
1261
+ if (panelOpen) return;
1262
+ let stopped = false;
1263
+ const poll = async () => {
1264
+ try {
1265
+ const r = await streamPost("/stream/status", {});
1266
+ if (stopped) return;
1267
+ setRunning(r.running === true);
1268
+ // Only the serial reaches the DOM. Returning the previous object
1269
+ // when it is unchanged lets React bail out of the re-render.
1270
+ setStatus((prev) => (prev?.serial === r.serial ? prev : r));
1271
+ } catch {
1272
+ if (stopped) return;
1273
+ setRunning(false);
1274
+ setStatus(null);
1275
+ }
1276
+ };
1277
+ poll();
1278
+ const timer = setInterval(poll, 5000);
1279
+ return () => { stopped = true; clearInterval(timer); };
1280
+ }, [panelOpen]);
1281
+ if (!running || panelOpen) return null;
1282
+ return h("button", { className: "mc-capsule", onClick: () => activeController?.setOpen(true) },
1283
+ h("span", { className: "mc-dot on" }),
1284
+ h("span", null, status?.serial ?? "device stream"),
1285
+ );
1286
+ }
1287
+
1288
+ /** Shared stream lifecycle (0.9.0 co-op): HMAC-granted frame URL with a
1289
+ * re-mint timer, plus tap/drag pointer mapping for the <img>. Used by
1290
+ * the full card and by the second co-op pane. */
1291
+ /** Keyboard swipe distance as a fraction of the stage: a quarter-screen
1292
+ * is a decisive fling on a phone UI, and comfortably above the 2%
1293
+ * travel that would make it read as a tap. */
1294
+ const KEY_STEP = 0.25;
1295
+ function useLiveStream({ serial, onError }) {
1296
+ const [streamUrl, setStreamUrl] = useState("");
1297
+ // Native aspect ratio (w/h) of the streamed frames, learned from the
1298
+ // first decoded MJPEG frame so the Fit stage has the device's real
1299
+ // shape — zero letterboxing regardless of pane/device mix (0.11.5).
1300
+ const [ar, setAr] = useState(null);
1301
+ const imgRef = useRef(null);
1302
+ const dragRef = useRef(null);
1303
+ const grantTimer = useRef(null);
1304
+ const serialRef = useRef(serial);
1305
+ serialRef.current = serial;
1306
+ const onErrorRef = useRef(onError);
1307
+ onErrorRef.current = onError;
1308
+
1309
+ const grant = async (device) => {
1310
+ if (!device) return false;
1311
+ try {
1312
+ const r = await streamPost("/stream/grant", { device });
1313
+ // Absolute URL: the plugin card can be hosted under a different
1314
+ // origin/path than the API, and <img> src ignores fetch()'s base.
1315
+ setStreamUrl(location.origin + r.streamUrl);
1316
+ clearTimeout(grantTimer.current);
1317
+ // Re-mint a minute before the 10-minute capability expires.
1318
+ const ms = Math.max(30000, (r.expiresAt - Date.now()) - 60000);
1319
+ grantTimer.current = setTimeout(() => { grant(r.device).catch(() => {}); }, ms);
1320
+ return true;
1321
+ } catch (err) {
1322
+ onErrorRef.current(err instanceof Error ? err.message : String(err));
1323
+ setStreamUrl("");
1324
+ return false;
1325
+ }
1326
+ };
1327
+ const reset = () => { clearTimeout(grantTimer.current); setStreamUrl(""); };
1328
+ useEffect(() => () => clearTimeout(grantTimer.current), []);
1329
+ // Presence watcher (0.11.3): an MJPEG <img> does NOT fire error when
1330
+ // the device dies — it just freezes on the last frame with the Live
1331
+ // badge stuck. While a stream is up, poll device presence and drop
1332
+ // the stream if the serial vanished (⏻ off, crash, unplug).
1333
+ useEffect(() => {
1334
+ if (streamUrl === "") return;
1335
+ const watch = setInterval(async () => {
1336
+ try {
1337
+ const r = await streamPost("/stream/devices", {});
1338
+ if (!r.devices.some((d) => d.serial === serialRef.current)) {
1339
+ clearTimeout(grantTimer.current);
1340
+ setStreamUrl("");
1341
+ onErrorRef.current("device offline — stream stopped");
1342
+ }
1343
+ } catch { /* the next tick decides */ }
1344
+ }, 5000);
1345
+ return () => clearInterval(watch);
1346
+ }, [streamUrl]);
1347
+
1348
+ const control = useCallback(async (action) => {
1349
+ if (serialRef.current === "") return;
1350
+ try { await streamPost("/stream/control", { device: serialRef.current, action }); }
1351
+ catch (err) { onErrorRef.current(err instanceof Error ? err.message : String(err)); }
1352
+ }, []);
1353
+ const norm = useCallback((clientX, clientY) => {
1354
+ const el = imgRef.current;
1355
+ if (!el) return null;
1356
+ const rect = el.getBoundingClientRect();
1357
+ if (rect.width === 0 || rect.height === 0) return null;
1358
+ return {
1359
+ x: Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)),
1360
+ y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
1361
+ };
1362
+ }, []);
1363
+ // Gesture coalescing: remember the down point + timestamp; on up,
1364
+ // taps (<2% travel) vs drags keep the real duration (clamped 0..5s).
1365
+ const onDown = useCallback((e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = { ...p, t: Date.now() }; }, [norm]);
1366
+ const onUp = useCallback((e) => {
1367
+ const start = dragRef.current;
1368
+ dragRef.current = null;
1369
+ const end = norm(e.clientX, e.clientY);
1370
+ if (!start || !end) return;
1371
+ const durationMs = Math.max(0, Math.min(5000, Date.now() - start.t));
1372
+ if (Math.hypot(end.x - start.x, end.y - start.y) > 0.02) {
1373
+ control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y, durationMs });
1374
+ } else {
1375
+ control({ kind: "tap", x: end.x, y: end.y });
1376
+ }
1377
+ }, [control, norm]);
1378
+ // Keyboard equivalent of those gestures (WCAG 2.1.1). The stage is
1379
+ // focusable but was pointer-only: a keyboard user could tab to it,
1380
+ // get a focus ring, and do nothing. Arrows swipe a quarter-screen
1381
+ // from the centre — 25% travel is well past the 2% tap threshold,
1382
+ // so it is delivered as a drag — and Enter/Space tap the centre.
1383
+ const onKeyDown = useCallback((e) => {
1384
+ const dx = e.key === "ArrowRight" ? KEY_STEP : e.key === "ArrowLeft" ? -KEY_STEP : 0;
1385
+ const dy = e.key === "ArrowDown" ? KEY_STEP : e.key === "ArrowUp" ? -KEY_STEP : 0;
1386
+ if (dx !== 0 || dy !== 0) {
1387
+ e.preventDefault();
1388
+ control({ kind: "drag", fromX: 0.5, fromY: 0.5, toX: 0.5 + dx, toY: 0.5 + dy, durationMs: 200 });
1389
+ return;
1390
+ }
1391
+ if (e.key === "Enter" || e.key === " ") {
1392
+ e.preventDefault();
1393
+ control({ kind: "tap", x: 0.5, y: 0.5 });
1394
+ }
1395
+ }, [control]);
1396
+ // Aspect-ratio learner: MJPEG frames decode into the same <img>, so
1397
+ // naturalWidth/naturalHeight is live. Poll once a second while the
1398
+ // stream is up; stage boxes read --mc-ar-n from here (fit mode).
1399
+ useEffect(() => {
1400
+ if (streamUrl === "") return;
1401
+ const tick = setInterval(() => {
1402
+ const el = imgRef.current;
1403
+ if (el && el.naturalWidth > 0 && el.naturalHeight > 0) {
1404
+ const a = +(el.naturalWidth / el.naturalHeight).toFixed(4);
1405
+ setAr((prev) => (prev === a ? prev : a));
1406
+ }
1407
+ }, 1000);
1408
+ return () => clearInterval(tick);
1409
+ }, [streamUrl]);
1410
+
1411
+ return {
1412
+ streamUrl, grant, reset, control, ar,
1413
+ // aria-label carries the keyboard affordance alongside the name;
1414
+ // it supersedes the consumer's alt text as the accessible name,
1415
+ // so it has to say everything the alt did and then some.
1416
+ imgProps: useMemo(() => ({
1417
+ ref: imgRef,
1418
+ draggable: false,
1419
+ onPointerDown: onDown,
1420
+ onPointerUp: onUp,
1421
+ onKeyDown,
1422
+ "aria-label": "Live screen of " + (serial || "the device") + " — arrow keys swipe, Enter taps the centre",
1423
+ }), [onDown, onUp, onKeyDown, serial]),
1424
+ };
1425
+ }
1426
+
1427
+ /** Device 1 stream: picker-popover header, SVG toolbar pill, device
1428
+ * menu, quick sizes + frame styles, and tap/drag with duration. */
1429
+ function LiveDeviceCard({ coopOn, onToggleCoop, onSerial, active }) {
1430
+ const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
1431
+ const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
1432
+ const [devices, setDevices] = useState([]);
1433
+ const [avds, setAvds] = useState([]);
1434
+ const [serial, setSerial] = useState("");
1435
+ const [error, setError] = useState("");
1436
+ const [pickerOpen, setPickerOpen] = useState(false);
1437
+ const [still, setStill] = useState(null);
1438
+ const [view, setView] = useState("live"); // 'live' | 'still'
1439
+ // sizeMode/frame are per-pane (0.11.2); StageControls renders the
1440
+ // identical row in both cards, and Fit locks both to one height.
1441
+ const [booting, setBooting] = useState(""); // AVD name while POST /boot is in flight
1442
+ const [powering, setPowering] = useState(""); // serial while POST /off is in flight
1443
+ const live = useLiveStream({ serial, onError: setError });
1444
+ const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
1445
+ const control = live.control;
1446
+ const serialRef = useRef(serial);
1447
+ serialRef.current = serial;
1448
+ const autoPicked = useRef(false);
1449
+ const lastPayload = useRef("");
1450
+ useEffect(() => { if (onSerial) onSerial(serial); }, [serial]);
1451
+
1452
+ const refreshDevices = async () => {
1453
+ try {
1454
+ const r = await streamPost("/stream/devices", {});
1455
+ // Every tick handed React a fresh array, so the card re-rendered
1456
+ // every 5 s even when the device list had not changed. The payload
1457
+ // is a handful of small objects: identical bytes in means
1458
+ // identical output out, so skip the write entirely.
1459
+ const payload = JSON.stringify(r);
1460
+ if (payload !== lastPayload.current) {
1461
+ lastPayload.current = payload;
1462
+ setDevices(r.devices);
1463
+ setAvds(r.avds ?? []);
1464
+ }
1465
+ // Prefer emulators for the welcome auto-pick: a physical
1466
+ // serial sorts first in adb order but has no ⏻ off control.
1467
+ // (Serial prefix, not the avd field — avdName() can fail.)
1468
+ const isEmu = (d) => d.serial.startsWith("emulator-");
1469
+ const first = r.devices.find((d) => d.streaming && isEmu(d))?.serial ?? r.devices.find(isEmu)?.serial
1470
+ ?? r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? "";
1471
+ const cur = serialRef.current;
1472
+ if (cur === "") {
1473
+ // Auto-stream once per page load (the welcome flow). After
1474
+ // an explicit ⏻ off, the card stays idle — it must NOT
1475
+ // silently grab the partner device (0.11.3).
1476
+ if (!autoPicked.current && first !== "") { autoPicked.current = true; setSerial(first); }
1477
+ } else if (!r.devices.some((d) => d.serial === cur)) {
1478
+ autoPicked.current = true;
1479
+ setSerial(""); live.reset();
1480
+ }
1481
+ } catch (err) {
1482
+ setError(err instanceof Error ? err.message : String(err));
1483
+ }
1484
+ };
1485
+
1486
+ // Only while the pane is actually visible: the panel tree stays mounted
1487
+ // behind container.hidden, so an unguarded interval would poll `adb devices`
1488
+ // every 5 s for the whole life of the page.
1489
+ useEffect(() => {
1490
+ if (!active) return;
1491
+ refreshDevices();
1492
+ const timer = setInterval(refreshDevices, 5000);
1493
+ return () => clearInterval(timer);
1494
+ }, [active]);
1495
+
1496
+ // Auto-grant once a serial is known, no stream, and we're in live view.
1497
+ useEffect(() => { if (serial !== "" && live.streamUrl === "" && view === "live") grant(serial); }, [serial, live.streamUrl, view]);
1498
+
1499
+ const capture = async () => {
1500
+ if (serial === "") return;
1501
+ try {
1502
+ const r = await streamPost("/stream/still", { device: serial });
1503
+ if (r.dataUrl) {
1504
+ setStill(r);
1505
+ setView("still");
1506
+ setError("");
1507
+ } else {
1508
+ setStill(null);
1509
+ setView("live");
1510
+ setError("still saved to " + (r.path ?? "a temp file") + " but too large to embed here");
1511
+ }
1512
+ } catch (err) {
1513
+ setError(err instanceof Error ? err.message : String(err));
1514
+ }
1515
+ };
1516
+
1517
+ const pick = (device) => { autoPicked.current = true; setSerial(device); live.reset(); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
1518
+ // 0.10.0 merged "Start server": boot a configured AVD from the picker.
1519
+ // The route waits for boot completion; then we re-list and stream it.
1520
+ const boot = async (avd) => {
1521
+ setBooting(avd); setError("");
1522
+ try {
1523
+ const r = await streamPost("/boot", { avd });
1524
+ await refreshDevices();
1525
+ if (r.serial) pick(r.serial);
1526
+ } catch (err) {
1527
+ setError(err instanceof Error ? err.message : String(err));
1528
+ await refreshDevices();
1529
+ } finally {
1530
+ setBooting("");
1531
+ }
1532
+ };
1533
+ // 0.11.0 device power-off: graceful `adb emu kill` via POST /off.
1534
+ // If we just killed the streamed device, drop back to the picker.
1535
+ const powerOff = async (device) => {
1536
+ setPowering(device); setError("");
1537
+ try {
1538
+ await streamPost("/off", { serial: device });
1539
+ if (device === serial) { setSerial(""); live.reset(); }
1540
+ await refreshDevices();
1541
+ } catch (err) {
1542
+ setError(err instanceof Error ? err.message : String(err));
1543
+ await refreshDevices();
1544
+ } finally {
1545
+ setPowering("");
1546
+ }
1547
+ };
1548
+ const showLive = () => { setView("live"); if (serial !== "" && live.streamUrl === "") grant(serial); };
1549
+
1550
+ const sizeStyle = stageSizeStyle(sizeMode);
1551
+ const stageClass = stageClassFor(frame, sizeMode.mode === "fit");
1552
+ const runningAvds = new Set(devices.map((d) => d.avd).filter(Boolean));
1553
+ const toolbar = navToolbar(control, capture, () => { if (serial !== "") grant(serial); });
1554
+
1555
+ return h("div", { className: "mc-card mc-live" },
1556
+ h("div", { className: "mc-card-head" },
1557
+ h("span", null, "Device 1"),
1558
+ live.streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
1559
+ h("span", { className: "sp" }),
1560
+ h("div", { className: "mc-picker" },
1561
+ h("button", {
1562
+ className: "mc-btn", type: "button",
1563
+ "aria-haspopup": "listbox", "aria-expanded": pickerOpen,
1564
+ onClick: () => setPickerOpen((v) => !v),
1565
+ }, serial || "no device", " ▾"),
1566
+ pickerOpen && h("div", { className: "mc-picker-pop" },
1567
+ devices.length > 0
1568
+ ? h("div", { className: "mc-picker-group" },
1569
+ h("div", { className: "mc-picker-label" }, "Online devices"),
1570
+ devices.map((d) => h("div", {
1571
+ key: d.serial,
1572
+ className: "mc-picker-row" + (d.serial === serial ? " on" : ""),
1573
+ },
1574
+ // The row's action is a real button and the ⏻ off control is its
1575
+ // SIBLING. role="button" on the row would make its children
1576
+ // presentational and drop the off button out of the a11y tree
1577
+ // (axe nested-interactive, WCAG 4.1.2).
1578
+ h("button", {
1579
+ className: "mc-pick", type: "button",
1580
+ "aria-current": d.serial === serial ? "true" : undefined,
1581
+ onClick: () => pick(d.serial),
1582
+ },
1583
+ h("span", { "aria-hidden": "true" }, d.kind === "emulator" ? "🖥" : "📱"),
1584
+ h("span", { className: "mc-serial" }, d.avd ? d.avd + " · " + d.serial : d.serial),
1585
+ d.streaming && h("span", { className: "mc-badge" }, "streaming"),
1586
+ ),
1587
+ d.kind === "emulator" && h("button", {
1588
+ className: "mc-btn", type: "button", disabled: booting !== "" || powering !== "",
1589
+ title: "Shut down this emulator (adb emu kill)",
1590
+ "aria-label": "Shut down " + d.serial,
1591
+ onClick: () => powerOff(d.serial),
1592
+ }, powering === d.serial ? "stopping…" : "⏻ off"),
1593
+ )),
1594
+ )
1595
+ : h("div", { className: "mc-picker-hint" }, "No device attached"),
1596
+ // Running AVDs already have their row (with ⏻ off) above;
1597
+ // the boot list shows only the stopped ones.
1598
+ avds.filter((avd) => !runningAvds.has(avd)).length > 0 && h("div", { className: "mc-picker-group" },
1599
+ h("div", { className: "mc-picker-label" }, "AVDs"),
1600
+ avds.filter((avd) => !runningAvds.has(avd)).map((avd) => h("div", { key: avd, className: "mc-picker-row" },
1601
+ h("span", null, "▫ " + avd),
1602
+ h("span", { className: "sp" }),
1603
+ h("button", {
1604
+ className: "mc-btn", type: "button", disabled: booting !== "" || powering !== "",
1605
+ title: "Boot this AVD (runs detached; it appears online when ready)",
1606
+ "aria-label": "Boot " + avd,
1607
+ onClick: () => boot(avd),
1608
+ }, booting === avd ? "booting…" : "⏻ boot"),
1609
+ )),
1610
+ ),
1611
+ ),
1612
+ ),
1613
+ h(DeviceMenu, { serial, onError: setError }),
1614
+ // 0.9.0 co-op: side-by-side second pane (needs two online devices).
1615
+ devices.length >= 2 && h("button", {
1616
+ className: "mc-btn", type: "button", "data-on": coopOn || undefined,
1617
+ "aria-pressed": coopOn === true,
1618
+ title: "Co-op: watch Device 1 + Device 2 side by side",
1619
+ "aria-label": "Co-op: watch Device 1 and Device 2 side by side",
1620
+ onClick: onToggleCoop,
1621
+ }, "⧉"),
1622
+ ),
1623
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
1624
+ booting !== "" && h("div", { className: "mc-status", role: "status" },
1625
+ h("span", null, h("span", { className: "mc-dot warn" }), " Booting " + booting + " — it appears here once online (about a minute).")),
1626
+ // 0.11.0 audit: no ghost affordances — the toolbar appears once a
1627
+ // device is selected (disabled-grey read as broken).
1628
+ serial !== "" && h(ToolbarRow, { items: toolbar }),
1629
+ h("div", { className: stageClass, style: sizeMode.mode === "fit" && live.ar != null ? { "--mc-ar-n": live.ar } : undefined },
1630
+ view === "still" && still?.dataUrl
1631
+ ? h("img", { src: still.dataUrl, alt: "Still capture of " + serial, draggable: false })
1632
+ : live.streamUrl !== ""
1633
+ ? h("img", {
1634
+ ...live.imgProps,
1635
+ src: live.streamUrl,
1636
+ alt: "Live screen of " + serial,
1637
+ tabIndex: 0,
1638
+ style: sizeStyle,
1639
+ onError: () => grant(serial),
1640
+ })
1641
+ : h("div", { className: "mc-live-off" }, serial === ""
1642
+ ? (devices.length === 0
1643
+ ? "No device attached — boot an AVD from the ▾ picker, or press Run app."
1644
+ : "Device was shut down — pick one from ▾, or ⏻ boot an AVD.")
1645
+ : "Connecting to " + serial + "..."),
1646
+ ),
1647
+ view === "still"
1648
+ ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
1649
+ h("button", { className: "mc-log-toggle", type: "button", onClick: showLive }, "back to Live"))
1650
+ : h("div", { className: "mc-live-cap" }, "tap, drag or arrow-key the screen to drive the device · v" + MC_VERSION),
1651
+ h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
1652
+ );
1653
+ }
1654
+ /** Co-op second pane (0.9.0): a compact stream view for a second device —
1655
+ * device select, live image, tap/drag — and (0.11.2) its own Size/Frame
1656
+ * row, mirroring Device 1's controls. No toolbar/menu: those belong to
1657
+ * the full card. Defaults are identical, so the panes match out of the
1658
+ * box and can be adjusted independently. */
1659
+ function CoopPane({ excludeSerial, active }) {
1660
+ const [devices, setDevices] = useState([]);
1661
+ const [serial, setSerial] = useState("");
1662
+ const [sizeMode, setSizeMode] = useState({ mode: "fit" });
1663
+ const [frame, setFrame] = useState("none");
1664
+ const [error, setError] = useState("");
1665
+ const live = useLiveStream({ serial, onError: setError });
1666
+ const grant = (device) => live.grant(device).then((ok) => { if (ok) setError(""); });
1667
+ // Serial mirror for the 5s poller + one-shot auto-pick guard: this
1668
+ // pane may steal a partner while nothing is picked, but it must NOT
1669
+ // silently switch to a third device after its own goes offline.
1670
+ const serialRef = useRef(serial);
1671
+ serialRef.current = serial;
1672
+ const autoPicked = useRef(false);
1673
+ const lastPayload = useRef("");
1674
+
1675
+ const refresh = async () => {
1676
+ try {
1677
+ const r = await streamPost("/stream/devices", {});
1678
+ // Every tick handed React a fresh array, so the card re-rendered
1679
+ // every 5 s even when the device list had not changed. The payload
1680
+ // is a handful of small objects: identical bytes in means
1681
+ // identical output out, so skip the write entirely.
1682
+ const payload = JSON.stringify(r);
1683
+ if (payload !== lastPayload.current) {
1684
+ lastPayload.current = payload;
1685
+ setDevices(r.devices);
1686
+ }
1687
+ // Partner candidates: emulators only (serial prefix, not the
1688
+ // flaky avd field) — the co-op partner must be killable from
1689
+ // the picker, and never Device 1's own serial.
1690
+ const others = r.devices.filter((d) => d.serial !== excludeSerial && d.serial.startsWith("emulator-"));
1691
+ const cur = serialRef.current;
1692
+ if (cur !== "") {
1693
+ // Keep any valid pick — including a manually chosen physical
1694
+ // device; only emulators are eligible for auto-pick. A dead
1695
+ // or stolen pick clears to the placeholder (never re-grabs).
1696
+ if (cur !== excludeSerial && r.devices.some((d) => d.serial === cur)) return;
1697
+ autoPicked.current = true;
1698
+ setSerial(""); live.reset();
1699
+ return;
1700
+ }
1701
+ if (!autoPicked.current) {
1702
+ const next = (others.find((d) => d.streaming) ?? others[0])?.serial ?? "";
1703
+ if (next !== "") { autoPicked.current = true; setSerial(next); }
1704
+ }
1705
+ } catch {
1706
+ /* the header keeps the last device list */
1707
+ }
1708
+ };
1709
+
1710
+ useEffect(() => {
1711
+ if (!active) return;
1712
+ refresh();
1713
+ const timer = setInterval(refresh, 5000);
1714
+ return () => clearInterval(timer);
1715
+ }, [excludeSerial, active]);
1716
+
1717
+ useEffect(() => { if (serial !== "" && live.streamUrl === "") grant(serial); }, [serial, live.streamUrl]);
1718
+
1719
+ return h("div", { className: "mc-card mc-live mc-coop-pane" },
1720
+ h("div", { className: "mc-card-head" },
1721
+ h("span", null, "Device 2"),
1722
+ live.streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
1723
+ h("span", { className: "sp" }),
1724
+ h("select", { className: "mc-input mc-coop-select", "aria-label": "Device 2 serial", value: serial, onChange: (e) => { setSerial(e.target.value); live.reset(); } },
1725
+ // An honest empty option: a select with no matching option
1726
+ // silently DISPLAYS devices[0] while state is still "" —
1727
+ // that made the probe read a phantom pick (0.11.4).
1728
+ !devices.some((d) => d.serial === serial) && h("option", { value: "" }, devices.length === 0 ? "no device" : "— pick device —"),
1729
+ devices.map((d) => h("option", { key: d.serial, value: d.serial }, d.serial + (d.serial === excludeSerial ? " (Device 1)" : d.streaming ? " ●" : ""))),
1730
+ ),
1731
+ h(DeviceMenu, { serial, onError: setError }),
1732
+ ),
1733
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
1734
+ // 0.11.5: Device 2 gets the same nav toolbar as Device 1
1735
+ // (back/home/recents/rotate/refresh) — full control of both players.
1736
+ serial !== "" && h(ToolbarRow, { items: navToolbar(live.control, undefined, () => { if (serial !== "") grant(serial); }) }),
1737
+ h("div", { className: stageClassFor(frame, sizeMode.mode === "fit"), style: sizeMode.mode === "fit" && live.ar != null ? { "--mc-ar-n": live.ar } : undefined },
1738
+ live.streamUrl !== ""
1739
+ ? h("img", { ...live.imgProps, src: live.streamUrl, alt: "Live screen of " + serial, tabIndex: 0, style: stageSizeStyle(sizeMode), onError: () => grant(serial) })
1740
+ : h("div", { className: "mc-live-off" }, devices.filter((d) => d.serial !== excludeSerial).length === 0
1741
+ ? "No second device — boot another emulator."
1742
+ : serial === "" || !devices.some((d) => d.serial === serial)
1743
+ ? "Second device offline — pick one above."
1744
+ : "Connecting to " + serial + "..."),
1745
+ ),
1746
+ h("div", { className: "mc-live-cap" }, "tap, drag or arrow-key the screen to drive this device · v" + MC_VERSION),
1747
+ h(StageControls, { sizeMode, setSizeMode, frame, setFrame }),
1748
+ );
1749
+ }
1750
+
1751
+ /** Stream section: the classic single card, plus a co-op mode that docks
1752
+ * a second pane next to it for two-player testing. Each pane owns its
1753
+ * Size/Frame knobs (0.11.2); the shared CSS base makes them match. */
1754
+ // The boundary that matters most: everything below this line owns the two
1755
+ // live MJPEG stages, and `active` is its only prop from the panel. Without
1756
+ // memo, every panel-level write — a keystroke in the directory field, a log
1757
+ // toggle, opening settings — walked the whole device subtree again.
1758
+ const LiveStreamSection = memo(function LiveStreamSection({ active }) {
1759
+ const [coop, setCoop] = useState(false);
1760
+ const [serialA, setSerialA] = useState("");
1761
+ return h("div", { className: "mc-live-section" + (coop ? " coop" : "") },
1762
+ h(LiveDeviceCard, { coopOn: coop, onToggleCoop: () => setCoop((v) => !v), onSerial: setSerialA, active }),
1763
+ coop && h(CoopPane, { excludeSerial: serialA, active }),
1764
+ );
1765
+ });
1766
+
1767
+ //#region setup UI (welcome + settings)
1768
+
1769
+ /** PaddleOCR status card: fetch /ocr, Install button, log tail. */
1770
+ function OcrCard({ compact }) {
1771
+ const [status, setStatus] = useState(null);
1772
+ const [error, setError] = useState("");
1773
+ const [busy, setBusy] = useState(false);
1774
+
1775
+ const refresh = async () => {
1776
+ try { setStatus(await apiGet("/ocr")); } catch (err) { setError(err.message); }
1777
+ };
1778
+ useEffect(() => {
1779
+ refresh();
1780
+ const timer = setInterval(refresh, 3000); // follow a running install
1781
+ return () => clearInterval(timer);
1782
+ }, []);
1783
+
1784
+ const install = async () => {
1785
+ setBusy(true);
1786
+ setError("");
1787
+ try { await post("/ocr/install"); } catch (err) { setError(err.message); }
1788
+ setBusy(false);
1789
+ refresh();
1790
+ };
1791
+
1792
+ const state = status?.state ?? "idle";
1793
+ const ok = status?.working === true;
1794
+ const installing = state === "installing";
1795
+ const failed = state === "failed";
1796
+ const badge = ok ? "Ready" : failed ? "Failed" : installing ? "Installing…" : "Not installed";
1797
+
1798
+ return h("div", { className: "mc-card" },
1799
+ h("div", { className: "mc-card-head" },
1800
+ h("span", null, "PaddleOCR"),
1801
+ h("span", { className: "mc-dot " + (ok ? "on" : failed ? "err" : installing ? "warn" : "off") }),
1802
+ h("b", null, badge),
1803
+ h("span", { className: "sp" }),
1804
+ !ok && !installing && h("button", { className: "mc-btn primary", type: "button", disabled: busy, onClick: install }, "Install"),
1805
+ ),
1806
+ h("div", { className: "mc-status", role: "status" },
1807
+ ok
1808
+ ? h("span", null, "device_screen reads text from screenshots with local OCR.")
1809
+ : h("span", null, "Without it, device_screen returns the screenshot and UI tree but no OCR text — install to make the plugin work perfectly."),
1810
+ h("span", { className: "mc-hint" }, "PaddleOCR 3.x · fully local · one-time install (~2 min, needs network once)"),
1811
+ ),
1812
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
1813
+ (installing || failed) && (status?.log?.length > 0) && h("pre", { className: "mc-logline", tabIndex: 0, "aria-label": "Install log" }, status.log.join("\n")),
1814
+ !compact && h("div", { className: "mc-status" },
1815
+ h("span", null, ok ? "OCR is available to every AI via device_screen." : "Run the doctor after installing to confirm everything works."),
1816
+ ),
1817
+ );
1818
+ }
1819
+
1820
+ /** Doctor view: run /doctor checks, show ✓/✗ with detail, Fix buttons. */
1821
+ function DoctorView({ onFixed }) {
1822
+ const [checks, setChecks] = useState(null);
1823
+ const [error, setError] = useState("");
1824
+ const [busy, setBusy] = useState("");
1825
+ const [fixResult, setFixResult] = useState("");
1826
+
1827
+ const run = async () => {
1828
+ setChecks(null);
1829
+ setError("");
1830
+ setFixResult("");
1831
+ try { setChecks((await apiGet("/doctor")).checks); } catch (err) { setError(err.message); }
1832
+ };
1833
+ useEffect(() => { run(); }, []);
1834
+
1835
+ const fix = async (id) => {
1836
+ setBusy(id);
1837
+ setFixResult("");
1838
+ try {
1839
+ const result = await post("/doctor/fix", { id });
1840
+ setFixResult(result.message ?? "Fix requested.");
1841
+ await new Promise((r) => setTimeout(r, 1500));
1842
+ await run();
1843
+ onFixed?.();
1844
+ } catch (err) {
1845
+ setError(err.message);
1846
+ } finally {
1847
+ setBusy("");
1848
+ }
1849
+ };
1850
+
1851
+ return h("div", { className: "mc-body", style: { padding: "var(--mc-s6) 0 0" } },
1852
+ h("div", { className: "mc-row" },
1853
+ h("button", { className: "mc-btn primary", disabled: checks === null, onClick: run }, "Re-run checks"),
1854
+ checks !== null && h("span", { className: "mc-hint" },
1855
+ checks.filter((c) => c.ok).length + "/" + checks.length + " checks passing"),
1856
+ ),
1857
+ error !== "" && h("div", { className: "mc-error", role: "alert" }, error),
1858
+ fixResult !== "" && h("div", { className: "mc-status", role: "status" }, fixResult),
1859
+ checks === null && !error && h("div", { className: "mc-row", role: "status" }, h("span", { className: "mc-spin", "aria-hidden": "true" }), " Running checks…"),
1860
+ checks?.map((check) =>
1861
+ h("div", { key: check.name, className: "mc-check" },
1862
+ h("span", { className: "mc-check-ico", "aria-hidden": "true", style: { color: check.ok ? "var(--mc-ok)" : "var(--mc-err)" } }, check.ok ? "✓" : "✗"),
1863
+ h("span", { className: "mc-check-detail" },
1864
+ h("span", { className: "mc-check-name" }, check.name + (check.ok ? " — passed" : " — failed")),
1865
+ " — ", check.detail,
1866
+ check.fix && !check.ok && h("span", null,
1867
+ " ", h("button", { className: "mc-btn", disabled: busy !== "", onClick: () => fix(check.fix) },
1868
+ busy === check.fix ? "Fixing…" : "Fix")),
1869
+ ),
1870
+ ),
1871
+ ),
1872
+ );
1873
+ }
1874
+
1875
+ /** Settings dialog: tabs for Doctor / PaddleOCR / AI prompt. */
1876
+ /** Shared settings tabs (Doctor / PaddleOCR / AI Prompt / Connection) — used by both the ⚙ dialog and the DSH Settings page. */
1877
+ let tabsInstance = 0;
1878
+ const TAB_IDS = ["doctor", "ocr", "prompt", "connection"];
1879
+
1880
+ function SettingsTabs() {
1881
+ // Two SettingsTabs can be mounted at once (dialog + settings page), so
1882
+ // the tab/panel ids must be per-instance or aria-controls collides.
1883
+ const uidRef = useRef(null);
1884
+ if (uidRef.current === null) uidRef.current = "mc-tabs" + (++tabsInstance) + "-";
1885
+ const uid = uidRef.current;
1886
+ const [tab, setTab] = useState("doctor");
1887
+ const [prompt, setPrompt] = useState("");
1888
+ const [copied, setCopied] = useState(false);
1889
+ const [conn, setConn] = useState(null);
1890
+ const [connError, setConnError] = useState("");
1891
+ const [wifiHost, setWifiHost] = useState("");
1892
+ const [wifiPort, setWifiPort] = useState("5555");
1893
+ const [pairCode, setPairCode] = useState("");
1894
+ const [pairPort, setPairPort] = useState("37000");
1895
+ const [connBusy, setConnBusy] = useState("");
1896
+ const [connMsg, setConnMsg] = useState("");
1897
+ const [qrText, setQrText] = useState("");
1898
+ useEffect(() => {
1899
+ apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
1900
+ }, []);
1901
+ useEffect(() => {
1902
+ if (tab !== "connection") return;
1903
+ setConn(null); setConnError("");
1904
+ apiGet("/connection").then(setConn).catch((e) => setConnError(e instanceof Error ? e.message : String(e)));
1905
+ }, [tab]);
1906
+
1907
+ const refreshConn = () => {
1908
+ setConnError("");
1909
+ apiGet("/connection").then(setConn).catch((e) => setConnError(e instanceof Error ? e.message : String(e)));
1910
+ };
1911
+
1912
+ const doConnect = async () => {
1913
+ const host = wifiHost.trim();
1914
+ if (!host) { setConnMsg(""); setConnError("Enter the device IP first."); return; }
1915
+ setConnBusy("connecting"); setConnError(""); setConnMsg("");
1916
+ try {
1917
+ const r = await streamPost("/connect", {
1918
+ host,
1919
+ port: parseInt(wifiPort, 10) || 5555,
1920
+ pairing_code: pairCode.trim() || undefined,
1921
+ pairing_port: parseInt(pairPort, 10) || 37000,
1922
+ });
1923
+ setConnMsg(`Connected ${r.serial}${r.paired ? " (paired)" : ""}.`);
1924
+ setPairCode("");
1925
+ refreshConn();
1926
+ } catch (e) {
1927
+ setConnError(e instanceof Error ? e.message : String(e));
1928
+ } finally {
1929
+ setConnBusy("");
1930
+ }
1931
+ };
1932
+
1933
+ const doPairQr = async () => {
1934
+ setConnBusy("qr"); setConnError(""); setConnMsg("");
1935
+ try {
1936
+ const r = await streamPost("/pair-qr", { timeout_ms: 60000 });
1937
+ setQrText(r.qr_text ?? "");
1938
+ if (r.status === "connected") setConnMsg(`Paired & connected ${r.serial}.`);
1939
+ else if (r.status === "paired-not-connected") setConnMsg(`Paired with ${r.serial} but connect failed — try Connect with the same IP.`);
1940
+ else setConnMsg("QR generated. Scan it with the phone (Settings → Connected devices → Pair by QR) and keep this dialog open — pairing completes automatically.");
1941
+ refreshConn();
1942
+ } catch (e) {
1943
+ setConnError(e instanceof Error ? e.message : String(e));
1944
+ } finally {
1945
+ setConnBusy("");
1946
+ }
1947
+ };
1948
+
1949
+ const copy = async () => {
1950
+ const done = await copyText(prompt);
1951
+ setCopied(done);
1952
+ setTimeout(() => setCopied(false), 2000);
1953
+ };
1954
+
1955
+ const tabButton = (id, label) =>
1956
+ h("button", {
1957
+ className: "mc-tab", type: "button", role: "tab",
1958
+ id: uid + "tab-" + id,
1959
+ "aria-selected": tab === id,
1960
+ "aria-controls": uid + "panel-" + id,
1961
+ tabIndex: tab === id ? 0 : -1,
1962
+ "data-on": tab === id ? "true" : undefined,
1963
+ onClick: () => setTab(id),
1964
+ onKeyDown: (event) => {
1965
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
1966
+ if (step === 0) return;
1967
+ event.preventDefault();
1968
+ const next = TAB_IDS[(TAB_IDS.indexOf(id) + step + TAB_IDS.length) % TAB_IDS.length];
1969
+ setTab(next);
1970
+ document.getElementById(uid + "tab-" + next)?.focus();
1971
+ },
1972
+ }, label);
1973
+ const panel = (id, children) =>
1974
+ h("div", { className: "mc-modal-body", role: "tabpanel", id: uid + "panel-" + id, "aria-labelledby": uid + "tab-" + id, tabIndex: 0 }, children);
1975
+
1976
+ return [
1977
+ h("div", { className: "mc-tabs", role: "tablist", "aria-label": "Settings sections" },
1978
+ tabButton("doctor", "Doctor"),
1979
+ tabButton("ocr", "PaddleOCR"),
1980
+ tabButton("prompt", "AI Prompt"),
1981
+ tabButton("connection", "Connection"),
1982
+ ),
1983
+ tab === "doctor" && panel("doctor", h(DoctorView, { onFixed: () => {} })),
1984
+ tab === "ocr" && panel("ocr", h(OcrCard, {})),
1985
+ tab === "prompt" && panel("prompt",
1986
+ h("div", null,
1987
+ h("h3", null, "Prompt for any AI"),
1988
+ h("div", { className: "mc-hint" }, "Copy this into any AI model to tell it how to fully control this plugin."),
1989
+ ),
1990
+ h("div", { className: "mc-prompt" },
1991
+ h("pre", null, prompt || "Loading…"),
1992
+ h("button", { className: "mc-btn mc-copy", type: "button", onClick: copy }, copied ? "Copied ✓" : "Copy"),
1993
+ ),
1994
+ ),
1995
+ tab === "connection" && h("div", { className: "mc-modal-body" },
1996
+ h("div", null,
1997
+ h("h3", null, "adb connection"),
1998
+ connError !== "" && h("div", { className: "mc-warn", role: "alert" }, connError),
1999
+ connMsg !== "" && h("div", { className: "mc-hint", role: "status", style: { color: "var(--mc-ok)" } }, connMsg), h("div", { className: "mc-conn-form" }, h("div", { className: "mc-conn-row" }, h("input", { className: "mc-input", "aria-label": "Device IP address", placeholder: "device IP, e.g. 192.168.1.23", value: wifiHost, onChange: (e) => setWifiHost(e.target.value) }), h("input", { className: "mc-input mc-input-sm", "aria-label": "adb port", placeholder: "port", value: wifiPort, onChange: (e) => setWifiPort(e.target.value) }), h("button", { className: "mc-btn", type: "button", onClick: doConnect, disabled: connBusy !== "" }, connBusy === "connecting" ? "Connecting…" : "Connect"), ), h("div", { className: "mc-conn-row" }, h("input", { className: "mc-input", "aria-label": "Pairing code", placeholder: "pairing code (first-time only)", value: pairCode, onChange: (e) => setPairCode(e.target.value) }), h("input", { className: "mc-input mc-input-sm", "aria-label": "Pairing port", placeholder: "pair port", value: pairPort, onChange: (e) => setPairPort(e.target.value) }), h("button", { className: "mc-btn", type: "button", onClick: doPairQr, disabled: connBusy !== "" }, connBusy === "qr" ? "Waiting for scan…" : "Pair by QR"), ), ), qrText !== "" && h("div", { className: "mc-qr" }, h("div", { className: "mc-hint" }, "WIFI:T:ADB — render as a QR code and scan with the phone (Settings → Connected devices → Pair by QR):"), h("pre", { className: "mc-qr-text" }, qrText), ),
2000
+ conn && h("div", null,
2001
+ h("div", { className: "mc-hint" }, `adb: ${conn.adb}`),
2002
+ h("div", { className: "mc-device-list" },
2003
+ conn.devices.length === 0 && h("div", { className: "mc-hint" }, "No devices attached. Plug a phone (USB debug) or boot an emulator."),
2004
+ conn.devices.map((d) => h("div", { className: "mc-device-row", key: d.serial },
2005
+ h("span", { className: "mc-dot", "data-on": d.state === "device" ? "true" : undefined }),
2006
+ h("span", { className: "mc-serial" }, d.serial),
2007
+ d.model && h("span", { className: "mc-hint" }, d.model),
2008
+ h("span", { className: "mc-badge" }, d.wifi ? "Wi-Fi" : "USB"),
2009
+ h("span", { className: "mc-hint" }, d.state),
2010
+ )),
2011
+ ),
2012
+ h("div", { className: "mc-hint" }, "Wi-Fi serials look like 192.168.1.23:5555 — attach with `adb connect <ip>:5555`. A dropped Wi-Fi link is reconnected once automatically; read-only probes then replay, taps never do."),
2013
+ ),
2014
+ ),
2015
+ ),
2016
+ ];
2017
+ }
2018
+
2019
+ /** Settings dialog (⚙ in the Devices pane header). */
2020
+ function SettingsDialog({ onClose }) {
2021
+ const ref = useDialogA11y(onClose);
2022
+ return h("div", { className: "mc-overlay", onClick: (e) => { if (e.target === e.currentTarget) onClose(); } },
2023
+ h("div", { className: "mc-modal", ref, role: "dialog", "aria-modal": "true", "aria-labelledby": "mc-settings-title", tabIndex: -1 },
2024
+ h("div", { className: "mc-modal-head" },
2025
+ h("h2", { className: "mc-modal-title", id: "mc-settings-title" }, "dsh-mobilecode · Settings"),
2026
+ h("button", { className: "mc-close", "aria-label": "Close settings", onClick: onClose }, "✕"),
2027
+ ),
2028
+ h(SettingsTabs, {}),
2029
+ h("div", { className: "mc-modal-foot" },
2030
+ h("span", { className: "mc-hint" }, "Settings live in ~/.dsh/mobilecode/"),
2031
+ h("span", { className: "sp" }),
2032
+ h("button", { className: "mc-btn primary", onClick: onClose }, "Done"),
2033
+ ),
2034
+ ),
2035
+ );
2036
+ }
2037
+
2038
+ /** The DSH Settings page contribution (registered into settings.section). */
2039
+ function SettingsSection({ close }) {
2040
+ return h("div", { className: "mc-section" },
2041
+ h("div", { className: "mc-section-head" },
2042
+ h("h2", { className: "mc-modal-title" }, "dsh-mobilecode"),
2043
+ h("span", { className: "mc-hint" }, "Detect iOS/Android projects, run them on the Simulator/Emulator, and let AI agents control the device."),
2044
+ close !== undefined && h("button", { className: "mc-btn", onClick: close }, "Close"),
2045
+ ),
2046
+ h(SettingsTabs, {}),
2047
+ );
2048
+ }
2049
+
2050
+ /** First-run welcome modal: how-to + copyable prompt + PaddleOCR install. */
2051
+ function WelcomeModal({ onClose }) {
2052
+ const [prompt, setPrompt] = useState("");
2053
+ const [copied, setCopied] = useState(false);
2054
+ useEffect(() => {
2055
+ apiGet("/welcome").then((w) => setPrompt(w.prompt ?? "")).catch(() => {});
2056
+ }, []);
2057
+
2058
+ const copy = async () => {
2059
+ const done = await copyText(prompt);
2060
+ setCopied(done);
2061
+ setTimeout(() => setCopied(false), 2000);
2062
+ };
2063
+
2064
+ const dismiss = async () => {
2065
+ try { await post("/welcome/dismiss"); } catch { /* keep modal in-memory */ }
2066
+ onClose();
2067
+ };
2068
+ const ref = useDialogA11y(dismiss);
2069
+
2070
+ return h("div", { className: "mc-overlay" },
2071
+ h("div", { className: "mc-modal", ref, role: "dialog", "aria-modal": "true", "aria-labelledby": "mc-welcome-title", tabIndex: -1 },
2072
+ h("div", { className: "mc-modal-head" },
2073
+ h("h2", { className: "mc-modal-title", id: "mc-welcome-title" }, "Welcome to dsh-mobilecode 🚀"),
2074
+ h("button", { className: "mc-close", "aria-label": "Dismiss welcome", onClick: dismiss }, "✕"),
2075
+ ),
2076
+ h("div", { className: "mc-modal-body" },
2077
+ h("div", null,
2078
+ h("h3", null, "How to use it"),
2079
+ h("div", { className: "mc-status" },
2080
+ h("span", null, "1. Open the Devices pane (sidebar) and pick a project directory, or let an AI do it for you."),
2081
+ h("span", null, "2. Press Run app (or ask the AI) — it builds, installs and launches on the Simulator/Emulator."),
2082
+ h("span", null, "3. AI agents get device_detect / device_run / device_screen / device_input / device_log / device_status — full build, see, tap, log control."),
2083
+ h("span", null, "4. The ⚙ Settings button (Devices pane) has the doctor, PaddleOCR and this prompt."),
2084
+ ),
2085
+ ),
2086
+ h("div", { className: "mc-prompt" },
2087
+ h("pre", null, prompt || "Loading…"),
2088
+ h("button", { className: "mc-btn mc-copy", onClick: copy }, copied ? "Copied ✓" : "Copy prompt"),
2089
+ ),
2090
+ h(OcrCard, { compact: true }),
2091
+ h("div", { className: "mc-hint" },
2092
+ "Tip: install PaddleOCR above so device_screen can read text from the screen — the plugin still works without it, just without OCR."),
2093
+ ),
2094
+ h("div", { className: "mc-modal-foot" },
2095
+ h("span", { className: "mc-hint" }, "The ⚙ in the Devices pane opens Settings (Doctor / PaddleOCR / AI Prompt) anytime."),
2096
+ h("span", { className: "sp" }),
2097
+ h("button", { className: "mc-btn primary", onClick: dismiss }, "Got it — let's go"),
2098
+ ),
2099
+ ),
2100
+ );
2101
+ }
2102
+ //#endregion
2103
+
2104
+ //#region DOM mounts
2105
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><rect x="2" y="1.5" width="12" height="13" rx="2"/><path d="M4.5 4h7M4.5 6.5h7M4.5 9h4" stroke-linecap="round"/><circle cx="8" cy="11.8" r="0.8" fill="currentColor" stroke="none"/></svg>';
2106
+
2107
+ function sidebarRoot() {
2108
+ const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
2109
+ if (column === null) return undefined;
2110
+ const logoOwner = column.querySelector('[class*="logoRow"]')?.parentElement;
2111
+ return logoOwner ?? (column.firstElementChild ?? undefined);
2112
+ }
2113
+
2114
+ function newSessionButton(root) {
2115
+ const nested = root.querySelector('button[class*="newSession"]');
2116
+ if (nested !== null) return nested;
2117
+ for (const child of root.children) {
2118
+ if (child.tagName === "BUTTON") return child;
2119
+ }
2120
+ return undefined;
2121
+ }
2122
+
2123
+ function createEntry(controller) {
2124
+ const entry = document.createElement("button");
2125
+ entry.type = "button";
2126
+ entry.dataset.dshMobilecodeEntry = "";
2127
+ entry.setAttribute("aria-label", "Devices (mobilecode)");
2128
+ entry.setAttribute("title", "Devices — run the mobile app in the Simulator/Emulator");
2129
+ entry.innerHTML = '<span class="mc-entry-icon">' + ICON + '</span><span class="mc-entry-label">Devices</span>';
2130
+ entry.addEventListener("click", () => { controller.toggle(); });
2131
+ return entry;
2132
+ }
2133
+
2134
+ function placeEntry(root, entry) {
2135
+ const button = newSessionButton(root);
2136
+ if (button === undefined) return false;
2137
+ if (entry.parentElement !== root) {
2138
+ const row = button.closest('[class*="logoRow"]');
2139
+ const base = (row !== null && row.parentElement === root) ? row : button;
2140
+ const family = Array.from(root.children).filter(
2141
+ (el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-logcat-entry], [data-dsh-mobilecode-entry]"),
2142
+ );
2143
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
2144
+ root.insertBefore(entry, anchor);
2145
+ }
2146
+ return true;
2147
+ }
2148
+
2149
+ function mountSidebarEntry(controller) {
2150
+ const entry = createEntry(controller);
2151
+ let root;
2152
+ let placed = false;
2153
+ let rootObserver;
2154
+
2155
+ // Every observer below funnels into ONE rAF-coalesced pass. This is the
2156
+ // page's hottest idle path: the wait observer watches all of document.body
2157
+ // with subtree:true, the DSH shell mutates the DOM continuously (streaming
2158
+ // text, typing, the sidebar re-rendering on every session change), and the
2159
+ // old per-mutation callback ran a full querySelector walk synchronously on
2160
+ // every one of those mutations.
2161
+ let scheduled = false;
2162
+ const schedule = () => {
2163
+ if (scheduled) return;
2164
+ scheduled = true;
2165
+ requestAnimationFrame(() => { scheduled = false; tryPlace(); });
2166
+ };
2167
+
2168
+ const tryPlace = () => {
2169
+ if (root !== undefined && !root.isConnected) {
2170
+ rootObserver?.disconnect();
2171
+ root = undefined;
2172
+ placed = false;
2173
+ }
2174
+ if (placed) {
2175
+ if (document.body.contains(entry)) return;
2176
+ rootObserver?.disconnect();
2177
+ root = undefined;
2178
+ placed = false;
2179
+ }
2180
+ root ??= sidebarRoot();
2181
+ if (root === undefined) return;
2182
+ placed = placeEntry(root, entry);
2183
+ if (placed) {
2184
+ rootObserver = new MutationObserver(schedule);
2185
+ rootObserver.observe(root, { childList: true, subtree: true });
2186
+ }
2187
+ };
2188
+
2189
+ // Watching the whole body is still required — the host can drop the sidebar
2190
+ // at any moment — but the guard is one pointer walk, so the overwhelmingly
2191
+ // common case (entry still mounted, mutations happening everywhere else)
2192
+ // costs nothing and never reaches the scheduler.
2193
+ const waitObserver = new MutationObserver(() => { if (!document.body.contains(entry)) schedule(); });
2194
+ waitObserver.observe(document.body, { childList: true, subtree: true });
2195
+
2196
+ const syncActive = () => {
2197
+ if (controller.getSnapshot().panelOpen) entry.dataset.active = "true";
2198
+ else delete entry.dataset.active;
2199
+ };
2200
+ const unsubscribe = controller.subscribe(syncActive);
2201
+ syncActive();
2202
+ tryPlace();
2203
+
2204
+ return () => {
2205
+ waitObserver.disconnect();
2206
+ rootObserver?.disconnect();
2207
+ unsubscribe();
2208
+ entry.remove();
2209
+ };
2210
+ }
2211
+
2212
+ function mountPanel(controller) {
2213
+ let root;
2214
+ let container;
2215
+ let handle;
2216
+ let drag = null;
2217
+
2218
+ const MIN_WIDTH = 320;
2219
+ const DEFAULT_WIDTH = 560;
2220
+ const readWidth = () => {
2221
+ try {
2222
+ const w = Number(localStorage.getItem("dsh-mobilecode-width"));
2223
+ if (Number.isFinite(w) && w >= MIN_WIDTH && w <= window.innerWidth - 40) return w;
2224
+ } catch { /* private mode */ }
2225
+ return Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80));
2226
+ };
2227
+
2228
+ const onMove = (e) => {
2229
+ if (drag === null || container === undefined) return;
2230
+ const width = Math.max(MIN_WIDTH, Math.min(window.innerWidth - 40, drag.startWidth + (drag.startX - e.clientX)));
2231
+ container.style.width = width + "px";
2232
+ };
2233
+ const onUp = () => {
2234
+ if (drag === null) return;
2235
+ handle?.removeAttribute("data-drag");
2236
+ try {
2237
+ const w = container.style.width.replace("px", "");
2238
+ localStorage.setItem("dsh-mobilecode-width", w);
2239
+ } catch { /* private mode */ }
2240
+ window.removeEventListener("mousemove", onMove);
2241
+ window.removeEventListener("mouseup", onUp);
2242
+ drag = null;
2243
+ };
2244
+ const onDown = (e) => {
2245
+ e.preventDefault();
2246
+ if (drag !== null) return;
2247
+ const now = Date.now();
2248
+ if (now - (handle?._lastClick ?? 0) < 350) {
2249
+ handle._lastClick = 0;
2250
+ resetWidth();
2251
+ return;
2252
+ }
2253
+ handle._lastClick = now;
2254
+ drag = { startX: e.clientX, startWidth: container.getBoundingClientRect().width };
2255
+ handle?.setAttribute("data-drag", "");
2256
+ window.addEventListener("mousemove", onMove);
2257
+ window.addEventListener("mouseup", onUp);
2258
+ };
2259
+
2260
+ // The panel is anchored to the right edge, so widening means dragging the
2261
+ // handle left. Keyboard resizing mirrors that: ArrowLeft widens.
2262
+ const resetWidth = () => applyWidth(Math.min(DEFAULT_WIDTH, Math.max(MIN_WIDTH, window.innerWidth - 80)));
2263
+ const applyWidth = (w) => {
2264
+ const width = Math.round(Math.max(MIN_WIDTH, Math.min(window.innerWidth - 40, w)));
2265
+ container.style.width = width + "px";
2266
+ handle?.setAttribute("aria-valuenow", String(width));
2267
+ try { localStorage.setItem("dsh-mobilecode-width", String(width)); } catch { /* private mode */ }
2268
+ };
2269
+ const onKey = (e) => {
2270
+ const width = parseFloat(container.style.width) || readWidth();
2271
+ const step = e.shiftKey ? 64 : 16;
2272
+ if (e.key === "ArrowLeft") applyWidth(width + step);
2273
+ else if (e.key === "ArrowRight") applyWidth(width - step);
2274
+ else if (e.key === "Home" || e.key === "Enter" || e.key === " ") resetWidth();
2275
+ else return;
2276
+ e.preventDefault();
2277
+ };
2278
+ // readWidth() only runs in ensure(), so before this a window narrowed
2279
+ // while the pane was open left it at its old inline width, hanging off
2280
+ // the left edge of the viewport.
2281
+ const onResize = () => {
2282
+ if (container === undefined) return;
2283
+ const before = Math.round(parseFloat(container.style.width) || readWidth());
2284
+ const after = Math.round(Math.max(MIN_WIDTH, Math.min(window.innerWidth - 40, before)));
2285
+ if (after !== before) applyWidth(after);
2286
+ else handle?.setAttribute("aria-valuemax", String(Math.max(MIN_WIDTH, window.innerWidth - 40)));
2287
+ };
2288
+
2289
+ const ensure = () => {
2290
+ if (container !== undefined && container.isConnected) return;
2291
+ root?.unmount();
2292
+ root = undefined;
2293
+ container?.remove();
2294
+ handle = undefined;
2295
+ container = document.createElement("div");
2296
+ container.dataset.dshMobilecodeView = "";
2297
+ container.className = "dsh-mobilecode-view";
2298
+ // A landmark, so the drawer is reachable by landmark navigation and
2299
+ // not only by tabbing through it. `complementary` is the honest role:
2300
+ // this is a sidebar supporting the host's main content. The name is
2301
+ // the same string the h2 inside carries, and test/client.mjs asserts
2302
+ // they stay equal — a labelled landmark that names itself something
2303
+ // other than its own heading is worse than an unnamed one. While the
2304
+ // drawer is hidden, `display: none` keeps it out of the a11y tree.
2305
+ container.setAttribute("role", "complementary");
2306
+ container.setAttribute("aria-label", "Devices");
2307
+ container.style.cssText += "position:fixed;top:0;right:0;bottom:0;left:auto;z-index:var(--mc-z-panel);";
2308
+ container.style.width = readWidth() + "px";
2309
+ container.hidden = true;
2310
+ handle = document.createElement("div");
2311
+ handle.className = "dsh-mobilecode-resize";
2312
+ handle.title = "Drag to resize (double-click to reset)";
2313
+ handle.setAttribute("role", "separator");
2314
+ handle.setAttribute("aria-orientation", "vertical");
2315
+ handle.setAttribute("aria-label", "Resize the Devices pane — arrow keys to resize, Home to reset");
2316
+ handle.setAttribute("aria-valuemin", String(MIN_WIDTH));
2317
+ handle.setAttribute("aria-valuemax", String(Math.max(MIN_WIDTH, window.innerWidth - 40)));
2318
+ handle.setAttribute("aria-valuenow", String(parseFloat(container.style.width) || readWidth()));
2319
+ handle.tabIndex = 0;
2320
+ handle.addEventListener("mousedown", onDown);
2321
+ handle.addEventListener("keydown", onKey);
2322
+ container.appendChild(handle);
2323
+ const panel = document.createElement("div");
2324
+ panel.className = "dsh-mobilecode-panel";
2325
+ panel.style.cssText += "position:absolute;inset:0;display:flex;flex-direction:column;";
2326
+ container.appendChild(panel);
2327
+ document.body.appendChild(container);
2328
+ root = createRoot(panel);
2329
+ root.render(h(MobileCodePanel, { controller }));
2330
+ };
2331
+
2332
+ ensure();
2333
+ window.addEventListener("resize", onResize);
2334
+
2335
+ const applyOpen = () => {
2336
+ if (container !== undefined) container.hidden = !controller.getSnapshot().panelOpen;
2337
+ };
2338
+ const unsubscribe = controller.subscribe(applyOpen);
2339
+ applyOpen();
2340
+
2341
+ return () => {
2342
+ window.removeEventListener("mousemove", onMove);
2343
+ window.removeEventListener("mouseup", onUp);
2344
+ window.removeEventListener("resize", onResize);
2345
+ unsubscribe();
2346
+ root?.unmount();
2347
+ root = undefined;
2348
+ container?.remove();
2349
+ container = undefined;
2350
+ handle = undefined;
2351
+ };
2352
+ }
2353
+ //#endregion
2354
+
2355
+ //#region entry
2356
+ /** Required services (fiber inject waiting — the runtime must be up first). */
2357
+ const inject = ["slots"];
2358
+
2359
+ /**
2360
+ * First-run welcome: ask the host whether to show it, and if so mount the
2361
+ * modal over everything. The host remembers dismissal in settings.json.
2362
+ */
2363
+ function mountWelcome() {
2364
+ let root;
2365
+ let container;
2366
+ const render = (show) => {
2367
+ if (!show) return;
2368
+ container = document.createElement("div");
2369
+ container.dataset.dshMobilecodeWelcome = "";
2370
+ document.body.appendChild(container);
2371
+ root = createRoot(container);
2372
+ root.render(h(WelcomeModal, { onClose: () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; } }));
2373
+ };
2374
+ apiGet("/welcome").then((w) => render(w.show === true)).catch(() => { /* host older than this feature — no welcome */ });
2375
+ return () => { root?.unmount(); container?.remove(); root = undefined; container = undefined; };
2376
+ }
2377
+
2378
+ /**
2379
+ * Mount the device pane.
2380
+ * @param ctx - client root context.
2381
+ */
2382
+ function apply(ctx) {
2383
+ const style = document.createElement("style");
2384
+ style.textContent = STYLE;
2385
+ style.dataset.dshMobilecodeStyle = "";
2386
+ document.head.appendChild(style);
2387
+ // `isConnected` instead of a head.contains() tree walk — same answer, one flag.
2388
+ const styleGuard = new MutationObserver(() => { if (!style.isConnected) document.head.appendChild(style); });
2389
+ styleGuard.observe(document.head, { childList: true });
2390
+
2391
+ const controller = new PanelController();
2392
+ activeController = controller;
2393
+ const disposers = [];
2394
+ try {
2395
+ disposers.push(mountSidebarEntry(controller));
2396
+ disposers.push(mountPanel(controller));
2397
+ disposers.push(mountWelcome());
2398
+ // DSH Settings page: register our own section (doctor / PaddleOCR / AI prompt),
2399
+ // like the other installed plugins' settings pages.
2400
+ if (typeof ctx.slots?.inject === "function") {
2401
+ const disposeSlot = ctx.slots.inject("settings.section", () =>
2402
+ ctx.slots.register({
2403
+ name: "settings.section",
2404
+ id: "mobilecode",
2405
+ order: 200,
2406
+ label: "MobileCode",
2407
+ }, SettingsSection),
2408
+ );
2409
+ disposers.push(disposeSlot);
2410
+ // 0.7.0: compact conversation card under a settled device_stream /
2411
+ // device_boot result (opens the panel on "⤢ open in panel", and
2412
+ // auto-opens it once when a stream first settles). One slot per
2413
+ // tool name keyed like the reference port (openpencil shape):
2414
+ // `key: toolName` scopes the card to that tool's calls, so other
2415
+ // tools keep their default card.
2416
+ for (const toolName of ["device_stream", "device_boot"]) {
2417
+ const disposeCard = ctx.slots.inject("tool.call.toolview", () =>
2418
+ ctx.slots.register({
2419
+ name: "tool.call.toolview",
2420
+ key: toolName,
2421
+ }, MobileCodeToolCard),
2422
+ );
2423
+ disposers.push(disposeCard);
2424
+ }
2425
+ // 0.7.0: green "● <serial>" composer capsule while a device
2426
+ // streams; clicking it opens the panel. Registered unconditionally
2427
+ // so the capsule can appear even before any card auto-opens.
2428
+ const disposeCapsule = ctx.slots.inject("conversation.input.dock", () =>
2429
+ ctx.slots.register({
2430
+ name: "conversation.input.dock",
2431
+ id: "dsh-mobilecode-status",
2432
+ order: 40,
2433
+ }, MobileCodeStatusCapsule),
2434
+ );
2435
+ disposers.push(disposeCapsule);
2436
+ }
2437
+ } catch (error) {
2438
+ console.warn("[dsh-mobilecode] mount failed:", error);
2439
+ }
2440
+ ctx.effect(() => () => {
2441
+ styleGuard.disconnect();
2442
+ for (const dispose of disposers.splice(0)) dispose();
2443
+ activeController = null;
2444
+ autoOpenedOnce = false;
2445
+ style.remove();
2446
+ }, "dsh-mobilecode: ui mounts");
2447
+ }
2448
+ //#endregion
2449
+
2450
+ exports.apply = apply;
2451
+ exports.inject = inject;
2452
+ return module.exports;
2453
+ }
2454
+ });
2455
+