Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

ac-electron: actually load the xbox controller bridge (file was orphaned)

The gamepad bridge added in 042e4f8ac landed in ac-electron/flip-view.html,
but main.js loads renderer/flip-view.html — two divergent copies have been
drifting since d21a8cd59. The "fix" was never running, so navigator.getGamepads()
inside the webview stayed empty in the installed Electron app.

- Port the host->guest gamepad bridge into renderer/flip-view.html (the
file main.js actually loads). The bridge polls navigator.getGamepads()
at the host BrowserWindow and forwards a snapshot via IPC, where
webview-preload.js patches navigator.getGamepads in the guest renderer.
- Grant sticky user activation to the host webContents on did-finish-load
via webContents.executeJavaScript('1', true). Chromium gates the Gamepad
API behind sticky activation; in a <webview>-host setup the user
typically clicks inside the webview and the host never receives one,
which made the bridge silently send empty snapshots even when a pad
was plugged in.
- Also grant the guest webview activation on dom-ready as a belt-and-
suspenders fallback so its native getGamepads works too.
- Delete the orphaned ac-electron/flip-view.html so future edits land
in the file that actually ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+74 -1690
-1689
ac-electron/flip-view.html
··· 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 - // 🎮 Gamepad bridge — host → guest. 959 - // Inside an Electron <webview>, navigator.getGamepads() in the guest 960 - // renderer often stays empty (the guest doesn't reliably receive the 961 - // user activation Chromium gates the API behind). Poll at the host, 962 - // which has activation as soon as the BrowserWindow is interacted 963 - // with, and forward a snapshot to the webview where webview-preload 964 - // surfaces it via a patched navigator.getGamepads. 965 - (function setupGamepadBridge() { 966 - let lastSig = ''; 967 - let logged = false; 968 - setInterval(() => { 969 - if (!webviewEl || !webviewReady) return; 970 - const pads = navigator.getGamepads ? navigator.getGamepads() : []; 971 - const snapshot = []; 972 - let any = false; 973 - for (let i = 0; i < pads.length; i++) { 974 - const g = pads[i]; 975 - if (!g) { snapshot.push(null); continue; } 976 - any = true; 977 - snapshot.push({ 978 - index: g.index, 979 - id: g.id, 980 - connected: g.connected, 981 - timestamp: g.timestamp, 982 - mapping: g.mapping, 983 - axes: Array.from(g.axes), 984 - buttons: Array.from(g.buttons).map((b) => ({ 985 - pressed: b.pressed, 986 - touched: b.touched, 987 - value: b.value, 988 - })), 989 - }); 990 - } 991 - const sig = JSON.stringify(snapshot); 992 - if (sig === lastSig) return; 993 - lastSig = sig; 994 - if (any && !logged) { 995 - console.log('[flip] gamepad bridge: forwarding host pad to webview', snapshot.find(Boolean)?.id); 996 - logged = true; 997 - } 998 - try { webviewEl.send('ac:gamepad-state', snapshot); } catch (_) {} 999 - }, 16); 1000 - })(); 1001 - 1002 - // Also try did-create-window (Electron 22+) 1003 - webviewEl.addEventListener('did-create-window', (e) => { 1004 - console.log('[flip] did-create-window event:', e); 1005 - }); 1006 - 1007 - // And will-navigate for debugging 1008 - webviewEl.addEventListener('will-navigate', (e) => { 1009 - console.log('[flip] will-navigate:', e?.url); 1010 - }); 1011 - 1012 - // Mark this as the main window and set initial piece 1013 - ipcRenderer.invoke('set-main-window'); 1014 - ipcRenderer.invoke('set-current-piece', 'prompt'); 1015 - 1016 - volumeSlider.addEventListener('input', () => { 1017 - updateVolumeUI(volumeSlider.value); 1018 - syncMasterVolume(); 1019 - }); 1020 - 1021 - // Container control button handlers 1022 - const startContainerBtn = document.getElementById('start-container-btn'); 1023 - const stopContainerBtn = document.getElementById('stop-container-btn'); 1024 - const welcomeScreen = document.getElementById('backend-welcome'); 1025 - 1026 - async function updateContainerButtons() { 1027 - const isRunning = await ipcRenderer.invoke('check-container'); 1028 - startContainerBtn.disabled = isRunning; 1029 - stopContainerBtn.disabled = !isRunning; 1030 - } 1031 - 1032 - // Show a big centered message in the terminal 1033 - function showTerminalMessage(message, color = '\x1b[35m') { 1034 - term.clear(); 1035 - const rows = term.rows; 1036 - const cols = term.cols; 1037 - const midRow = Math.floor(rows / 2); 1038 - const padding = Math.floor((cols - message.length) / 2); 1039 - 1040 - // Move to middle and print centered message 1041 - for (let i = 0; i < midRow - 1; i++) { 1042 - term.writeln(''); 1043 - } 1044 - term.writeln(color + ' '.repeat(Math.max(0, padding)) + message + '\x1b[0m'); 1045 - } 1046 - 1047 - startContainerBtn.addEventListener('click', async () => { 1048 - console.log('[flip] Start devcontainer requested'); 1049 - startContainerBtn.disabled = true; 1050 - startContainerBtn.textContent = '⏳'; 1051 - 1052 - // Clear terminal and show starting message immediately 1053 - welcomeScreen.classList.add('hidden'); 1054 - fitTerminal(); 1055 - showTerminalMessage('▶ STARTING...', '\x1b[35m\x1b[1m'); 1056 - 1057 - try { 1058 - await ipcRenderer.invoke('start-flip-devcontainer'); 1059 - startContainerBtn.textContent = '▶'; 1060 - await updateContainerButtons(); 1061 - } catch (err) { 1062 - console.error('[flip] Failed to start devcontainer:', err); 1063 - showTerminalMessage('✗ START FAILED', '\x1b[31m\x1b[1m'); 1064 - startContainerBtn.textContent = '▶'; 1065 - startContainerBtn.disabled = false; 1066 - } 1067 - }); 1068 - 1069 - stopContainerBtn.addEventListener('click', async () => { 1070 - console.log('[flip] Stop container requested (aggressive)'); 1071 - stopContainerBtn.disabled = true; 1072 - stopContainerBtn.textContent = '⏳'; 1073 - 1074 - // Show stopping message immediately 1075 - showTerminalMessage('⏹ STOPPING...', '\x1b[35m\x1b[1m'); 1076 - 1077 - try { 1078 - await ipcRenderer.invoke('stop-container-aggressive'); 1079 - showTerminalMessage('⏹ STOPPED', '\x1b[90m'); 1080 - stopContainerBtn.textContent = '⏹'; 1081 - await updateContainerButtons(); 1082 - } catch (err) { 1083 - console.error('[flip] Failed to stop container:', err); 1084 - showTerminalMessage('✗ STOP FAILED', '\x1b[31m\x1b[1m'); 1085 - stopContainerBtn.textContent = '⏹'; 1086 - stopContainerBtn.disabled = false; 1087 - } 1088 - }); 1089 - 1090 - // Update button states periodically 1091 - updateContainerButtons(); 1092 - setInterval(updateContainerButtons, 5000); 1093 - 1094 - // Listen for global flip shortcut from main process 1095 - ipcRenderer.on('toggle-flip', () => { 1096 - toggle(); 1097 - }); 1098 - 1099 - // Listen for navigate messages (from new window requests) 1100 - ipcRenderer.on('navigate', (event, url) => { 1101 - console.log('[flip] Navigate to:', url); 1102 - if (url && typeof url === 'string') { 1103 - webviewEl.src = url; 1104 - } 1105 - }); 1106 - 1107 - // ========== Zoom Handling ========== 1108 - let frontZoom = 1.0; 1109 - let terminalFontSize = 10; 1110 - 1111 - ipcRenderer.on('zoom-in', () => { 1112 - if (showingTerminal) { 1113 - // Zoom terminal by increasing font size 1114 - terminalFontSize = Math.min(terminalFontSize + 2, 32); 1115 - term.options.fontSize = terminalFontSize; 1116 - fitTerminal(); 1117 - console.log('[flip] Terminal font size:', terminalFontSize); 1118 - } else { 1119 - // Zoom webview 1120 - frontZoom = Math.min(frontZoom + 0.1, 3.0); 1121 - webviewEl.setZoomFactor(frontZoom); 1122 - console.log('[flip] Webview zoom:', frontZoom); 1123 - } 1124 - }); 1125 - 1126 - ipcRenderer.on('zoom-out', () => { 1127 - if (showingTerminal) { 1128 - // Zoom terminal by decreasing font size 1129 - terminalFontSize = Math.max(terminalFontSize - 2, 6); 1130 - term.options.fontSize = terminalFontSize; 1131 - fitTerminal(); 1132 - console.log('[flip] Terminal font size:', terminalFontSize); 1133 - } else { 1134 - // Zoom webview 1135 - frontZoom = Math.max(frontZoom - 0.1, 0.3); 1136 - webviewEl.setZoomFactor(frontZoom); 1137 - console.log('[flip] Webview zoom:', frontZoom); 1138 - } 1139 - }); 1140 - 1141 - ipcRenderer.on('zoom-reset', () => { 1142 - if (showingTerminal) { 1143 - terminalFontSize = 10; 1144 - term.options.fontSize = terminalFontSize; 1145 - fitTerminal(); 1146 - console.log('[flip] Terminal font size reset to:', terminalFontSize); 1147 - } else { 1148 - frontZoom = 1.0; 1149 - webviewEl.setZoomFactor(frontZoom); 1150 - console.log('[flip] Webview zoom reset to:', frontZoom); 1151 - } 1152 - }); 1153 - 1154 - // ========== Terminal Setup ========== 1155 - if (isPaperWM) { 1156 - // PaperWM: no terminal, no back face — just the webview 1157 - var term = null, fitTerminal = () => {}, fitAddon = null; 1158 - } 1159 - const terminalContainer = !isPaperWM ? document.getElementById('terminal-container') : null; 1160 - 1161 - if (!isPaperWM) { 1162 - // Theme definitions for terminal 1163 - const darkTermTheme = { 1164 - background: '#0a0a12', 1165 - foreground: '#eee', 1166 - cursor: '#f0f', 1167 - cursorAccent: '#0a0a12', 1168 - selectionBackground: 'rgba(255, 0, 255, 0.3)', 1169 - black: '#1a1a2e', 1170 - red: '#ff5555', 1171 - green: '#50fa7b', 1172 - yellow: '#f1fa8c', 1173 - blue: '#6272a4', 1174 - magenta: '#ff79c6', 1175 - cyan: '#8be9fd', 1176 - white: '#f8f8f2', 1177 - }; 1178 - 1179 - const lightTermTheme = { 1180 - background: '#f5f0c0', 1181 - foreground: '#281e5a', 1182 - cursor: '#387adf', 1183 - cursorAccent: '#f5f0c0', 1184 - selectionBackground: 'rgba(56, 122, 223, 0.3)', 1185 - black: '#281e5a', 1186 - red: '#cc0000', 1187 - green: '#006400', 1188 - yellow: '#996600', 1189 - blue: '#387adf', 1190 - magenta: '#8844ff', 1191 - cyan: '#0077aa', 1192 - white: '#fcf7c5', 1193 - }; 1194 - 1195 - // Detect current color scheme 1196 - function isDarkMode() { 1197 - return window.matchMedia('(prefers-color-scheme: dark)').matches; 1198 - } 1199 - 1200 - const term = new Terminal({ 1201 - cursorBlink: true, 1202 - cursorStyle: 'block', 1203 - fontSize: 10, 1204 - fontFamily: "'Menlo', 'DejaVu Sans Mono', 'Consolas', 'Liberation Mono', monospace", 1205 - theme: isDarkMode() ? darkTermTheme : lightTermTheme, 1206 - allowTransparency: true, 1207 - scrollback: 5000, 1208 - fastScrollModifier: 'alt', 1209 - fastScrollSensitivity: 5, 1210 - drawBoldTextInBrightColors: true, 1211 - }); 1212 - 1213 - // Listen for color scheme changes 1214 - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { 1215 - console.log('[flip] Color scheme changed to:', e.matches ? 'dark' : 'light'); 1216 - term.options.theme = e.matches ? darkTermTheme : lightTermTheme; 1217 - }); 1218 - 1219 - const fitAddon = new FitAddon(); 1220 - term.loadAddon(fitAddon); 1221 - term.open(terminalContainer); 1222 - 1223 - // Load WebGL2 renderer for better performance 1224 - try { 1225 - const webglAddon = new WebglAddon(); 1226 - webglAddon.onContextLoss(() => { 1227 - console.warn('[flip] WebGL context lost, disposing addon'); 1228 - webglAddon.dispose(); 1229 - }); 1230 - term.loadAddon(webglAddon); 1231 - console.log('[flip] WebGL2 renderer loaded successfully'); 1232 - } catch (e) { 1233 - console.warn('[flip] WebGL2 renderer failed to load, using canvas fallback:', e.message); 1234 - } 1235 - 1236 - // Fit terminal to container and send initial size to PTY 1237 - function fitTerminal() { 1238 - fitAddon.fit(); 1239 - console.log('[flip] Terminal fit:', term.cols, 'x', term.rows); 1240 - ipcRenderer.send('flip-pty-resize', term.cols, term.rows); 1241 - } 1242 - 1243 - // Initial fit - wait for layout to stabilize, then fit multiple times 1244 - function initialFit() { 1245 - // Force container dimensions to be calculated 1246 - terminalContainer.style.display = 'block'; 1247 - terminalContainer.offsetHeight; // Force reflow 1248 - 1249 - fitTerminal(); 1250 - } 1251 - 1252 - // Wait for window to fully load and layout to settle 1253 - if (document.readyState === 'complete') { 1254 - setTimeout(initialFit, 50); 1255 - setTimeout(fitTerminal, 200); 1256 - setTimeout(fitTerminal, 500); 1257 - } else { 1258 - window.addEventListener('load', () => { 1259 - setTimeout(initialFit, 50); 1260 - setTimeout(fitTerminal, 200); 1261 - setTimeout(fitTerminal, 500); 1262 - }); 1263 - } 1264 - 1265 - // Also fit when the flip card becomes visible (showing backend) 1266 - const flipObserver = new MutationObserver(() => { 1267 - if (showingTerminal) { 1268 - requestAnimationFrame(() => { 1269 - fitTerminal(); 1270 - }); 1271 - } 1272 - }); 1273 - flipObserver.observe(flipCard, { attributes: true, attributeFilter: ['style'] }); 1274 - 1275 - // Resize handling 1276 - const resizeObserver = new ResizeObserver(() => { 1277 - fitTerminal(); 1278 - }); 1279 - resizeObserver.observe(terminalContainer); 1280 - 1281 - // PTY communication 1282 - ipcRenderer.on('flip-pty-data', (event, data) => { 1283 - term.write(data); 1284 - }); 1285 - 1286 - // Respond to size requests from main process 1287 - ipcRenderer.on('request-terminal-size', () => { 1288 - // Force a fit and send current dimensions 1289 - fitAddon.fit(); 1290 - ipcRenderer.send('flip-pty-resize', term.cols, term.rows); 1291 - }); 1292 - 1293 - term.onData((data) => { 1294 - ipcRenderer.send('flip-pty-input', data); 1295 - }); 1296 - 1297 - // ========== Welcome Screen Logic ========== 1298 - const infoRepo = document.getElementById('info-repo'); 1299 - const infoGitUser = document.getElementById('info-git-user'); 1300 - const infoDocker = document.getElementById('info-docker'); 1301 - const infoContainer = document.getElementById('info-container'); 1302 - 1303 - let systemInfo = { 1304 - repoPath: null, 1305 - gitUser: null, 1306 - dockerAvailable: false, 1307 - containerExists: false 1308 - }; 1309 - 1310 - async function gatherSystemInfo() { 1311 - // Get repo path 1312 - try { 1313 - const repo = await ipcRenderer.invoke('get-repo-path'); 1314 - if (repo) { 1315 - systemInfo.repoPath = repo.path; 1316 - infoRepo.textContent = repo.name; 1317 - infoRepo.classList.remove('loading'); 1318 - infoRepo.classList.add('success'); 1319 - } else { 1320 - infoRepo.textContent = 'Not found'; 1321 - infoRepo.classList.remove('loading'); 1322 - infoRepo.classList.add('warning'); 1323 - } 1324 - } catch (e) { 1325 - infoRepo.textContent = 'Error'; 1326 - infoRepo.classList.remove('loading'); 1327 - infoRepo.classList.add('error'); 1328 - } 1329 - 1330 - // Get git user 1331 - try { 1332 - const gitUser = await ipcRenderer.invoke('get-git-user'); 1333 - if (gitUser && gitUser.name) { 1334 - systemInfo.gitUser = gitUser; 1335 - infoGitUser.textContent = gitUser.name; 1336 - infoGitUser.classList.remove('loading'); 1337 - infoGitUser.classList.add('success'); 1338 - } else { 1339 - infoGitUser.textContent = 'Not configured'; 1340 - infoGitUser.classList.remove('loading'); 1341 - infoGitUser.classList.add('warning'); 1342 - } 1343 - } catch (e) { 1344 - infoGitUser.textContent = 'Error'; 1345 - infoGitUser.classList.remove('loading'); 1346 - infoGitUser.classList.add('error'); 1347 - } 1348 - 1349 - // Check Docker 1350 - try { 1351 - const dockerOk = await ipcRenderer.invoke('check-docker'); 1352 - systemInfo.dockerAvailable = dockerOk; 1353 - if (dockerOk) { 1354 - infoDocker.textContent = 'Running'; 1355 - infoDocker.classList.remove('loading'); 1356 - infoDocker.classList.add('success'); 1357 - } else { 1358 - infoDocker.textContent = 'Not running'; 1359 - infoDocker.classList.remove('loading'); 1360 - infoDocker.classList.add('warning'); 1361 - } 1362 - } catch (e) { 1363 - infoDocker.textContent = 'Error'; 1364 - infoDocker.classList.remove('loading'); 1365 - infoDocker.classList.add('error'); 1366 - } 1367 - 1368 - // Check container exists 1369 - try { 1370 - const containerOk = await ipcRenderer.invoke('check-container-exists'); 1371 - systemInfo.containerExists = containerOk; 1372 - if (containerOk) { 1373 - infoContainer.textContent = 'Ready'; 1374 - infoContainer.classList.remove('loading'); 1375 - infoContainer.classList.add('success'); 1376 - } else { 1377 - infoContainer.textContent = 'Not found'; 1378 - infoContainer.classList.remove('loading'); 1379 - infoContainer.classList.add('warning'); 1380 - } 1381 - } catch (e) { 1382 - infoContainer.textContent = 'Error'; 1383 - infoContainer.classList.remove('loading'); 1384 - infoContainer.classList.add('error'); 1385 - } 1386 - } 1387 - 1388 - // Start gathering info immediately 1389 - gatherSystemInfo(); 1390 - } // end if (!isPaperWM) 1391 - 1392 - // ========== Toggle Logic ========== 1393 - let rotationAngle = 0; // Track cumulative rotation 1394 - let midflipTimeout = null; 1395 - let swivelingTimeout = null; 1396 - 1397 - function toggle(direction = 'right') { 1398 - console.log('[flip] toggle() called, direction:', direction, 'rotation was:', rotationAngle); 1399 - 1400 - // Clear any pending timeouts 1401 - if (midflipTimeout) { 1402 - clearTimeout(midflipTimeout); 1403 - } 1404 - if (swivelingTimeout) { 1405 - clearTimeout(swivelingTimeout); 1406 - } 1407 - 1408 - // Show spinner cursor during swivel 1409 - document.body.classList.add('swiveling'); 1410 - 1411 - // Always rotate 180deg in the specified direction (continuous spin) 1412 - // Right tab = clockwise (positive), Left tab = counter-clockwise (negative) 1413 - if (direction === 'right') { 1414 - rotationAngle += 180; 1415 - } else { 1416 - rotationAngle -= 180; 1417 - } 1418 - 1419 - // Apply rotation directly to flip-card 1420 - flipCard.style.transform = `rotateY(${rotationAngle}deg)`; 1421 - 1422 - // Determine which side will be showing after flip completes 1423 - const normalized = ((rotationAngle % 360) + 360) % 360; 1424 - const willShowTerminal = (normalized > 90 && normalized < 270); 1425 - 1426 - // Swap translucency when card is edge-on (90°) 1427 - // With cubic-bezier(0.4, 0, 0.2, 1) over 700ms, 90° is reached around 260ms 1428 - midflipTimeout = setTimeout(() => { 1429 - showingTerminal = willShowTerminal; 1430 - document.body.classList.toggle('midflip', showingTerminal); 1431 - document.body.classList.toggle('flipped', showingTerminal); 1432 - console.log('[flip] Edge-on reached - swapping translucency, showingTerminal:', showingTerminal); 1433 - }, 260); 1434 - 1435 - // Remove spinner cursor when swivel completes 1436 - swivelingTimeout = setTimeout(() => { 1437 - document.body.classList.remove('swiveling'); 1438 - }, 700); 1439 - 1440 - console.log('[flip] Will show', willShowTerminal ? 'terminal' : 'webview', 'rotation:', rotationAngle, 'normalized:', normalized); 1441 - 1442 - // After flip completes, fit terminal and focus if showing terminal 1443 - if (willShowTerminal && term) { 1444 - setTimeout(() => { 1445 - fitTerminal(); 1446 - term.focus(); 1447 - }, 700); 1448 - } 1449 - } 1450 - 1451 - // Flip tabs - click to flip, native drag to move window (PaperWM/Wayland compatible) 1452 - // Inner elements have -webkit-app-region: drag for native window dragging 1453 - console.log('[flip] Found flip tabs:', flipTabs.length); 1454 - flipTabs.forEach(tab => { 1455 - // Use mousedown/mouseup to detect clicks (click event doesn't fire with app-region children) 1456 - let mouseDownTime = 0; 1457 - let mouseDownPos = { x: 0, y: 0 }; 1458 - 1459 - tab.addEventListener('mousedown', (e) => { 1460 - mouseDownTime = Date.now(); 1461 - mouseDownPos = { x: e.clientX, y: e.clientY }; 1462 - }); 1463 - 1464 - tab.addEventListener('mouseup', (e) => { 1465 - const timeDiff = Date.now() - mouseDownTime; 1466 - const moveDiff = Math.hypot(e.clientX - mouseDownPos.x, e.clientY - mouseDownPos.y); 1467 - 1468 - // If mouse was down for less than 300ms and didn't move much, treat as click 1469 - if (timeDiff < 300 && moveDiff < 10) { 1470 - e.preventDefault(); 1471 - e.stopPropagation(); 1472 - 1473 - // Base direction on SCREEN position, not tab class 1474 - const windowCenter = window.innerWidth / 2; 1475 - const direction = (e.clientX > windowCenter) ? 'right' : 'left'; 1476 - 1477 - console.log('[flip] Flip tab clicked at x:', e.clientX, 'center:', windowCenter, 'direction:', direction); 1478 - toggle(direction); 1479 - } 1480 - }); 1481 - }); 1482 - 1483 - // Keyboard shortcuts 1484 - document.addEventListener('keydown', (e) => { 1485 - // Tab to toggle flip 1486 - if (e.key === 'Tab') { 1487 - console.log('[flip] Tab key pressed'); 1488 - e.preventDefault(); 1489 - toggle(); 1490 - } 1491 - // Cmd+Shift+I or Ctrl+Shift+I to open webview devtools (check both cases) 1492 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'i' || e.key === 'I')) { 1493 - e.preventDefault(); 1494 - console.log('[flip] Opening webview DevTools via keyboard'); 1495 - if (webviewEl.isDevToolsOpened()) { 1496 - webviewEl.closeDevTools(); 1497 - } else { 1498 - webviewEl.openDevTools(); 1499 - } 1500 - } 1501 - // F12 also opens devtools (Windows standard) 1502 - if (e.key === 'F12') { 1503 - e.preventDefault(); 1504 - console.log('[flip] Opening webview DevTools via F12'); 1505 - if (webviewEl.isDevToolsOpened()) { 1506 - webviewEl.closeDevTools(); 1507 - } else { 1508 - webviewEl.openDevTools(); 1509 - } 1510 - } 1511 - }); 1512 - 1513 - // Context menu for DevTools 1514 - document.addEventListener('contextmenu', (e) => { 1515 - // Show custom context menu 1516 - e.preventDefault(); 1517 - showContextMenu(e.clientX, e.clientY); 1518 - }); 1519 - 1520 - // Custom context menu 1521 - function showContextMenu(x, y) { 1522 - // Remove any existing menu 1523 - const existing = document.getElementById('custom-context-menu'); 1524 - if (existing) existing.remove(); 1525 - 1526 - const menu = document.createElement('div'); 1527 - menu.id = 'custom-context-menu'; 1528 - menu.style.cssText = ` 1529 - position: fixed; 1530 - left: ${x}px; 1531 - top: ${y}px; 1532 - background: rgba(30, 30, 40, 0.95); 1533 - border: 1px solid var(--ac-border); 1534 - border-radius: 6px; 1535 - padding: 4px 0; 1536 - min-width: 160px; 1537 - box-shadow: 0 4px 16px rgba(0,0,0,0.4); 1538 - z-index: 10000; 1539 - font-size: 12px; 1540 - backdrop-filter: blur(8px); 1541 - `; 1542 - 1543 - const menuItems = [ 1544 - { label: '🔧 Open DevTools', action: () => webviewEl.openDevTools() }, 1545 - { label: '🔄 Reload Page', action: () => webviewEl.reload() }, 1546 - { label: '📋 Copy URL', action: () => navigator.clipboard.writeText(webviewEl.src) }, 1547 - { type: 'separator' }, 1548 - { label: '🔊 Reset Volume', action: () => { updateVolumeUI(DEFAULT_MASTER_VOLUME); syncMasterVolume(); } }, 1549 - ]; 1550 - 1551 - menuItems.forEach(item => { 1552 - if (item.type === 'separator') { 1553 - const sep = document.createElement('div'); 1554 - sep.style.cssText = 'height: 1px; background: var(--ac-border); margin: 4px 8px;'; 1555 - menu.appendChild(sep); 1556 - } else { 1557 - const menuItem = document.createElement('div'); 1558 - menuItem.textContent = item.label; 1559 - menuItem.style.cssText = ` 1560 - padding: 6px 12px; 1561 - cursor: pointer; 1562 - color: var(--ac-text); 1563 - `; 1564 - menuItem.addEventListener('mouseenter', () => { 1565 - menuItem.style.background = 'var(--ac-border-hover)'; 1566 - }); 1567 - menuItem.addEventListener('mouseleave', () => { 1568 - menuItem.style.background = 'transparent'; 1569 - }); 1570 - menuItem.addEventListener('click', () => { 1571 - item.action(); 1572 - menu.remove(); 1573 - }); 1574 - menu.appendChild(menuItem); 1575 - } 1576 - }); 1577 - 1578 - document.body.appendChild(menu); 1579 - 1580 - // Close menu on click outside 1581 - const closeMenu = (e) => { 1582 - if (!menu.contains(e.target)) { 1583 - menu.remove(); 1584 - document.removeEventListener('click', closeMenu); 1585 - } 1586 - }; 1587 - setTimeout(() => document.addEventListener('click', closeMenu), 0); 1588 - } 1589 - 1590 - // Window resize 1591 - window.addEventListener('resize', () => { 1592 - fitAddon.fit(); 1593 - ipcRenderer.send('flip-pty-resize', term.cols, term.rows); 1594 - }); 1595 - 1596 - // ========== Custom Resize Handles ========== 1597 - const resizeOverlay = document.getElementById('resize-overlay'); 1598 - 1599 - document.querySelectorAll('[data-resize]').forEach(handle => { 1600 - handle.addEventListener('mousedown', (e) => { 1601 - e.preventDefault(); 1602 - e.stopPropagation(); 1603 - const direction = handle.dataset.resize; 1604 - 1605 - // Get cursor style for this direction 1606 - const cursorStyle = window.getComputedStyle(handle).cursor; 1607 - 1608 - // Show overlay and set body class to disable pointer events on content 1609 - resizeOverlay.style.cursor = cursorStyle; 1610 - resizeOverlay.classList.add('active'); 1611 - document.body.classList.add('resizing'); 1612 - 1613 - const startX = e.screenX; 1614 - const startY = e.screenY; 1615 - const startBounds = { 1616 - x: window.screenX, 1617 - y: window.screenY, 1618 - width: window.outerWidth, 1619 - height: window.outerHeight 1620 - }; 1621 - 1622 - const onMouseMove = (e) => { 1623 - const dx = e.screenX - startX; 1624 - const dy = e.screenY - startY; 1625 - 1626 - let newX = startBounds.x; 1627 - let newY = startBounds.y; 1628 - let newWidth = startBounds.width; 1629 - let newHeight = startBounds.height; 1630 - 1631 - if (direction.includes('left')) { 1632 - newX = startBounds.x + dx; 1633 - newWidth = startBounds.width - dx; 1634 - } 1635 - if (direction.includes('right')) { 1636 - newWidth = startBounds.width + dx; 1637 - } 1638 - if (direction.includes('top')) { 1639 - newY = startBounds.y + dy; 1640 - newHeight = startBounds.height - dy; 1641 - } 1642 - if (direction.includes('bottom')) { 1643 - newHeight = startBounds.height + dy; 1644 - } 1645 - 1646 - // Minimum size 1647 - if (newWidth < 400) { newWidth = 400; newX = startBounds.x + startBounds.width - 400; } 1648 - if (newHeight < 300) { newHeight = 300; newY = startBounds.y + startBounds.height - 300; } 1649 - 1650 - ipcRenderer.send('resize-window', { x: newX, y: newY, width: newWidth, height: newHeight }); 1651 - }; 1652 - 1653 - const onMouseUp = () => { 1654 - // Hide overlay and restore pointer events 1655 - resizeOverlay.classList.remove('active'); 1656 - document.body.classList.remove('resizing'); 1657 - 1658 - document.removeEventListener('mousemove', onMouseMove); 1659 - document.removeEventListener('mouseup', onMouseUp); 1660 - }; 1661 - 1662 - document.addEventListener('mousemove', onMouseMove); 1663 - document.addEventListener('mouseup', onMouseUp); 1664 - }); 1665 - }); 1666 - 1667 - // ========== Alt + Scroll to Drag Window ========== 1668 - let scrollDragActive = false; 1669 - let scrollDragStart = { x: 0, y: 0 }; 1670 - 1671 - document.addEventListener('wheel', (e) => { 1672 - // Alt + scroll (two-finger drag with alt) moves the window 1673 - if (e.altKey) { 1674 - e.preventDefault(); 1675 - 1676 - // Use deltaX and deltaY to move window 1677 - const currentX = window.screenX; 1678 - const currentY = window.screenY; 1679 - 1680 - // Invert and scale the delta for natural dragging feel 1681 - const newX = currentX - e.deltaX; 1682 - const newY = currentY - e.deltaY; 1683 - 1684 - ipcRenderer.send('move-window', { x: Math.round(newX), y: Math.round(newY) }); 1685 - } 1686 - }, { passive: false }); 1687 - </script> 1688 - </body> 1689 - </html>
+10 -1
ac-electron/main.js
··· 1577 1577 const win = new BrowserWindow(winOpts); 1578 1578 1579 1579 win.loadFile(getAppPath('renderer/flip-view.html'), isPaperWM ? { query: { wm: 'paper' } } : undefined); 1580 - 1580 + 1581 + // 🎮 Grant sticky user activation to the host page so navigator.getGamepads() 1582 + // works without the user clicking host chrome first. Chromium gates the 1583 + // Gamepad API behind sticky activation; in a <webview>-host setup the user 1584 + // typically clicks inside the webview and the host never receives one, 1585 + // which made the host→guest gamepad bridge silently send empty snapshots. 1586 + win.webContents.once('did-finish-load', () => { 1587 + win.webContents.executeJavaScript('1', true).catch(() => {}); 1588 + }); 1589 + 1581 1590 // Track it 1582 1591 const windowId = windowIdCounter++; 1583 1592 windows.set(windowId, { window: win, mode: 'ac-pane' });
+64
ac-electron/renderer/flip-view.html
··· 687 687 webviewEl.addEventListener('dom-ready', () => { 688 688 console.log('[volume] dom-ready - syncing volume') 689 689 webviewReady = true; 690 + // 🎮 Grant the guest renderer sticky user activation so its own 691 + // navigator.getGamepads() returns real pads. Chromium gates the 692 + // Gamepad API behind sticky activation; without this, pads are 693 + // invisible until the user clicks inside the webview. 694 + try { webviewEl.executeJavaScript('1', true); } catch (_) {} 690 695 if (pendingMasterVolume !== null) { 691 696 appliedMasterVolume = clampVolume(pendingMasterVolume); 692 697 sendMasterVolume(appliedMasterVolume); ··· 702 707 syncMasterVolume(); 703 708 }, 5000); 704 709 }); 710 + 711 + // 🎮 Gamepad bridge — host → guest. 712 + // Inside an Electron <webview>, navigator.getGamepads() in the guest 713 + // renderer often stays empty (the guest doesn't reliably receive the 714 + // user activation Chromium gates the API behind). Poll at the host, 715 + // which has activation as soon as the BrowserWindow is interacted 716 + // with (or via the executeJavaScript('1', true) call from main.js), 717 + // and forward a snapshot to the webview where webview-preload.js 718 + // surfaces it via a patched navigator.getGamepads. 719 + (function setupGamepadBridge() { 720 + let lastSig = ''; 721 + let loggedSeen = false; 722 + let loggedConnect = new Set(); 723 + window.addEventListener('gamepadconnected', (e) => { 724 + console.log('[flip] gamepadconnected at host:', e.gamepad?.id, 'index', e.gamepad?.index); 725 + }); 726 + window.addEventListener('gamepaddisconnected', (e) => { 727 + console.log('[flip] gamepaddisconnected at host:', e.gamepad?.id, 'index', e.gamepad?.index); 728 + }); 729 + setInterval(() => { 730 + if (!webviewEl || !webviewReady) return; 731 + const pads = navigator.getGamepads ? navigator.getGamepads() : []; 732 + const snapshot = []; 733 + let any = false; 734 + for (let i = 0; i < pads.length; i++) { 735 + const g = pads[i]; 736 + if (!g) { snapshot.push(null); continue; } 737 + any = true; 738 + snapshot.push({ 739 + index: g.index, 740 + id: g.id, 741 + connected: g.connected, 742 + timestamp: g.timestamp, 743 + mapping: g.mapping, 744 + axes: Array.from(g.axes), 745 + buttons: Array.from(g.buttons).map((b) => ({ 746 + pressed: b.pressed, 747 + touched: b.touched, 748 + value: b.value, 749 + })), 750 + }); 751 + } 752 + const sig = JSON.stringify(snapshot); 753 + if (sig === lastSig) return; 754 + lastSig = sig; 755 + if (any && !loggedSeen) { 756 + loggedSeen = true; 757 + const first = snapshot.find(Boolean); 758 + console.log('[flip] gamepad bridge: host detected pad, forwarding to webview:', first?.id); 759 + } 760 + for (const p of snapshot) { 761 + if (p && !loggedConnect.has(p.index)) { 762 + loggedConnect.add(p.index); 763 + console.log('[flip] gamepad bridge: forwarding new pad index', p.index, p.id); 764 + } 765 + } 766 + try { webviewEl.send('ac:gamepad-state', snapshot); } catch (_) {} 767 + }, 16); 768 + })(); 705 769 706 770 // Handle IPC from main process to open devtools 707 771 ipcRenderer.on('open-devtools', () => {