Monorepo for Aesthetic.Computer
aesthetic.computer
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <script>if(new URLSearchParams(location.search).get('wm')==='paper')document.documentElement.classList.add('paperwm')</script>
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <title>AC Pane</title>
8 <link rel="stylesheet" href="../node_modules/@xterm/xterm/css/xterm.css">
9 <style>
10 /* Theme color variables - changes based on system light/dark mode */
11 :root {
12 /* Dark mode (default) - purple theme */
13 --ac-border: rgba(136, 68, 255, 0.25);
14 --ac-border-solid: rgba(136, 68, 255, 0.4);
15 --ac-border-hover: rgba(136, 68, 255, 0.5);
16 --ac-border-active: rgba(136, 68, 255, 0.7);
17 --ac-accent: rgba(136, 68, 255, 0.6);
18 --ac-accent-glow: rgba(136, 68, 255, 0.5);
19 --ac-shadow-inner: rgba(136, 68, 255, 0.2);
20 --ac-tab-back: rgba(60, 40, 100, 0.6);
21 --ac-tab-back-hover: rgba(80, 50, 130, 0.8);
22 --ac-tab-back-accent: rgba(100, 60, 160, 0.6);
23 --ac-tab-back-accent-hover: rgba(140, 90, 200, 1);
24 --ac-front-bg: #111;
25 --ac-back-bg: #0a0a12;
26 --ac-text: #fff;
27 --ac-text-muted: #888;
28 }
29
30 /* Light mode - golden/sand theme (matching legal pad yellow) */
31 @media (prefers-color-scheme: light) {
32 :root {
33 /* Using #c8a050 (200, 160, 80) as the base golden color */
34 --ac-border: rgba(200, 160, 80, 0.35);
35 --ac-border-solid: rgba(200, 160, 80, 0.5);
36 --ac-border-hover: rgba(200, 160, 80, 0.65);
37 --ac-border-active: rgba(200, 160, 80, 0.85);
38 --ac-accent: rgba(200, 160, 80, 0.6);
39 --ac-accent-glow: rgba(200, 160, 80, 0.5);
40 --ac-shadow-inner: rgba(200, 160, 80, 0.25);
41 --ac-tab-back: rgba(200, 160, 80, 0.4);
42 --ac-tab-back-hover: rgba(200, 160, 80, 0.6);
43 --ac-tab-back-accent: rgba(200, 160, 80, 0.5);
44 --ac-tab-back-accent-hover: rgba(200, 160, 80, 0.9);
45 --ac-front-bg: #fcf7c5;
46 --ac-back-bg: #f5f0c0;
47 --ac-text: #281e5a;
48 --ac-text-muted: #666;
49 }
50 }
51
52 * { margin: 0; padding: 0; box-sizing: border-box; }
53 html, body {
54 width: 100%;
55 height: 100%;
56 overflow: hidden;
57 background: transparent;
58 font-family: system-ui, sans-serif;
59 user-select: none;
60 -webkit-user-select: none;
61 perspective: 1500px;
62 }
63
64 /*
65 * 3D FLIP LAYOUT:
66 * - Everything flips together like a physical card
67 * - Desktop shows through during flip (transparent)
68 * - Thick border frames the content
69 * - Side tabs and mode tags flip too (blank backsides)
70 */
71
72 /* Perspective container - static, provides 3D context */
73 .flip-perspective {
74 position: fixed;
75 top: 40px; /* Extra space at top for flip animation headroom */
76 left: 30px; /* Extra space for wider flip tabs */
77 right: 30px; /* Extra space for wider flip tabs */
78 bottom: 56px; /* Extra space at bottom for mode tags + flip headroom */
79 perspective: 1200px;
80 perspective-origin: center center;
81 }
82
83 /* The flipping card - this actually rotates in 3D */
84 .flip-card {
85 position: absolute;
86 inset: 0;
87 transform-style: preserve-3d;
88 transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
89 /* Rotation angle set via JS inline style */
90 }
91
92 /* Shared face styles - positioned in 3D space */
93 .card-face {
94 position: absolute;
95 inset: 0;
96 border: 6px solid var(--ac-border);
97 border-radius: 10px;
98 overflow: hidden;
99 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3),
100 inset 0 0 0 1px var(--ac-shadow-inner);
101 background: transparent;
102 /* No transition - JS controls the swap at midpoint */
103 }
104
105 /* Front face - webview - active and interactive when not flipped */
106 .card-front {
107 z-index: 1;
108 opacity: 1;
109 filter: none;
110 background: var(--ac-front-bg);
111 }
112
113 /* Midflip class applied by JS at exact midpoint */
114 body.midflip .card-front {
115 z-index: 3;
116 opacity: 0.15;
117 filter: blur(2px);
118 pointer-events: none;
119 }
120
121 .card-front webview {
122 width: 100%;
123 height: 100%;
124 border: none;
125 }
126
127 /* Back face - terminal - ghost ON TOP when not flipped */
128 .card-back {
129 z-index: 3;
130 transform: rotateY(180deg);
131 opacity: 0.15;
132 filter: blur(2px);
133 pointer-events: none;
134 background: var(--ac-back-bg);
135 }
136
137 /* Midflip class - back becomes active */
138 body.midflip .card-back {
139 z-index: 1;
140 opacity: 1;
141 filter: none;
142 pointer-events: auto;
143 }
144
145 #terminal-container {
146 width: 100%;
147 height: 100%;
148 padding: 12px;
149 }
150
151 .xterm { height: 100%; }
152 .xterm-viewport { padding-top: 0 !important; }
153
154 /* Flip tabs - attached to card edges, swap appearance on flip */
155 .flip-tab {
156 position: absolute;
157 z-index: 250;
158 width: 26px;
159 top: 50%;
160 height: 80px;
161 cursor: pointer;
162 transform: translateY(-50%);
163 /* Don't set app-region here - we need click events on the parent */
164 }
165
166 .flip-tab:hover {
167 cursor: pointer;
168 }
169
170 /* Tab front face - visible when not flipped */
171 .flip-tab .tab-front {
172 position: absolute;
173 inset: 0;
174 background: var(--ac-border-solid);
175 display: flex;
176 align-items: center;
177 justify-content: center;
178 cursor: pointer;
179 }
180
181 .flip-tab .tab-front::after {
182 content: '';
183 width: 3px;
184 height: 30px;
185 background: var(--ac-accent);
186 border-radius: 2px;
187 transition: all 0.2s ease;
188 pointer-events: none; /* Don't block parent clicks */
189 }
190
191 .flip-tab:hover .tab-front {
192 background: var(--ac-border-hover);
193 }
194
195 .flip-tab:hover .tab-front::after {
196 background: var(--ac-border-active);
197 height: 50px;
198 box-shadow: 0 0 12px var(--ac-accent-glow);
199 }
200
201 /* Tab back face - visible when flipped */
202 .flip-tab .tab-back {
203 position: absolute;
204 inset: 0;
205 background: var(--ac-tab-back);
206 opacity: 0;
207 display: flex;
208 align-items: center;
209 justify-content: center;
210 cursor: pointer;
211 }
212
213 .flip-tab .tab-back::after {
214 content: '';
215 width: 3px;
216 height: 30px;
217 background: var(--ac-tab-back-accent);
218 border-radius: 2px;
219 transition: all 0.2s ease;
220 pointer-events: none; /* Don't block parent clicks */
221 }
222
223 /* Hover effect for back tab */
224 .flip-tab:hover .tab-back {
225 background: var(--ac-tab-back-hover);
226 }
227
228 .flip-tab:hover .tab-back::after {
229 background: var(--ac-tab-back-accent-hover);
230 height: 50px;
231 box-shadow: 0 0 12px var(--ac-accent-glow);
232 }
233
234 /* When midflip, swap front/back visibility */
235 body.midflip .flip-tab .tab-front {
236 opacity: 0;
237 }
238
239 body.midflip .flip-tab .tab-back {
240 opacity: 1;
241 }
242
243 /* Left tab - on left edge */
244 .flip-tab.left {
245 left: -26px;
246 }
247
248 .flip-tab.left .tab-front,
249 .flip-tab.left .tab-back {
250 border-radius: 6px 0 0 6px;
251 }
252
253 /* Right tab - on right edge */
254 .flip-tab.right {
255 right: -26px;
256 }
257
258 .flip-tab.right .tab-front,
259 .flip-tab.right .tab-back {
260 border-radius: 0 6px 6px 0;
261 }
262
263 /* Volume control - integrated into top-right corner of frame */
264 .volume-control {
265 position: absolute;
266 right: 0;
267 top: -26px;
268 z-index: 240;
269 height: 26px;
270 display: flex;
271 flex-direction: row;
272 align-items: center;
273 gap: 6px;
274 padding: 4px 12px 4px 10px;
275 border-radius: 10px 10px 0 0;
276 background: var(--ac-border-solid);
277 border: 1px solid var(--ac-border);
278 border-bottom: none;
279 box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2);
280 }
281
282 body.midflip .volume-control {
283 background: var(--ac-tab-back);
284 border-color: var(--ac-border-solid);
285 }
286
287 #volume-slider {
288 -webkit-appearance: none;
289 appearance: none;
290 width: 72px;
291 height: 4px;
292 background: var(--ac-border-solid);
293 border-radius: 2px;
294 cursor: pointer;
295 }
296
297 #volume-slider::-webkit-slider-runnable-track {
298 height: 4px;
299 background: transparent;
300 border-radius: 2px;
301 }
302
303 #volume-slider::-webkit-slider-thumb {
304 -webkit-appearance: none;
305 appearance: none;
306 width: 11px;
307 height: 11px;
308 border-radius: 50%;
309 background: var(--ac-accent);
310 border: 1px solid var(--ac-border-active);
311 box-shadow: 0 0 8px var(--ac-accent-glow);
312 margin-top: -4px;
313 }
314
315 #volume-slider::-moz-range-track {
316 height: 4px;
317 background: transparent;
318 border-radius: 2px;
319 }
320
321 #volume-slider::-moz-range-thumb {
322 width: 11px;
323 height: 11px;
324 border-radius: 50%;
325 background: var(--ac-accent);
326 border: 1px solid var(--ac-border-active);
327 box-shadow: 0 0 8px var(--ac-accent-glow);
328 }
329
330 /* Backend controls - appears on backend side (counter-rotated to fix mirroring) */
331 .backend-controls {
332 position: absolute;
333 z-index: 100;
334 bottom: -32px;
335 left: 50%;
336 transform: translateX(-50%) rotateY(180deg);
337 opacity: 0;
338 pointer-events: none;
339 transition: opacity 0.1s ease;
340 display: flex;
341 gap: 4px;
342 }
343
344 body.midflip .backend-controls {
345 opacity: 1;
346 pointer-events: auto;
347 }
348
349 .backend-controls button {
350 font-size: 16px;
351 font-weight: 700;
352 padding: 4px 16px 6px 16px;
353 border-radius: 0 0 6px 6px;
354 border: none;
355 cursor: pointer;
356 transition: all 0.2s ease;
357 }
358
359 .backend-controls button.start-btn {
360 background: var(--ac-border-hover);
361 color: var(--ac-text);
362 }
363
364 .backend-controls button.start-btn:hover {
365 background: var(--ac-border-active);
366 box-shadow: 0 2px 12px var(--ac-accent-glow);
367 }
368
369 .backend-controls button.start-btn:disabled {
370 background: var(--ac-border);
371 color: var(--ac-text-muted);
372 cursor: not-allowed;
373 }
374
375 .backend-controls button.stop-btn {
376 background: var(--ac-accent);
377 color: var(--ac-text);
378 }
379
380 .backend-controls button.stop-btn:hover {
381 background: var(--ac-border-active);
382 box-shadow: 0 2px 12px var(--ac-accent-glow);
383 }
384
385 .backend-controls button.stop-btn:disabled {
386 background: var(--ac-border);
387 color: var(--ac-text-muted);
388 cursor: not-allowed;
389 }
390
391
392 /* Resize handles on the card border edge */
393 .resize-handle {
394 position: fixed;
395 z-index: 600;
396 /* Debug: background: rgba(255,0,0,0.2); */
397 }
398
399 /* Positions aligned with flip-perspective: top: 40px, left/right: 30px, bottom: 56px */
400 .resize-handle.top {
401 top: 34px; left: 24px; right: 24px; height: 12px;
402 cursor: n-resize;
403 }
404
405 .resize-handle.bottom {
406 bottom: 50px; left: 24px; right: 24px; height: 12px;
407 cursor: s-resize;
408 }
409
410 /* Left side - split into top and bottom sections, leaving gap for flip tab */
411 .resize-handle.left-top {
412 left: 24px; top: 46px; height: calc(50% - 90px); width: 12px;
413 cursor: w-resize;
414 }
415
416 .resize-handle.left-bottom {
417 left: 24px; bottom: 62px; height: calc(50% - 90px); width: 12px;
418 cursor: w-resize;
419 }
420
421 /* Right side - split into top and bottom sections, leaving gap for flip tab */
422 .resize-handle.right-top {
423 right: 24px; top: 46px; height: calc(50% - 90px); width: 12px;
424 cursor: e-resize;
425 }
426
427 .resize-handle.right-bottom {
428 right: 24px; bottom: 62px; height: calc(50% - 90px); width: 12px;
429 cursor: e-resize;
430 }
431
432 .resize-handle.top-left {
433 top: 34px; left: 24px; width: 16px; height: 16px;
434 cursor: nw-resize;
435 }
436
437 .resize-handle.top-right {
438 top: 34px; right: 24px; width: 16px; height: 16px;
439 cursor: ne-resize;
440 }
441
442 .resize-handle.bottom-left {
443 bottom: 50px; left: 24px; width: 16px; height: 16px;
444 cursor: sw-resize;
445 }
446
447 .resize-handle.bottom-right {
448 bottom: 50px; right: 24px; width: 16px; height: 16px;
449 cursor: se-resize;
450 }
451
452 /* Overlay to capture mouse during resize drag */
453 .resize-overlay {
454 position: fixed;
455 inset: 0;
456 z-index: 9999;
457 cursor: inherit;
458 display: none;
459 }
460
461 /* Backend welcome screen */
462 .backend-welcome {
463 position: absolute;
464 inset: 0;
465 background: linear-gradient(135deg, #0a0a18 0%, #12081f 50%, #0a0a18 100%);
466 display: flex;
467 flex-direction: column;
468 align-items: center;
469 justify-content: center;
470 padding: 24px;
471 z-index: 10;
472 color: #fff;
473 font-family: system-ui, -apple-system, sans-serif;
474 }
475
476 .backend-welcome.hidden {
477 display: none;
478 }
479
480 .backend-welcome h1 {
481 font-size: 18px;
482 font-weight: 600;
483 margin-bottom: 8px;
484 color: #f0f;
485 text-shadow: 0 0 20px rgba(255, 0, 255, 0.5);
486 }
487
488 .backend-welcome .subtitle {
489 font-size: 11px;
490 color: #888;
491 margin-bottom: 24px;
492 }
493
494 .backend-welcome .hint-text {
495 font-size: 10px;
496 color: #666;
497 margin-top: 16px;
498 padding: 8px 16px;
499 background: rgba(136, 68, 255, 0.1);
500 border-radius: 4px;
501 }
502
503 .backend-welcome .info-section {
504 width: 100%;
505 max-width: 400px;
506 margin-bottom: 20px;
507 }
508
509 .backend-welcome .info-row {
510 display: flex;
511 justify-content: space-between;
512 align-items: center;
513 padding: 8px 12px;
514 background: rgba(255, 255, 255, 0.03);
515 border-radius: 6px;
516 margin-bottom: 6px;
517 font-size: 11px;
518 }
519
520 .backend-welcome .info-label {
521 color: #666;
522 }
523
524 .backend-welcome .info-value {
525 color: #aaa;
526 font-family: 'SF Mono', 'Fira Code', monospace;
527 font-size: 10px;
528 }
529
530 .backend-welcome .info-value.success {
531 color: #0f0;
532 }
533
534 .backend-welcome .info-value.warning {
535 color: #ff9500;
536 }
537
538 .backend-welcome .info-value.error {
539 color: #f55;
540 }
541
542 .backend-welcome .info-value.loading {
543 color: #888;
544 }
545
546 .backend-welcome .info-value.loading::after {
547 content: '';
548 display: inline-block;
549 width: 4px;
550 height: 4px;
551 margin-left: 6px;
552 background: #888;
553 border-radius: 50%;
554 animation: pulse 1s ease-in-out infinite;
555 }
556
557 @keyframes pulse {
558 0%, 100% { opacity: 0.3; transform: scale(0.8); }
559 50% { opacity: 1; transform: scale(1.2); }
560 }
561
562 .backend-welcome .start-button {
563 background: linear-gradient(180deg, #8844ff 0%, #6622dd 100%);
564 color: #fff;
565 border: none;
566 padding: 12px 32px;
567 border-radius: 8px;
568 font-size: 13px;
569 font-weight: 600;
570 cursor: pointer;
571 transition: all 0.2s ease;
572 box-shadow: 0 4px 16px rgba(136, 68, 255, 0.4);
573 margin-top: 8px;
574 }
575
576 .backend-welcome .start-button:hover {
577 background: linear-gradient(180deg, #9955ff 0%, #7733ee 100%);
578 transform: translateY(-1px);
579 box-shadow: 0 6px 20px rgba(136, 68, 255, 0.5);
580 }
581
582 .backend-welcome .start-button:active {
583 transform: translateY(0);
584 }
585
586 .backend-welcome .start-button:disabled {
587 background: #333;
588 color: #666;
589 cursor: not-allowed;
590 box-shadow: none;
591 }
592
593 .backend-welcome .start-button.secondary {
594 background: linear-gradient(180deg, #333 0%, #222 100%);
595 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
596 margin-top: 8px;
597 padding: 10px 24px;
598 font-size: 12px;
599 }
600
601 .backend-welcome .start-button.secondary:hover {
602 background: linear-gradient(180deg, #444 0%, #333 100%);
603 }
604
605 .backend-welcome .button-row {
606 display: flex;
607 gap: 12px;
608 flex-wrap: wrap;
609 justify-content: center;
610 }
611
612 .resize-overlay.active {
613 display: block;
614 }
615
616 /* Spinner cursor during swivel animation */
617 body.swiveling,
618 body.swiveling * {
619 cursor: wait !important;
620 }
621
622 /* Disable pointer events on content during resize */
623 body.resizing .view,
624 body.resizing webview {
625 pointer-events: none !important;
626 }
627
628 /* PaperWM: full-bleed webview only, no card chrome, no back face */
629 html.paperwm { background: #000; }
630 html.paperwm body { perspective: none; }
631 html.paperwm .flip-perspective {
632 top: 0; left: 0; right: 0; bottom: 0;
633 perspective: none;
634 }
635 html.paperwm .flip-card {
636 transform-style: flat;
637 transition: none;
638 }
639 html.paperwm .card-face {
640 border: none;
641 border-radius: 0;
642 box-shadow: none;
643 }
644 html.paperwm .card-back,
645 html.paperwm .flip-tab,
646 html.paperwm .mode-tags,
647 html.paperwm .volume-control,
648 html.paperwm .backend-controls,
649 html.paperwm .resize-handle,
650 html.paperwm .resize-overlay {
651 display: none !important;
652 }
653 html.paperwm .card-front {
654 position: absolute;
655 inset: 0;
656 }
657 html.paperwm .card-front webview {
658 width: 100% !important;
659 height: 100% !important;
660 }
661
662 </style>
663</head>
664<body>
665 <!-- Overlay to capture mouse during resize -->
666 <div class="resize-overlay" id="resize-overlay"></div>
667
668 <!-- Resize handles on the card border (split on sides to avoid flip tabs) -->
669 <div class="resize-handle top" data-resize="top"></div>
670 <div class="resize-handle bottom" data-resize="bottom"></div>
671 <div class="resize-handle left-top" data-resize="left"></div>
672 <div class="resize-handle left-bottom" data-resize="left"></div>
673 <div class="resize-handle right-top" data-resize="right"></div>
674 <div class="resize-handle right-bottom" data-resize="right"></div>
675 <div class="resize-handle top-left" data-resize="top-left"></div>
676 <div class="resize-handle top-right" data-resize="top-right"></div>
677 <div class="resize-handle bottom-left" data-resize="bottom-left"></div>
678 <div class="resize-handle bottom-right" data-resize="bottom-right"></div>
679
680 <!-- Perspective container -->
681 <div class="flip-perspective">
682 <!-- The flipping card with front and back faces -->
683 <div class="flip-card">
684 <!-- Flip tabs - inside card so they swivel with it -->
685 <div class="flip-tab left" data-flip>
686 <div class="tab-front"></div>
687 <div class="tab-back"></div>
688 </div>
689 <div class="flip-tab right" data-flip>
690 <div class="tab-front"></div>
691 <div class="tab-back"></div>
692 </div>
693
694 <div class="volume-control" aria-label="Master volume">
695 <input id="volume-slider" type="range" min="0" max="1" step="0.01" value="0.25" />
696 </div>
697
698 <!-- Shutdown button - visible on backend side -->
699 <div class="backend-controls">
700 <button id="start-container-btn" class="start-btn">▶</button>
701 <button id="stop-container-btn" class="stop-btn">⏹</button>
702 </div>
703
704 <!-- Front face: AC Webview -->
705 <div class="card-face card-front">
706 <webview id="front-webview" src="https://aesthetic.computer/prompt?desktop" allowpopups preload="../webview-preload.js"></webview>
707 </div>
708
709 <!-- Back face: Terminal -->
710 <div class="card-face card-back">
711 <!-- Welcome screen before terminal starts -->
712 <div class="backend-welcome" id="backend-welcome">
713 <h1>⚡ AC Pane</h1>
714 <div class="subtitle">Development Environment</div>
715
716 <div class="info-section">
717 <div class="info-row">
718 <span class="info-label">📁 Repository</span>
719 <span class="info-value loading" id="info-repo">checking</span>
720 </div>
721 <div class="info-row">
722 <span class="info-label">👤 Git User</span>
723 <span class="info-value loading" id="info-git-user">checking</span>
724 </div>
725 <div class="info-row">
726 <span class="info-label">🐳 Docker</span>
727 <span class="info-value loading" id="info-docker">checking</span>
728 </div>
729 <div class="info-row">
730 <span class="info-label">📦 Container</span>
731 <span class="info-value loading" id="info-container">checking</span>
732 </div>
733 </div>
734
735 <div class="hint-text">Use the START button below to launch the devcontainer</div>
736 </div>
737
738 <div id="terminal-container"></div>
739 </div>
740 </div>
741 </div> <!-- close flip-perspective -->
742
743 <script>
744 const isPaperWM = document.documentElement.classList.contains('paperwm');
745 const { ipcRenderer } = require('electron');
746 // Only load terminal deps if we have a back face
747 const Terminal = isPaperWM ? null : require('@xterm/xterm').Terminal;
748 const FitAddon = isPaperWM ? null : require('@xterm/addon-fit').FitAddon;
749 const WebglAddon = isPaperWM ? null : require('@xterm/addon-webgl').WebglAddon;
750
751 const flipCard = document.querySelector('.flip-card');
752 const webviewEl = document.getElementById('front-webview');
753 const flipTabs = document.querySelectorAll('.flip-tab');
754 const volumeSlider = document.getElementById('volume-slider');
755 let showingTerminal = false;
756 let webviewReady = false;
757 let pendingMasterVolume = null;
758
759 const DEFAULT_MASTER_VOLUME = 0.25;
760 const VOLUME_STORAGE_KEY = 'ac-master-volume';
761
762 function clampVolume(value) {
763 const numeric = Number(value);
764 if (!Number.isFinite(numeric)) return DEFAULT_MASTER_VOLUME;
765 return Math.min(1, Math.max(0, numeric));
766 }
767
768 function updateVolumeUI(value) {
769 const clamped = clampVolume(value);
770 volumeSlider.value = clamped.toString();
771 return clamped;
772 }
773
774 function sendMasterVolume(value) {
775 const clamped = clampVolume(value);
776 console.log('[volume] Sending master volume:', clamped);
777 if (typeof webviewEl?.executeJavaScript !== 'function') {
778 pendingMasterVolume = clamped;
779 return false;
780 }
781 const code = `(() => {
782 const v = ${clamped};
783 console.log('[AC] setMasterVolume called with:', v);
784 if (window.AC && typeof window.AC.setMasterVolume === 'function') {
785 const result = window.AC.setMasterVolume(v);
786 console.log('[AC] setMasterVolume result:', result);
787 return { success: true, method: 'AC.setMasterVolume', result };
788 }
789 if (typeof window.acSetMasterVolume === 'function') {
790 const result = window.acSetMasterVolume(v);
791 console.log('[AC] acSetMasterVolume result:', result);
792 return { success: true, method: 'acSetMasterVolume', result };
793 }
794 console.warn('[AC] No volume setter found. AC:', window.AC);
795 return { success: false, AC: !!window.AC };
796 })()`;
797 webviewEl.executeJavaScript(code, true)
798 .then(result => console.log('[volume] executeJavaScript result:', result))
799 .catch(err => console.error('[volume] executeJavaScript error:', err));
800 pendingMasterVolume = null;
801 return true;
802 }
803
804 function syncMasterVolume() {
805 const clamped = clampVolume(volumeSlider.value);
806 sessionStorage.setItem(VOLUME_STORAGE_KEY, String(clamped));
807 if (!webviewReady) {
808 pendingMasterVolume = clamped;
809 return;
810 }
811 sendMasterVolume(clamped);
812 }
813
814 // Initialize per-window volume
815 const storedVolume = sessionStorage.getItem(VOLUME_STORAGE_KEY);
816 if (storedVolume !== null) {
817 updateVolumeUI(storedVolume);
818 } else {
819 updateVolumeUI(DEFAULT_MASTER_VOLUME);
820 sessionStorage.setItem(VOLUME_STORAGE_KEY, String(DEFAULT_MASTER_VOLUME));
821 }
822 syncMasterVolume();
823
824
825 // Extract piece name from URL
826 function extractPieceName(url) {
827 try {
828 const parsed = new URL(url);
829 const pathname = parsed.pathname;
830 // Remove leading slash and get first segment
831 const piece = pathname.replace(/^\//, '').split('/')[0] || 'prompt';
832 return piece;
833 } catch (e) {
834 return 'prompt';
835 }
836 }
837
838 // Track navigation and update tray title
839 webviewEl.addEventListener('did-navigate', (e) => {
840 const piece = extractPieceName(e.url);
841 console.log('[flip] Navigated to piece:', piece);
842 ipcRenderer.invoke('set-current-piece', piece);
843 syncMasterVolume();
844 });
845
846 webviewEl.addEventListener('did-navigate-in-page', (e) => {
847 if (e.isMainFrame) {
848 const piece = extractPieceName(e.url);
849 console.log('[flip] In-page navigation to piece:', piece);
850 ipcRenderer.invoke('set-current-piece', piece);
851 syncMasterVolume();
852 }
853 });
854
855 webviewEl.addEventListener('dom-ready', () => {
856 console.log('[volume] dom-ready - syncing volume')
857 webviewReady = true;
858 if (pendingMasterVolume !== null) {
859 sendMasterVolume(pendingMasterVolume);
860 }
861 syncMasterVolume();
862 // Retry after AC has had time to boot
863 setTimeout(() => {
864 console.log('[volume] Retry sync after 2s delay');
865 syncMasterVolume();
866 }, 2000);
867 setTimeout(() => {
868 console.log('[volume] Retry sync after 5s delay');
869 syncMasterVolume();
870 }, 5000);
871 });
872
873 // Handle IPC from main process to open devtools
874 ipcRenderer.on('open-devtools', () => {
875 console.log('[flip] Received open-devtools from main');
876 webviewEl.openDevTools();
877 });
878
879 // Handle context menu on webview (right-click)
880 webviewEl.addEventListener('context-menu', (e) => {
881 console.log('[flip] Webview context-menu event', e.params);
882 e.preventDefault();
883 showContextMenu(e.params.x, e.params.y);
884 });
885
886 // Handle IPC messages from webview preload (the primary communication channel)
887 webviewEl.addEventListener('ipc-message', (e) => {
888 const channel = e.channel;
889 const args = e.args?.[0] || {};
890 console.log('[flip] ipc-message from webview:', channel, args);
891
892 if (channel === 'ac-open-window') {
893 const { url, index = 0, total = 1 } = args;
894 console.log('[flip] Opening new window via ipc-message:', url, index, total);
895 ipcRenderer.invoke('ac-open-window', { url, index, total });
896 } else if (channel === 'ac-close-window') {
897 console.log('[flip] Closing window via ipc-message');
898 ipcRenderer.invoke('ac-close-window');
899 }
900 });
901
902 // Handle window.open() from webview (fallback for older code paths)
903 // Note: Main process also handles this via setWindowOpenHandler
904 webviewEl.addEventListener('new-window', (e) => {
905 console.log('[flip] new-window event received:', e?.url, e);
906 if (e?.preventDefault) e.preventDefault();
907 const url = e?.url || '';
908 if (url.startsWith('ac://close')) {
909 console.log('[flip] Handling ac://close - invoking ac-close-window');
910 ipcRenderer.invoke('ac-close-window');
911 return;
912 }
913
914 // Check if this is an external URL that should open in the system browser
915 try {
916 const urlObj = new URL(url);
917 const isExternal = !urlObj.hostname.includes('aesthetic.computer') &&
918 !urlObj.hostname.includes('localhost') &&
919 !urlObj.hostname.includes('127.0.0.1') &&
920 !url.startsWith('ac://');
921
922 if (isExternal) {
923 console.log('[flip] Opening external URL in system browser:', url);
924 ipcRenderer.invoke('open-external-url', url);
925 return;
926 }
927 } catch (err) {
928 console.warn('[flip] Failed to parse URL:', url, err.message);
929 }
930
931 console.log('[flip] Opening new window with url:', url);
932 ipcRenderer.invoke('ac-open-window', { url, index: 0 });
933 });
934
935 // Listen for postMessage from webview (legacy fallback)
936 window.addEventListener('message', (e) => {
937 if (e.data?.type === 'ac-open-window') {
938 console.log('[flip] Received ac-open-window postMessage:', e.data);
939 ipcRenderer.invoke('ac-open-window', {
940 url: e.data.url,
941 index: e.data.index || 0,
942 total: e.data.total || 1
943 });
944 } else if (e.data?.type === 'ac-close-window') {
945 console.log('[flip] Received ac-close-window postMessage');
946 ipcRenderer.invoke('ac-close-window');
947 }
948 });
949
950 // Listen for navigate commands from main process (for new windows)
951 ipcRenderer.on('navigate', (event, url) => {
952 console.log('[flip] Received navigate command:', url);
953 if (url && webviewEl) {
954 webviewEl.src = url;
955 }
956 });
957
958 // Also try did-create-window (Electron 22+)
959 webviewEl.addEventListener('did-create-window', (e) => {
960 console.log('[flip] did-create-window event:', e);
961 });
962
963 // And will-navigate for debugging
964 webviewEl.addEventListener('will-navigate', (e) => {
965 console.log('[flip] will-navigate:', e?.url);
966 });
967
968 // Mark this as the main window and set initial piece
969 ipcRenderer.invoke('set-main-window');
970 ipcRenderer.invoke('set-current-piece', 'prompt');
971
972 volumeSlider.addEventListener('input', () => {
973 updateVolumeUI(volumeSlider.value);
974 syncMasterVolume();
975 });
976
977 // Container control button handlers
978 const startContainerBtn = document.getElementById('start-container-btn');
979 const stopContainerBtn = document.getElementById('stop-container-btn');
980 const welcomeScreen = document.getElementById('backend-welcome');
981
982 async function updateContainerButtons() {
983 const isRunning = await ipcRenderer.invoke('check-container');
984 startContainerBtn.disabled = isRunning;
985 stopContainerBtn.disabled = !isRunning;
986 }
987
988 // Show a big centered message in the terminal
989 function showTerminalMessage(message, color = '\x1b[35m') {
990 term.clear();
991 const rows = term.rows;
992 const cols = term.cols;
993 const midRow = Math.floor(rows / 2);
994 const padding = Math.floor((cols - message.length) / 2);
995
996 // Move to middle and print centered message
997 for (let i = 0; i < midRow - 1; i++) {
998 term.writeln('');
999 }
1000 term.writeln(color + ' '.repeat(Math.max(0, padding)) + message + '\x1b[0m');
1001 }
1002
1003 startContainerBtn.addEventListener('click', async () => {
1004 console.log('[flip] Start devcontainer requested');
1005 startContainerBtn.disabled = true;
1006 startContainerBtn.textContent = '⏳';
1007
1008 // Clear terminal and show starting message immediately
1009 welcomeScreen.classList.add('hidden');
1010 fitTerminal();
1011 showTerminalMessage('▶ STARTING...', '\x1b[35m\x1b[1m');
1012
1013 try {
1014 await ipcRenderer.invoke('start-flip-devcontainer');
1015 startContainerBtn.textContent = '▶';
1016 await updateContainerButtons();
1017 } catch (err) {
1018 console.error('[flip] Failed to start devcontainer:', err);
1019 showTerminalMessage('✗ START FAILED', '\x1b[31m\x1b[1m');
1020 startContainerBtn.textContent = '▶';
1021 startContainerBtn.disabled = false;
1022 }
1023 });
1024
1025 stopContainerBtn.addEventListener('click', async () => {
1026 console.log('[flip] Stop container requested (aggressive)');
1027 stopContainerBtn.disabled = true;
1028 stopContainerBtn.textContent = '⏳';
1029
1030 // Show stopping message immediately
1031 showTerminalMessage('⏹ STOPPING...', '\x1b[35m\x1b[1m');
1032
1033 try {
1034 await ipcRenderer.invoke('stop-container-aggressive');
1035 showTerminalMessage('⏹ STOPPED', '\x1b[90m');
1036 stopContainerBtn.textContent = '⏹';
1037 await updateContainerButtons();
1038 } catch (err) {
1039 console.error('[flip] Failed to stop container:', err);
1040 showTerminalMessage('✗ STOP FAILED', '\x1b[31m\x1b[1m');
1041 stopContainerBtn.textContent = '⏹';
1042 stopContainerBtn.disabled = false;
1043 }
1044 });
1045
1046 // Update button states periodically
1047 updateContainerButtons();
1048 setInterval(updateContainerButtons, 5000);
1049
1050 // Listen for global flip shortcut from main process
1051 ipcRenderer.on('toggle-flip', () => {
1052 toggle();
1053 });
1054
1055 // Listen for navigate messages (from new window requests)
1056 ipcRenderer.on('navigate', (event, url) => {
1057 console.log('[flip] Navigate to:', url);
1058 if (url && typeof url === 'string') {
1059 webviewEl.src = url;
1060 }
1061 });
1062
1063 // ========== Zoom Handling ==========
1064 let frontZoom = 1.0;
1065 let terminalFontSize = 10;
1066
1067 ipcRenderer.on('zoom-in', () => {
1068 if (showingTerminal) {
1069 // Zoom terminal by increasing font size
1070 terminalFontSize = Math.min(terminalFontSize + 2, 32);
1071 term.options.fontSize = terminalFontSize;
1072 fitTerminal();
1073 console.log('[flip] Terminal font size:', terminalFontSize);
1074 } else {
1075 // Zoom webview
1076 frontZoom = Math.min(frontZoom + 0.1, 3.0);
1077 webviewEl.setZoomFactor(frontZoom);
1078 console.log('[flip] Webview zoom:', frontZoom);
1079 }
1080 });
1081
1082 ipcRenderer.on('zoom-out', () => {
1083 if (showingTerminal) {
1084 // Zoom terminal by decreasing font size
1085 terminalFontSize = Math.max(terminalFontSize - 2, 6);
1086 term.options.fontSize = terminalFontSize;
1087 fitTerminal();
1088 console.log('[flip] Terminal font size:', terminalFontSize);
1089 } else {
1090 // Zoom webview
1091 frontZoom = Math.max(frontZoom - 0.1, 0.3);
1092 webviewEl.setZoomFactor(frontZoom);
1093 console.log('[flip] Webview zoom:', frontZoom);
1094 }
1095 });
1096
1097 ipcRenderer.on('zoom-reset', () => {
1098 if (showingTerminal) {
1099 terminalFontSize = 10;
1100 term.options.fontSize = terminalFontSize;
1101 fitTerminal();
1102 console.log('[flip] Terminal font size reset to:', terminalFontSize);
1103 } else {
1104 frontZoom = 1.0;
1105 webviewEl.setZoomFactor(frontZoom);
1106 console.log('[flip] Webview zoom reset to:', frontZoom);
1107 }
1108 });
1109
1110 // ========== Terminal Setup ==========
1111 if (isPaperWM) {
1112 // PaperWM: no terminal, no back face — just the webview
1113 var term = null, fitTerminal = () => {}, fitAddon = null;
1114 }
1115 const terminalContainer = !isPaperWM ? document.getElementById('terminal-container') : null;
1116
1117 if (!isPaperWM) {
1118 // Theme definitions for terminal
1119 const darkTermTheme = {
1120 background: '#0a0a12',
1121 foreground: '#eee',
1122 cursor: '#f0f',
1123 cursorAccent: '#0a0a12',
1124 selectionBackground: 'rgba(255, 0, 255, 0.3)',
1125 black: '#1a1a2e',
1126 red: '#ff5555',
1127 green: '#50fa7b',
1128 yellow: '#f1fa8c',
1129 blue: '#6272a4',
1130 magenta: '#ff79c6',
1131 cyan: '#8be9fd',
1132 white: '#f8f8f2',
1133 };
1134
1135 const lightTermTheme = {
1136 background: '#f5f0c0',
1137 foreground: '#281e5a',
1138 cursor: '#387adf',
1139 cursorAccent: '#f5f0c0',
1140 selectionBackground: 'rgba(56, 122, 223, 0.3)',
1141 black: '#281e5a',
1142 red: '#cc0000',
1143 green: '#006400',
1144 yellow: '#996600',
1145 blue: '#387adf',
1146 magenta: '#8844ff',
1147 cyan: '#0077aa',
1148 white: '#fcf7c5',
1149 };
1150
1151 // Detect current color scheme
1152 function isDarkMode() {
1153 return window.matchMedia('(prefers-color-scheme: dark)').matches;
1154 }
1155
1156 const term = new Terminal({
1157 cursorBlink: true,
1158 cursorStyle: 'block',
1159 fontSize: 10,
1160 fontFamily: "'Menlo', 'DejaVu Sans Mono', 'Consolas', 'Liberation Mono', monospace",
1161 theme: isDarkMode() ? darkTermTheme : lightTermTheme,
1162 allowTransparency: true,
1163 scrollback: 5000,
1164 fastScrollModifier: 'alt',
1165 fastScrollSensitivity: 5,
1166 drawBoldTextInBrightColors: true,
1167 });
1168
1169 // Listen for color scheme changes
1170 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1171 console.log('[flip] Color scheme changed to:', e.matches ? 'dark' : 'light');
1172 term.options.theme = e.matches ? darkTermTheme : lightTermTheme;
1173 });
1174
1175 const fitAddon = new FitAddon();
1176 term.loadAddon(fitAddon);
1177 term.open(terminalContainer);
1178
1179 // Load WebGL2 renderer for better performance
1180 try {
1181 const webglAddon = new WebglAddon();
1182 webglAddon.onContextLoss(() => {
1183 console.warn('[flip] WebGL context lost, disposing addon');
1184 webglAddon.dispose();
1185 });
1186 term.loadAddon(webglAddon);
1187 console.log('[flip] WebGL2 renderer loaded successfully');
1188 } catch (e) {
1189 console.warn('[flip] WebGL2 renderer failed to load, using canvas fallback:', e.message);
1190 }
1191
1192 // Fit terminal to container and send initial size to PTY
1193 function fitTerminal() {
1194 fitAddon.fit();
1195 console.log('[flip] Terminal fit:', term.cols, 'x', term.rows);
1196 ipcRenderer.send('flip-pty-resize', term.cols, term.rows);
1197 }
1198
1199 // Initial fit - wait for layout to stabilize, then fit multiple times
1200 function initialFit() {
1201 // Force container dimensions to be calculated
1202 terminalContainer.style.display = 'block';
1203 terminalContainer.offsetHeight; // Force reflow
1204
1205 fitTerminal();
1206 }
1207
1208 // Wait for window to fully load and layout to settle
1209 if (document.readyState === 'complete') {
1210 setTimeout(initialFit, 50);
1211 setTimeout(fitTerminal, 200);
1212 setTimeout(fitTerminal, 500);
1213 } else {
1214 window.addEventListener('load', () => {
1215 setTimeout(initialFit, 50);
1216 setTimeout(fitTerminal, 200);
1217 setTimeout(fitTerminal, 500);
1218 });
1219 }
1220
1221 // Also fit when the flip card becomes visible (showing backend)
1222 const flipObserver = new MutationObserver(() => {
1223 if (showingTerminal) {
1224 requestAnimationFrame(() => {
1225 fitTerminal();
1226 });
1227 }
1228 });
1229 flipObserver.observe(flipCard, { attributes: true, attributeFilter: ['style'] });
1230
1231 // Resize handling
1232 const resizeObserver = new ResizeObserver(() => {
1233 fitTerminal();
1234 });
1235 resizeObserver.observe(terminalContainer);
1236
1237 // PTY communication
1238 ipcRenderer.on('flip-pty-data', (event, data) => {
1239 term.write(data);
1240 });
1241
1242 // Respond to size requests from main process
1243 ipcRenderer.on('request-terminal-size', () => {
1244 // Force a fit and send current dimensions
1245 fitAddon.fit();
1246 ipcRenderer.send('flip-pty-resize', term.cols, term.rows);
1247 });
1248
1249 term.onData((data) => {
1250 ipcRenderer.send('flip-pty-input', data);
1251 });
1252
1253 // ========== Welcome Screen Logic ==========
1254 const infoRepo = document.getElementById('info-repo');
1255 const infoGitUser = document.getElementById('info-git-user');
1256 const infoDocker = document.getElementById('info-docker');
1257 const infoContainer = document.getElementById('info-container');
1258
1259 let systemInfo = {
1260 repoPath: null,
1261 gitUser: null,
1262 dockerAvailable: false,
1263 containerExists: false
1264 };
1265
1266 async function gatherSystemInfo() {
1267 // Get repo path
1268 try {
1269 const repo = await ipcRenderer.invoke('get-repo-path');
1270 if (repo) {
1271 systemInfo.repoPath = repo.path;
1272 infoRepo.textContent = repo.name;
1273 infoRepo.classList.remove('loading');
1274 infoRepo.classList.add('success');
1275 } else {
1276 infoRepo.textContent = 'Not found';
1277 infoRepo.classList.remove('loading');
1278 infoRepo.classList.add('warning');
1279 }
1280 } catch (e) {
1281 infoRepo.textContent = 'Error';
1282 infoRepo.classList.remove('loading');
1283 infoRepo.classList.add('error');
1284 }
1285
1286 // Get git user
1287 try {
1288 const gitUser = await ipcRenderer.invoke('get-git-user');
1289 if (gitUser && gitUser.name) {
1290 systemInfo.gitUser = gitUser;
1291 infoGitUser.textContent = gitUser.name;
1292 infoGitUser.classList.remove('loading');
1293 infoGitUser.classList.add('success');
1294 } else {
1295 infoGitUser.textContent = 'Not configured';
1296 infoGitUser.classList.remove('loading');
1297 infoGitUser.classList.add('warning');
1298 }
1299 } catch (e) {
1300 infoGitUser.textContent = 'Error';
1301 infoGitUser.classList.remove('loading');
1302 infoGitUser.classList.add('error');
1303 }
1304
1305 // Check Docker
1306 try {
1307 const dockerOk = await ipcRenderer.invoke('check-docker');
1308 systemInfo.dockerAvailable = dockerOk;
1309 if (dockerOk) {
1310 infoDocker.textContent = 'Running';
1311 infoDocker.classList.remove('loading');
1312 infoDocker.classList.add('success');
1313 } else {
1314 infoDocker.textContent = 'Not running';
1315 infoDocker.classList.remove('loading');
1316 infoDocker.classList.add('warning');
1317 }
1318 } catch (e) {
1319 infoDocker.textContent = 'Error';
1320 infoDocker.classList.remove('loading');
1321 infoDocker.classList.add('error');
1322 }
1323
1324 // Check container exists
1325 try {
1326 const containerOk = await ipcRenderer.invoke('check-container-exists');
1327 systemInfo.containerExists = containerOk;
1328 if (containerOk) {
1329 infoContainer.textContent = 'Ready';
1330 infoContainer.classList.remove('loading');
1331 infoContainer.classList.add('success');
1332 } else {
1333 infoContainer.textContent = 'Not found';
1334 infoContainer.classList.remove('loading');
1335 infoContainer.classList.add('warning');
1336 }
1337 } catch (e) {
1338 infoContainer.textContent = 'Error';
1339 infoContainer.classList.remove('loading');
1340 infoContainer.classList.add('error');
1341 }
1342 }
1343
1344 // Start gathering info immediately
1345 gatherSystemInfo();
1346 } // end if (!isPaperWM)
1347
1348 // ========== Toggle Logic ==========
1349 let rotationAngle = 0; // Track cumulative rotation
1350 let midflipTimeout = null;
1351 let swivelingTimeout = null;
1352
1353 function toggle(direction = 'right') {
1354 console.log('[flip] toggle() called, direction:', direction, 'rotation was:', rotationAngle);
1355
1356 // Clear any pending timeouts
1357 if (midflipTimeout) {
1358 clearTimeout(midflipTimeout);
1359 }
1360 if (swivelingTimeout) {
1361 clearTimeout(swivelingTimeout);
1362 }
1363
1364 // Show spinner cursor during swivel
1365 document.body.classList.add('swiveling');
1366
1367 // Always rotate 180deg in the specified direction (continuous spin)
1368 // Right tab = clockwise (positive), Left tab = counter-clockwise (negative)
1369 if (direction === 'right') {
1370 rotationAngle += 180;
1371 } else {
1372 rotationAngle -= 180;
1373 }
1374
1375 // Apply rotation directly to flip-card
1376 flipCard.style.transform = `rotateY(${rotationAngle}deg)`;
1377
1378 // Determine which side will be showing after flip completes
1379 const normalized = ((rotationAngle % 360) + 360) % 360;
1380 const willShowTerminal = (normalized > 90 && normalized < 270);
1381
1382 // Swap translucency when card is edge-on (90°)
1383 // With cubic-bezier(0.4, 0, 0.2, 1) over 700ms, 90° is reached around 260ms
1384 midflipTimeout = setTimeout(() => {
1385 showingTerminal = willShowTerminal;
1386 document.body.classList.toggle('midflip', showingTerminal);
1387 document.body.classList.toggle('flipped', showingTerminal);
1388 console.log('[flip] Edge-on reached - swapping translucency, showingTerminal:', showingTerminal);
1389 }, 260);
1390
1391 // Remove spinner cursor when swivel completes
1392 swivelingTimeout = setTimeout(() => {
1393 document.body.classList.remove('swiveling');
1394 }, 700);
1395
1396 console.log('[flip] Will show', willShowTerminal ? 'terminal' : 'webview', 'rotation:', rotationAngle, 'normalized:', normalized);
1397
1398 // After flip completes, fit terminal and focus if showing terminal
1399 if (willShowTerminal && term) {
1400 setTimeout(() => {
1401 fitTerminal();
1402 term.focus();
1403 }, 700);
1404 }
1405 }
1406
1407 // Flip tabs - click to flip, native drag to move window (PaperWM/Wayland compatible)
1408 // Inner elements have -webkit-app-region: drag for native window dragging
1409 console.log('[flip] Found flip tabs:', flipTabs.length);
1410 flipTabs.forEach(tab => {
1411 // Use mousedown/mouseup to detect clicks (click event doesn't fire with app-region children)
1412 let mouseDownTime = 0;
1413 let mouseDownPos = { x: 0, y: 0 };
1414
1415 tab.addEventListener('mousedown', (e) => {
1416 mouseDownTime = Date.now();
1417 mouseDownPos = { x: e.clientX, y: e.clientY };
1418 });
1419
1420 tab.addEventListener('mouseup', (e) => {
1421 const timeDiff = Date.now() - mouseDownTime;
1422 const moveDiff = Math.hypot(e.clientX - mouseDownPos.x, e.clientY - mouseDownPos.y);
1423
1424 // If mouse was down for less than 300ms and didn't move much, treat as click
1425 if (timeDiff < 300 && moveDiff < 10) {
1426 e.preventDefault();
1427 e.stopPropagation();
1428
1429 // Base direction on SCREEN position, not tab class
1430 const windowCenter = window.innerWidth / 2;
1431 const direction = (e.clientX > windowCenter) ? 'right' : 'left';
1432
1433 console.log('[flip] Flip tab clicked at x:', e.clientX, 'center:', windowCenter, 'direction:', direction);
1434 toggle(direction);
1435 }
1436 });
1437 });
1438
1439 // Keyboard shortcuts
1440 document.addEventListener('keydown', (e) => {
1441 // Tab to toggle flip
1442 if (e.key === 'Tab') {
1443 console.log('[flip] Tab key pressed');
1444 e.preventDefault();
1445 toggle();
1446 }
1447 // Cmd+Shift+I or Ctrl+Shift+I to open webview devtools (check both cases)
1448 if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
1449 e.preventDefault();
1450 console.log('[flip] Opening webview DevTools via keyboard');
1451 if (webviewEl.isDevToolsOpened()) {
1452 webviewEl.closeDevTools();
1453 } else {
1454 webviewEl.openDevTools();
1455 }
1456 }
1457 // F12 also opens devtools (Windows standard)
1458 if (e.key === 'F12') {
1459 e.preventDefault();
1460 console.log('[flip] Opening webview DevTools via F12');
1461 if (webviewEl.isDevToolsOpened()) {
1462 webviewEl.closeDevTools();
1463 } else {
1464 webviewEl.openDevTools();
1465 }
1466 }
1467 });
1468
1469 // Context menu for DevTools
1470 document.addEventListener('contextmenu', (e) => {
1471 // Show custom context menu
1472 e.preventDefault();
1473 showContextMenu(e.clientX, e.clientY);
1474 });
1475
1476 // Custom context menu
1477 function showContextMenu(x, y) {
1478 // Remove any existing menu
1479 const existing = document.getElementById('custom-context-menu');
1480 if (existing) existing.remove();
1481
1482 const menu = document.createElement('div');
1483 menu.id = 'custom-context-menu';
1484 menu.style.cssText = `
1485 position: fixed;
1486 left: ${x}px;
1487 top: ${y}px;
1488 background: rgba(30, 30, 40, 0.95);
1489 border: 1px solid var(--ac-border);
1490 border-radius: 6px;
1491 padding: 4px 0;
1492 min-width: 160px;
1493 box-shadow: 0 4px 16px rgba(0,0,0,0.4);
1494 z-index: 10000;
1495 font-size: 12px;
1496 backdrop-filter: blur(8px);
1497 `;
1498
1499 const menuItems = [
1500 { label: '🔧 Open DevTools', action: () => webviewEl.openDevTools() },
1501 { label: '🔄 Reload Page', action: () => webviewEl.reload() },
1502 { label: '📋 Copy URL', action: () => navigator.clipboard.writeText(webviewEl.src) },
1503 { type: 'separator' },
1504 { label: '🔊 Reset Volume', action: () => { updateVolumeUI(DEFAULT_MASTER_VOLUME); syncMasterVolume(); } },
1505 ];
1506
1507 menuItems.forEach(item => {
1508 if (item.type === 'separator') {
1509 const sep = document.createElement('div');
1510 sep.style.cssText = 'height: 1px; background: var(--ac-border); margin: 4px 8px;';
1511 menu.appendChild(sep);
1512 } else {
1513 const menuItem = document.createElement('div');
1514 menuItem.textContent = item.label;
1515 menuItem.style.cssText = `
1516 padding: 6px 12px;
1517 cursor: pointer;
1518 color: var(--ac-text);
1519 `;
1520 menuItem.addEventListener('mouseenter', () => {
1521 menuItem.style.background = 'var(--ac-border-hover)';
1522 });
1523 menuItem.addEventListener('mouseleave', () => {
1524 menuItem.style.background = 'transparent';
1525 });
1526 menuItem.addEventListener('click', () => {
1527 item.action();
1528 menu.remove();
1529 });
1530 menu.appendChild(menuItem);
1531 }
1532 });
1533
1534 document.body.appendChild(menu);
1535
1536 // Close menu on click outside
1537 const closeMenu = (e) => {
1538 if (!menu.contains(e.target)) {
1539 menu.remove();
1540 document.removeEventListener('click', closeMenu);
1541 }
1542 };
1543 setTimeout(() => document.addEventListener('click', closeMenu), 0);
1544 }
1545
1546 // Window resize
1547 window.addEventListener('resize', () => {
1548 fitAddon.fit();
1549 ipcRenderer.send('flip-pty-resize', term.cols, term.rows);
1550 });
1551
1552 // ========== Custom Resize Handles ==========
1553 const resizeOverlay = document.getElementById('resize-overlay');
1554
1555 document.querySelectorAll('[data-resize]').forEach(handle => {
1556 handle.addEventListener('mousedown', (e) => {
1557 e.preventDefault();
1558 e.stopPropagation();
1559 const direction = handle.dataset.resize;
1560
1561 // Get cursor style for this direction
1562 const cursorStyle = window.getComputedStyle(handle).cursor;
1563
1564 // Show overlay and set body class to disable pointer events on content
1565 resizeOverlay.style.cursor = cursorStyle;
1566 resizeOverlay.classList.add('active');
1567 document.body.classList.add('resizing');
1568
1569 const startX = e.screenX;
1570 const startY = e.screenY;
1571 const startBounds = {
1572 x: window.screenX,
1573 y: window.screenY,
1574 width: window.outerWidth,
1575 height: window.outerHeight
1576 };
1577
1578 const onMouseMove = (e) => {
1579 const dx = e.screenX - startX;
1580 const dy = e.screenY - startY;
1581
1582 let newX = startBounds.x;
1583 let newY = startBounds.y;
1584 let newWidth = startBounds.width;
1585 let newHeight = startBounds.height;
1586
1587 if (direction.includes('left')) {
1588 newX = startBounds.x + dx;
1589 newWidth = startBounds.width - dx;
1590 }
1591 if (direction.includes('right')) {
1592 newWidth = startBounds.width + dx;
1593 }
1594 if (direction.includes('top')) {
1595 newY = startBounds.y + dy;
1596 newHeight = startBounds.height - dy;
1597 }
1598 if (direction.includes('bottom')) {
1599 newHeight = startBounds.height + dy;
1600 }
1601
1602 // Minimum size
1603 if (newWidth < 400) { newWidth = 400; newX = startBounds.x + startBounds.width - 400; }
1604 if (newHeight < 300) { newHeight = 300; newY = startBounds.y + startBounds.height - 300; }
1605
1606 ipcRenderer.send('resize-window', { x: newX, y: newY, width: newWidth, height: newHeight });
1607 };
1608
1609 const onMouseUp = () => {
1610 // Hide overlay and restore pointer events
1611 resizeOverlay.classList.remove('active');
1612 document.body.classList.remove('resizing');
1613
1614 document.removeEventListener('mousemove', onMouseMove);
1615 document.removeEventListener('mouseup', onMouseUp);
1616 };
1617
1618 document.addEventListener('mousemove', onMouseMove);
1619 document.addEventListener('mouseup', onMouseUp);
1620 });
1621 });
1622
1623 // ========== Alt + Scroll to Drag Window ==========
1624 let scrollDragActive = false;
1625 let scrollDragStart = { x: 0, y: 0 };
1626
1627 document.addEventListener('wheel', (e) => {
1628 // Alt + scroll (two-finger drag with alt) moves the window
1629 if (e.altKey) {
1630 e.preventDefault();
1631
1632 // Use deltaX and deltaY to move window
1633 const currentX = window.screenX;
1634 const currentY = window.screenY;
1635
1636 // Invert and scale the delta for natural dragging feel
1637 const newX = currentX - e.deltaX;
1638 const newY = currentY - e.deltaY;
1639
1640 ipcRenderer.send('move-window', { x: Math.round(newX), y: Math.round(newY) });
1641 }
1642 }, { passive: false });
1643 </script>
1644</body>
1645</html>