hop2 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.hop-tmux.conf +18 -0
  2. package/INTEGRATION.md +92 -0
  3. package/README.md +260 -0
  4. package/hay/CHANGELOG.md +46 -0
  5. package/hay/DEV_NOTES.md +39 -0
  6. package/hay/README.md +290 -0
  7. package/hay/apps/cli/package.json +25 -0
  8. package/hay/apps/cli/src/index.ts +1250 -0
  9. package/hay/apps/cli/tsconfig.json +8 -0
  10. package/hay/apps/server/package.json +37 -0
  11. package/hay/apps/server/src/index.ts +103 -0
  12. package/hay/apps/server/src/lib.ts +161 -0
  13. package/hay/apps/server/src/pty.ts +68 -0
  14. package/hay/apps/server/src/rooms.ts +421 -0
  15. package/hay/apps/server/src/utils.ts +20 -0
  16. package/hay/apps/server/test/rooms.test.ts +146 -0
  17. package/hay/apps/server/tsconfig.json +9 -0
  18. package/hay/apps/web/index.html +12 -0
  19. package/hay/apps/web/package.json +32 -0
  20. package/hay/apps/web/scripts/run-e2e.mjs +130 -0
  21. package/hay/apps/web/src/App.tsx +1302 -0
  22. package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
  23. package/hay/apps/web/src/main.tsx +13 -0
  24. package/hay/apps/web/src/setupTests.ts +1 -0
  25. package/hay/apps/web/src/styles.css +1198 -0
  26. package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
  27. package/hay/apps/web/src/utils/presence.ts +24 -0
  28. package/hay/apps/web/src/vite-env.d.ts +10 -0
  29. package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
  30. package/hay/apps/web/test/presence.test.ts +18 -0
  31. package/hay/apps/web/tsconfig.json +8 -0
  32. package/hay/apps/web/vite.config.ts +13 -0
  33. package/hay/apps/web/vitest.config.ts +12 -0
  34. package/hay/package-lock.json +5988 -0
  35. package/hay/package.json +18 -0
  36. package/hay/packages/shared/package.json +27 -0
  37. package/hay/packages/shared/src/colors.ts +16 -0
  38. package/hay/packages/shared/src/index.ts +2 -0
  39. package/hay/packages/shared/src/protocol.ts +90 -0
  40. package/hay/packages/shared/test/protocol.test.ts +33 -0
  41. package/hay/packages/shared/tsconfig.json +7 -0
  42. package/hay/tsconfig.base.json +15 -0
  43. package/hay-web/assets/index-CTTzApRp.css +32 -0
  44. package/hay-web/assets/index-FDfdolCs.js +60 -0
  45. package/hay-web/assets/index-FDfdolCs.js.map +1 -0
  46. package/hay-web/index.html +13 -0
  47. package/hop +3717 -0
  48. package/package.json +52 -0
  49. package/sessions.html +243 -0
  50. package/tunnel-shell.js +254 -0
