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/README.md +56 -0
- package/lib/client.js +2455 -1610
- package/lib/config-matrix.js +202 -0
- package/lib/index.js +656 -3
- package/lib/openpencil.js +139 -0
- package/lib/png-decode.js +86 -0
- package/lib/preview-gallery.js +309 -0
- package/lib/reference-workspace.js +283 -0
- package/lib/visual-compare.js +196 -0
- package/package.json +2 -2
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
/*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.mc-
|
|
167
|
-
.mc-
|
|
168
|
-
.
|
|
169
|
-
.
|
|
170
|
-
.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
.
|
|
178
|
-
.
|
|
179
|
-
.
|
|
180
|
-
.mc-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.mc-
|
|
188
|
-
.mc-
|
|
189
|
-
.mc-
|
|
190
|
-
.mc-
|
|
191
|
-
|
|
192
|
-
.mc-
|
|
193
|
-
.mc-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}, [
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
h("
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
)
|
|
882
|
-
|
|
883
|
-
h(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const [
|
|
918
|
-
const [
|
|
919
|
-
const [
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
h("span", {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
)
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
h(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
)
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
);
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
};
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
+
|