@@ -0,0 +1,701 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { flushSync } from "react-dom";
3
+
4
+ // iOS detection for haptic feedback
5
+ const isIOS = () => {
6
+ if (typeof navigator === "undefined") return false;
7
+ const ua = navigator.userAgent || "";
8
+ return /iPad|iPhone|iPod/.test(ua);
9
+ };
10
+
11
+ interface MobileKeyboardProps {
12
+ onInput: (data: string) => void;
13
+ visible: boolean;
14
+ onToggle: () => void;
15
+ hapticsEnabled?: boolean;
16
+ }
17
+
18
+ type KeyboardView = "abc" | "num";
19
+
20
+ interface KeyDef {
21
+ label: string;
22
+ key?: string;
23
+ mod?: "ctrl" | "alt";
24
+ fn?: "sysKb";
25
+ action?: "shift" | "bksp" | "paste" | "ret" | "space" | "123" | "abc";
26
+ className?: string;
27
+ flexGrow?: number;
28
+ }
29
+
30
+ const LAYOUTS: Record<KeyboardView, string[][]> = {
31
+ abc: [
32
+ ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
33
+ ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
34
+ ["SHIFT", "z", "x", "c", "v", "b", "n", "m", "BKSP"],
35
+ ["123", "PASTE", "SPACE", "RET"]
36
+ ],
37
+ num: [
38
+ ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
39
+ ["-", "/", ":", ";", "(", ")", "$", "&", "@", '"'],
40
+ ["SHIFT", ".", ",", "?", "!", "'", "BKSP"],
41
+ ["ABC", "PASTE", "SPACE", "RET"]
42
+ ]
43
+ };
44
+
45
+ const ACC_KEYS: KeyDef[] = [
46
+ { label: "Esc", key: "\x1b" },
47
+ { label: "Tab", key: "\t" },
48
+ { label: "Ctrl", mod: "ctrl" },
49
+ { label: "Alt", mod: "alt" },
50
+ { label: "←", key: "\x1b[D" },
51
+ { label: "↓", key: "\x1b[B" },
52
+ { label: "↑", key: "\x1b[A" },
53
+ { label: "→", key: "\x1b[C" },
54
+ { label: "KB", fn: "sysKb" }
55
+ ];
56
+
57
+ const ShiftIcon = ({ filled }: { filled: boolean }) => (
58
+ <svg
59
+ viewBox="0 0 24 24"
60
+ stroke="currentColor"
61
+ strokeWidth="1.5"
62
+ fill={filled ? "currentColor" : "none"}
63
+ >
64
+ <path d="M12 4L4 16h16L12 4z" />
65
+ </svg>
66
+ );
67
+
68
+ const BackspaceIcon = () => (
69
+ <svg viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5" fill="none">
70
+ <path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" />
71
+ <line x1="18" y1="9" x2="12" y2="15" />
72
+ <line x1="12" y1="9" x2="18" y2="15" />
73
+ </svg>
74
+ );
75
+
76
+ const KeyboardIcon = () => (
77
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
78
+ <rect x="2" y="6" width="20" height="12" rx="2" />
79
+ <line x1="6" y1="10" x2="6.01" y2="10" />
80
+ <line x1="10" y1="10" x2="10.01" y2="10" />
81
+ <line x1="14" y1="10" x2="14.01" y2="10" />
82
+ <line x1="18" y1="10" x2="18.01" y2="10" />
83
+ <line x1="8" y1="14" x2="16" y2="14" />
84
+ </svg>
85
+ );
86
+
87
+ const PasteIcon = () => (
88
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
89
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
90
+ <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
91
+ </svg>
92
+ );
93
+
94
+ export const MobileKeyboard = ({ onInput, visible, onToggle, hapticsEnabled = true }: MobileKeyboardProps) => {
95
+ const [view, setView] = useState<KeyboardView>("abc");
96
+ const [shift, setShift] = useState(false);
97
+ const [caps, setCaps] = useState(false);
98
+ const [ctrl, setCtrl] = useState(false);
99
+ const [alt, setAlt] = useState(false);
100
+ const [sysKbOpen, setSysKbOpen] = useState(false);
101
+ const [sysKbHintShown, setSysKbHintShown] = useState(false);
102
+ const [showHint, setShowHint] = useState(false);
103
+ const [sysKbDraft, setSysKbDraft] = useState("");
104
+
105
+ const shiftTapAt = useRef(0);
106
+ const bkspTimeout = useRef<number | null>(null);
107
+ const bkspInterval = useRef<number | null>(null);
108
+ const sysInputRef = useRef<HTMLTextAreaElement>(null);
109
+ const hapticLabelRef = useRef<HTMLLabelElement | null>(null);
110
+
111
+ // Use refs to avoid stale closures in rapid key presses
112
+ const onInputRef = useRef(onInput);
113
+ const shiftRef = useRef(shift);
114
+ const capsRef = useRef(caps);
115
+ const ctrlRef = useRef(ctrl);
116
+
117
+ // Keep refs in sync with state
118
+ onInputRef.current = onInput;
119
+ shiftRef.current = shift;
120
+ capsRef.current = caps;
121
+ ctrlRef.current = ctrl;
122
+
123
+ // Create hidden switch elements for iOS haptic feedback
124
+ // iOS doesn't support navigator.vibrate, so we use a hidden checkbox switch
125
+ // element trick - clicking its label triggers native haptic feedback
126
+ useEffect(() => {
127
+ if (!isIOS()) return;
128
+
129
+ const input = document.createElement("input");
130
+ input.type = "checkbox";
131
+ input.id = "hay-haptic-switch";
132
+ input.setAttribute("switch", "");
133
+ // Hide off-screen instead of display:none (display:none prevents haptic)
134
+ input.style.position = "fixed";
135
+ input.style.left = "-9999px";
136
+ input.style.top = "-9999px";
137
+ input.style.opacity = "0";
138
+ input.style.pointerEvents = "none";
139
+ document.body.appendChild(input);
140
+
141
+ const label = document.createElement("label");
142
+ label.htmlFor = "hay-haptic-switch";
143
+ label.style.position = "fixed";
144
+ label.style.left = "-9999px";
145
+ label.style.top = "-9999px";
146
+ label.style.opacity = "0";
147
+ label.style.pointerEvents = "none";
148
+ document.body.appendChild(label);
149
+ hapticLabelRef.current = label;
150
+
151
+ return () => {
152
+ document.body.removeChild(input);
153
+ document.body.removeChild(label);
154
+ hapticLabelRef.current = null;
155
+ };
156
+ }, []);
157
+
158
+ // Helper to add pressed class (fast in, slow fade out via CSS)
159
+ const addPressed = (e: React.TouchEvent | React.MouseEvent) => {
160
+ const target = e.currentTarget as HTMLElement;
161
+ target.classList.add("kb-pressed");
162
+ };
163
+
164
+ const removePressed = (e: React.TouchEvent | React.MouseEvent) => {
165
+ const target = e.currentTarget as HTMLElement;
166
+ target.classList.remove("kb-pressed");
167
+ };
168
+
169
+ const hapticTap = useCallback(() => {
170
+ if (!hapticsEnabled) return;
171
+ try {
172
+ if (isIOS()) {
173
+ // iOS: click hidden switch label to trigger native haptic
174
+ // Note: iOS 18+ requires this to be called from onClick handler
175
+ hapticLabelRef.current?.click();
176
+ } else if (navigator.vibrate) {
177
+ // Android/other: use Vibration API
178
+ navigator.vibrate(5);
179
+ }
180
+ } catch {
181
+ // Ignore haptic errors
182
+ }
183
+ }, [hapticsEnabled]);
184
+
185
+ // Use refs in send to avoid stale closures - this is critical for rapid typing
186
+ const send = useCallback(
187
+ (key: string) => {
188
+ let data = key;
189
+
190
+ // Handle Ctrl modifier - only for letter keys (A-Z)
191
+ // Read from ref for current value
192
+ if (ctrlRef.current && key.length === 1) {
193
+ const c = key.toUpperCase().charCodeAt(0);
194
+ if (c >= 65 && c <= 90) {
195
+ data = String.fromCharCode(c - 64);
196
+ setCtrl(false);
197
+ }
198
+ }
199
+
200
+ // Call onInput via ref to always use current callback
201
+ onInputRef.current(data);
202
+
203
+ // Clear shift after character input (unless caps lock)
204
+ if (shiftRef.current && !capsRef.current && data.length === 1) {
205
+ shiftTapAt.current = 0;
206
+ }
207
+ },
208
+ [] // No dependencies - we read from refs
209
+ );
210
+
211
+ const handlePaste = useCallback(async () => {
212
+ try {
213
+ if (navigator.clipboard && navigator.clipboard.readText) {
214
+ const text = await navigator.clipboard.readText();
215
+ if (text) {
216
+ send(text);
217
+ }
218
+ }
219
+ } catch {
220
+ // Clipboard access denied
221
+ }
222
+ }, [send]); // send is stable
223
+
224
+ const resizeSysTextarea = useCallback(() => {
225
+ const el = sysInputRef.current;
226
+ if (!el) return;
227
+ el.style.height = "auto";
228
+ const maxHeight = Math.min(window.innerHeight * 0.4, 240);
229
+ el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
230
+ }, []);
231
+
232
+ const closeSysKb = useCallback(() => {
233
+ sysInputRef.current?.blur();
234
+ setSysKbOpen(false);
235
+ }, []);
236
+
237
+ const focusSysKbInput = useCallback(() => {
238
+ const el = sysInputRef.current;
239
+ if (!el) return;
240
+ try {
241
+ el.focus({ preventScroll: true });
242
+ } catch {
243
+ el.focus();
244
+ }
245
+ const len = el.value.length;
246
+ if (typeof el.setSelectionRange === "function") {
247
+ el.setSelectionRange(len, len);
248
+ }
249
+ resizeSysTextarea();
250
+ }, [resizeSysTextarea]);
251
+
252
+ const openSysKb = useCallback(() => {
253
+ flushSync(() => {
254
+ setSysKbOpen(true);
255
+ });
256
+ focusSysKbInput();
257
+ }, [focusSysKbInput]);
258
+
259
+ const toggleSysKb = useCallback(() => {
260
+ if (sysKbOpen) {
261
+ closeSysKb();
262
+ } else {
263
+ openSysKb();
264
+ }
265
+ }, [sysKbOpen, closeSysKb, openSysKb]);
266
+
267
+ const handleSysKbSubmit = useCallback(() => {
268
+ const text = sysKbDraft;
269
+ if (text) {
270
+ send(text);
271
+ setSysKbDraft("");
272
+ }
273
+ if (sysInputRef.current) {
274
+ sysInputRef.current.style.height = "";
275
+ }
276
+ closeSysKb();
277
+ }, [send, closeSysKb, sysKbDraft]);
278
+
279
+ useEffect(() => {
280
+ if (!sysKbOpen) return;
281
+ const el = sysInputRef.current;
282
+ if (!el) return;
283
+ const focusAndSize = () => {
284
+ focusSysKbInput();
285
+ };
286
+ const frame = requestAnimationFrame(focusAndSize);
287
+ const frame2 = requestAnimationFrame(focusAndSize);
288
+ return () => {
289
+ cancelAnimationFrame(frame);
290
+ cancelAnimationFrame(frame2);
291
+ };
292
+ }, [sysKbOpen, resizeSysTextarea]);
293
+
294
+ useEffect(() => {
295
+ if (!sysKbOpen) return;
296
+ resizeSysTextarea();
297
+ }, [sysKbDraft, sysKbOpen, resizeSysTextarea]);
298
+
299
+ const handleShift = useCallback(() => {
300
+ const now = Date.now();
301
+ const doubleTap = shiftTapAt.current && now - shiftTapAt.current < 300;
302
+
303
+ if (caps) {
304
+ setCaps(false);
305
+ setShift(false);
306
+ shiftTapAt.current = 0;
307
+ } else if (shift) {
308
+ if (doubleTap) {
309
+ setCaps(true);
310
+ setShift(false);
311
+ shiftTapAt.current = 0;
312
+ } else {
313
+ setShift(false);
314
+ shiftTapAt.current = now;
315
+ }
316
+ } else {
317
+ setShift(true);
318
+ shiftTapAt.current = now;
319
+ }
320
+ }, [caps, shift]);
321
+
322
+ const startBksp = useCallback(() => {
323
+ send("\x7f");
324
+ bkspTimeout.current = window.setTimeout(() => {
325
+ bkspInterval.current = window.setInterval(() => {
326
+ send("\x7f");
327
+ }, 50);
328
+ }, 500);
329
+ }, [send]);
330
+
331
+ const stopBksp = useCallback(() => {
332
+ if (bkspTimeout.current) {
333
+ clearTimeout(bkspTimeout.current);
334
+ bkspTimeout.current = null;
335
+ }
336
+ if (bkspInterval.current) {
337
+ clearInterval(bkspInterval.current);
338
+ bkspInterval.current = null;
339
+ }
340
+ }, []);
341
+
342
+ // Show hint for first-time users
343
+ useEffect(() => {
344
+ if (visible && !sysKbHintShown) {
345
+ try {
346
+ const seen = localStorage.getItem("hay-syskb-seen");
347
+ if (!seen) {
348
+ setSysKbHintShown(true);
349
+ setTimeout(() => {
350
+ setShowHint(true);
351
+ setTimeout(() => {
352
+ setShowHint(false);
353
+ localStorage.setItem("hay-syskb-seen", "1");
354
+ }, 4000);
355
+ }, 1000);
356
+ } else {
357
+ setSysKbHintShown(true);
358
+ }
359
+ } catch {
360
+ setSysKbHintShown(true);
361
+ }
362
+ }
363
+ }, [visible, sysKbHintShown]);
364
+
365
+ // Use refs to avoid stale closures during rapid typing
366
+ const handleCharKey = useCallback(
367
+ (char: string) => {
368
+ const ch = shiftRef.current || capsRef.current ? char.toUpperCase() : char;
369
+ send(ch);
370
+ if (shiftRef.current && !capsRef.current) {
371
+ setShift(false);
372
+ }
373
+ },
374
+ [send] // send is stable (empty deps)
375
+ );
376
+
377
+ const renderKey = (keyStr: string, index: number, isThirdRow: boolean) => {
378
+ const isOn = shift || caps;
379
+
380
+ if (keyStr === "SHIFT") {
381
+ return (
382
+ <button
383
+ key={keyStr}
384
+ type="button"
385
+ className={`kb-key kb-act${isOn ? " kb-on" : ""}`}
386
+ style={{ flexGrow: 1.25 }}
387
+ onTouchStart={(e) => {
388
+ e.preventDefault();
389
+ addPressed(e);
390
+ handleShift();
391
+ }}
392
+ onTouchEnd={removePressed}
393
+ onTouchCancel={removePressed}
394
+ onClick={hapticTap}
395
+ >
396
+ <ShiftIcon filled={isOn} />
397
+ </button>
398
+ );
399
+ }
400
+
401
+ if (keyStr === "BKSP") {
402
+ return (
403
+ <button
404
+ key={keyStr}
405
+ type="button"
406
+ className="kb-key kb-act"
407
+ style={{ flexGrow: 1.25 }}
408
+ onTouchStart={(e) => {
409
+ e.preventDefault();
410
+ addPressed(e);
411
+ startBksp();
412
+ }}
413
+ onTouchEnd={(e) => {
414
+ removePressed(e);
415
+ stopBksp();
416
+ }}
417
+ onTouchCancel={(e) => {
418
+ removePressed(e);
419
+ stopBksp();
420
+ }}
421
+ onClick={hapticTap}
422
+ >
423
+ <BackspaceIcon />
424
+ </button>
425
+ );
426
+ }
427
+
428
+ if (keyStr === "PASTE") {
429
+ return (
430
+ <button
431
+ key={keyStr}
432
+ type="button"
433
+ className="kb-key kb-act"
434
+ style={{ flexGrow: 1.25 }}
435
+ onTouchStart={addPressed}
436
+ onTouchEnd={(e) => {
437
+ removePressed(e);
438
+ handlePaste();
439
+ }}
440
+ onTouchCancel={removePressed}
441
+ onClick={hapticTap}
442
+ >
443
+ <PasteIcon />
444
+ </button>
445
+ );
446
+ }
447
+
448
+ if (keyStr === "RET") {
449
+ return (
450
+ <button
451
+ key={keyStr}
452
+ type="button"
453
+ className="kb-key kb-pri kb-bottom"
454
+ style={{ flexGrow: 2.5 }}
455
+ onTouchStart={(e) => {
456
+ e.preventDefault();
457
+ addPressed(e);
458
+ send("\r");
459
+ }}
460
+ onTouchEnd={removePressed}
461
+ onTouchCancel={removePressed}
462
+ onClick={hapticTap}
463
+ >
464
+ return
465
+ </button>
466
+ );
467
+ }
468
+
469
+ if (keyStr === "SPACE") {
470
+ return (
471
+ <button
472
+ key={keyStr}
473
+ type="button"
474
+ className="kb-key kb-bottom"
475
+ style={{ flexGrow: 5 }}
476
+ onTouchStart={(e) => {
477
+ e.preventDefault();
478
+ addPressed(e);
479
+ send(" ");
480
+ }}
481
+ onTouchEnd={removePressed}
482
+ onTouchCancel={removePressed}
483
+ onClick={hapticTap}
484
+ >
485
+ space
486
+ </button>
487
+ );
488
+ }
489
+
490
+ if (keyStr === "123") {
491
+ return (
492
+ <button
493
+ key={keyStr}
494
+ type="button"
495
+ className="kb-key kb-act kb-bottom"
496
+ style={{ flexGrow: 1.25 }}
497
+ onTouchStart={(e) => {
498
+ e.preventDefault();
499
+ addPressed(e);
500
+ setView("num");
501
+ }}
502
+ onTouchEnd={removePressed}
503
+ onTouchCancel={removePressed}
504
+ onClick={hapticTap}
505
+ >
506
+ 123
507
+ </button>
508
+ );
509
+ }
510
+
511
+ if (keyStr === "ABC") {
512
+ return (
513
+ <button
514
+ key={keyStr}
515
+ type="button"
516
+ className="kb-key kb-act kb-bottom"
517
+ style={{ flexGrow: 1.25 }}
518
+ onTouchStart={(e) => {
519
+ e.preventDefault();
520
+ addPressed(e);
521
+ setView("abc");
522
+ }}
523
+ onTouchEnd={removePressed}
524
+ onTouchCancel={removePressed}
525
+ onClick={hapticTap}
526
+ >
527
+ ABC
528
+ </button>
529
+ );
530
+ }
531
+
532
+ // Character key
533
+ const displayChar = shift || caps ? keyStr.toUpperCase() : keyStr;
534
+ return (
535
+ <button
536
+ key={`${keyStr}-${index}`}
537
+ type="button"
538
+ className="kb-key"
539
+ onTouchStart={(e) => {
540
+ e.preventDefault();
541
+ addPressed(e);
542
+ handleCharKey(keyStr);
543
+ }}
544
+ onTouchEnd={removePressed}
545
+ onTouchCancel={removePressed}
546
+ onClick={hapticTap}
547
+ >
548
+ {displayChar}
549
+ </button>
550
+ );
551
+ };
552
+
553
+ if (!visible) return null;
554
+
555
+ return (
556
+ <>
557
+ {/* System keyboard hint overlay */}
558
+ {showHint && (
559
+ <div className="kb-hint-overlay">
560
+ <div className="kb-hint">
561
+ Tap the pulsing <strong>KB</strong> button for smooth typing with native keyboard, spellcheck, autocomplete and dictation
562
+ </div>
563
+ </div>
564
+ )}
565
+
566
+ {/* System keyboard dialog */}
567
+ {sysKbOpen && (
568
+ <div className="kb-sys-overlay" onClick={toggleSysKb}>
569
+ <div className="kb-sys-dialog" onClick={(e) => e.stopPropagation()}>
570
+ <textarea
571
+ ref={sysInputRef}
572
+ className="kb-sys-input"
573
+ placeholder="Type here..."
574
+ autoCapitalize="off"
575
+ autoComplete="off"
576
+ autoCorrect="off"
577
+ rows={1}
578
+ value={sysKbDraft}
579
+ onKeyDown={(e) => {
580
+ if (e.key === "Enter") {
581
+ e.preventDefault();
582
+ handleSysKbSubmit();
583
+ }
584
+ }}
585
+ onChange={(e) => {
586
+ setSysKbDraft(e.currentTarget.value);
587
+ }}
588
+ onInput={resizeSysTextarea}
589
+ />
590
+ </div>
591
+ </div>
592
+ )}
593
+
594
+ <div className="mobile-keyboard">
595
+ {/* Accessory row - Esc, Tab, Ctrl, Alt, arrows, system keyboard */}
596
+ <div className="kb-acc-row">
597
+ {ACC_KEYS.map((k, i) => {
598
+ if (k.mod) {
599
+ const isOn = k.mod === "ctrl" ? ctrl : alt;
600
+ return (
601
+ <button
602
+ key={k.label}
603
+ type="button"
604
+ className={`kb-key kb-act kb-acc${isOn ? " kb-on" : ""}`}
605
+ onTouchStart={(e) => {
606
+ e.preventDefault();
607
+ addPressed(e);
608
+ if (k.mod === "ctrl") setCtrl(prev => !prev);
609
+ else setAlt(prev => !prev);
610
+ }}
611
+ onTouchEnd={removePressed}
612
+ onTouchCancel={removePressed}
613
+ onClick={hapticTap}
614
+ >
615
+ {k.label}
616
+ </button>
617
+ );
618
+ }
619
+ if (k.fn === "sysKb") {
620
+ return (
621
+ <button
622
+ key={k.label}
623
+ type="button"
624
+ className={`kb-key kb-act kb-acc kb-syskb${showHint ? " kb-pulse" : ""}`}
625
+ onTouchStart={(e) => {
626
+ e.preventDefault();
627
+ addPressed(e);
628
+ try {
629
+ localStorage.setItem("hay-syskb-seen", "1");
630
+ } catch {
631
+ // Ignore
632
+ }
633
+ toggleSysKb();
634
+ }}
635
+ onTouchEnd={removePressed}
636
+ onTouchCancel={removePressed}
637
+ onClick={hapticTap}
638
+ >
639
+ <KeyboardIcon />
640
+ </button>
641
+ );
642
+ }
643
+ return (
644
+ <button
645
+ key={k.label}
646
+ type="button"
647
+ className="kb-key kb-act kb-acc"
648
+ onTouchStart={(e) => {
649
+ e.preventDefault();
650
+ addPressed(e);
651
+ if (k.key) send(k.key);
652
+ }}
653
+ onTouchEnd={removePressed}
654
+ onTouchCancel={removePressed}
655
+ onClick={hapticTap}
656
+ >
657
+ {k.label}
658
+ </button>
659
+ );
660
+ })}
661
+ </div>
662
+
663
+ {/* Main keyboard rows */}
664
+ <div className="kb-keys">
665
+ {LAYOUTS[view].map((row, ri) => {
666
+ const isThirdRow = ri === 2;
667
+ return (
668
+ <div
669
+ key={ri}
670
+ className={`kb-row${ri === 1 ? " kb-r2" : ""}${isThirdRow ? " kb-r3" : ""}`}
671
+ >
672
+ {row.map((keyStr, ki) => {
673
+ const elements = [];
674
+
675
+ // Add gap after SHIFT in third row
676
+ if (keyStr === "SHIFT" && isThirdRow) {
677
+ elements.push(renderKey(keyStr, ki, isThirdRow));
678
+ elements.push(<div key="gap-after-shift" className="kb-gap" />);
679
+ return elements;
680
+ }
681
+
682
+ // Add gap before BKSP in third row
683
+ if (keyStr === "BKSP" && isThirdRow) {
684
+ elements.push(<div key="gap-before-bksp" className="kb-gap" />);
685
+ elements.push(renderKey(keyStr, ki, isThirdRow));
686
+ return elements;
687
+ }
688
+
689
+ return renderKey(keyStr, ki, isThirdRow);
690
+ })}
691
+ </div>
692
+ );
693
+ })}
694
+ </div>
695
+
696
+ </div>
697
+ </>
698
+ );
699
+ };
700
+
701
+ export default MobileKeyboard;
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import App from "./App";
4
+ import "./styles.css";
5
+
6
+ const container = document.getElementById("root");
7
+ if (container) {
8
+ createRoot(container).render(
9
+ <React.StrictMode>
10
+ <App />
11
+ </React.StrictMode>
12
+ );
13
+ }
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom";