Download index.html from Sebebeb/EZAI: direct link, hf CLI and curl.
- Browser
- Download file 107 kB
-
https://huggingface.co/spaces/Sebebeb/EZAI/resolve/main/index.html
- Command line
-
hf download hf://spaces/Sebebeb/EZAI/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Sebebeb/EZAI/resolve/main/index.html
107 kB
| <html lang="en" data-canvasui-hover-rules=""><head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="color-scheme" content="dark"> | |
| <title>EZ.AI — Neural Interface</title> | |
| <link rel="icon" href="logo.png" type="image/png"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin=""> | |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ============================================================ | |
| EZ.AI — futuristic dark theme, powered by Canvas UI | |
| (HexFloat background) | |
| ============================================================ */ | |
| * { box-sizing: border-box; } | |
| :root { | |
| --bg0: #08090a; | |
| --panel: rgba(20, 20, 22, 0.72); | |
| --line: rgba(170, 175, 185, 0.18); | |
| --txt: #e8ecf8; | |
| --muted: #8b93a7; | |
| --cyan: #c9ced9; | |
| --violet: #8b91a5; | |
| --magenta: #5b6178; | |
| /* Peel defaults */ | |
| --peel-reveal: 300px; | |
| --peel-zone: 200px; | |
| --peel-smoothing: 0.3; | |
| --peel-translate: 0px; | |
| --peel-progress: 0; | |
| --peel-curl: 300; | |
| --peel-bow: 75; | |
| --peel-shade: 0.25; | |
| --peel-shine: 1; | |
| --peel-shine-distance: 1200; | |
| --peel-bulge: 50; | |
| --peel-perspective: 2000; | |
| } | |
| html, body { height: 100%; } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: "Space Grotesk", system-ui, -apple-system, sans-serif; | |
| background: var(--bg0); | |
| color: var(--txt); | |
| -webkit-font-smoothing: antialiased; | |
| text-rendering: optimizeLegibility; | |
| } | |
| ::selection { background: rgba(201, 206, 217, 0.35); color: #fff; } | |
| *::-webkit-scrollbar { width: 9px; height: 9px; } | |
| *::-webkit-scrollbar-track { background: transparent; } | |
| *::-webkit-scrollbar-thumb { | |
| background: linear-gradient(180deg, rgba(201, 206, 217,.35), rgba(139, 145, 165,.35)); | |
| border-radius: 99px; | |
| border: 2px solid transparent; | |
| background-clip: padding-box; | |
| } | |
| *::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, rgba(201, 206, 217,.6), rgba(139, 145, 165,.6)); border: 2px solid transparent; background-clip: padding-box; } | |
| :focus-visible { outline: 2px solid rgba(201, 206, 217,.7); outline-offset: 2px; } | |
| /* ---------- background FX — the only thing HexFloat textures ---------- */ | |
| #bgFX { | |
| position: absolute; inset: 0; overflow: hidden; | |
| background: | |
| radial-gradient(1200px 800px at 18% -10%, rgba(201, 206, 217,.10), transparent 55%), | |
| radial-gradient(1000px 700px at 85% 110%, rgba(139, 145, 165,.13), transparent 55%), | |
| radial-gradient(700px 520px at 52% 42%, rgba(91, 97, 120,.05), transparent 60%), | |
| var(--bg0); | |
| } | |
| #bgFX::before { | |
| content: ""; position: absolute; inset: 0; | |
| background-image: | |
| linear-gradient(rgba(120,160,255,.05) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(120,160,255,.05) 1px, transparent 1px); | |
| background-size: 44px 44px; | |
| -webkit-mask-image: radial-gradient(ellipse at 50% 32%, #000 30%, transparent 78%); | |
| mask-image: radial-gradient(ellipse at 50% 32%, #000 30%, transparent 78%); | |
| } | |
| #bgFX::after { | |
| content: ""; position: absolute; inset: 0; | |
| background: repeating-linear-gradient(0deg, rgba(255,255,255,.022) 0 1px, transparent 1px 3px); | |
| mix-blend-mode: overlay; | |
| opacity: .8; | |
| } | |
| #vignette { | |
| position: fixed; inset: 0; z-index: 1; pointer-events: none; | |
| background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%); | |
| } | |
| /* ---------- sidebar ---------- */ | |
| #sidebarPanel { | |
| position: fixed; top: 0; left: 0; bottom: 0; width: 300px; z-index: 1; | |
| display: flex; flex-direction: column; gap: 12px; padding: 14px 12px; | |
| background: linear-gradient(160deg, rgba(22, 22, 24,.88), rgba(14, 14, 16,.9)); | |
| border-right: 1px solid rgba(170, 175, 185,.16); | |
| backdrop-filter: blur(22px) saturate(150%); | |
| box-shadow: inset -1px 0 0 rgba(255,255,255,.04), 24px 0 70px -32px rgba(201, 206, 217,.22); | |
| } | |
| /* ---------- peel reveal (left edge) ---------- */ | |
| /* Canvas UI–style peel: curled handle that peels back to reveal sidebar */ | |
| #peel { | |
| /* hidden: peel activated by hovering left edge of mainArea, no visible tab */ | |
| position: fixed; left: 0; top: 0; width: 0; height: 0; z-index: 0; pointer-events: none; opacity: 0; | |
| } | |
| /* peel pseudo-elements intentionally hidden in this integration */ | |
| #peel::before, | |
| #peel::after { display: none; } | |
| /* peel-open is used by JS to indicate active state; clip-path/peelFlap handles visuals */ | |
| body.peel-open #mainArea { } | |
| body.peel-open #chatWindow { pointer-events: none; } | |
| .sidebar-logo { display: flex; align-items: center; gap: 11px; padding: 4px 8px 6px; } | |
| .sidebar-logo img { | |
| width: 46px; height: 46px; object-fit: contain; border-radius: 12px; | |
| filter: brightness(155%) drop-shadow(0 0 12px rgba(201, 206, 217,.65)); | |
| background: rgba(255,255,255,.04); padding: 5px; | |
| } | |
| .sidebar-logo .name { | |
| font-family: "Orbitron"; font-size: 17px; font-weight: 700; letter-spacing: .14em; | |
| background: linear-gradient(90deg, #c9ced9, #8b91a5); | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| } | |
| .sidebar-logo .tag { font-size: 9.5px; color: #6f7890; letter-spacing: .26em; text-transform: uppercase; margin-top: 2px; } | |
| #newChatBtn { | |
| position: relative; display: flex; align-items: center; gap: 9px; overflow: hidden; | |
| padding: 11px 14px; border: 1px solid rgba(170, 175, 185,.24); border-radius: 12px; | |
| cursor: pointer; font: 600 13px/1 "Space Grotesk"; color: #eaf2ff; | |
| background: linear-gradient(135deg, rgba(201, 206, 217,.16), rgba(139, 145, 165,.16)); | |
| transition: transform .18s, box-shadow .18s, border-color .18s; | |
| } | |
| #newChatBtn:hover { border-color: rgba(201, 206, 217,.55); box-shadow: 0 0 22px -5px rgba(201, 206, 217,.55); transform: translateY(-1px); } | |
| #newChatBtn::after { | |
| content: ""; position: absolute; inset: 0; | |
| background: linear-gradient(120deg, transparent 30%, rgba(255,255,255,.14) 50%, transparent 70%); | |
| transform: translateX(-120%); | |
| } | |
| #newChatBtn:hover::after { animation: sheen .85s ease; } | |
| @keyframes sheen { to { transform: translateX(120%); } } | |
| #chatList { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 2px; } | |
| .chat-item { | |
| display: flex; align-items: center; gap: 9px; padding: 10px 12px; border-radius: 11px; | |
| cursor: pointer; user-select: none; font-size: 13px; color: #c9d2e6; | |
| background: rgba(255,255,255,.035); border: 1px solid transparent; | |
| transition: background .16s, border-color .16s, color .16s, box-shadow .16s; | |
| white-space: nowrap; overflow: hidden; | |
| } | |
| .chat-item .dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px var(--cyan); } | |
| .chat-item .txt { overflow: hidden; text-overflow: ellipsis; } | |
| .chat-item:hover { | |
| background: rgba(201, 206, 217,.1); color: #fff; border-color: rgba(201, 206, 217,.28); | |
| box-shadow: 0 0 16px -6px rgba(201, 206, 217,.6); | |
| } | |
| .sidebar-foot { padding: 10px 8px 4px; border-top: 1px solid rgba(170, 175, 185,.12); } | |
| .sidebar-foot .hint { font-size: 10.5px; color: #7c85a0; line-height: 1.6; } | |
| .sidebar-foot .hint b { color: #aeb9d8; font-weight: 600; } | |
| /* ---------- main area · hex float bg + real peel ---------- */ | |
| /* mainArea sits above the sidebar (z-index) so, at rest, its content | |
| fully covers it — the peel engine's own canvas alpha is what reveals | |
| the sidebar underneath once the real Peel takes over rendering. */ | |
| #mainArea { position: fixed; inset: 0; z-index: 3; overflow: hidden; } | |
| /* peelContent is the actual "sheet" — once html-in-canvas is confirmed, | |
| this whole element gets MOVED (not copied) into the peel engine's | |
| hidden source canvas, exactly like #hexContent already does for | |
| HexFloat. Until then it just displays normally. */ | |
| #peelContent { position: relative; width: 100%; height: 100%; } | |
| #hexStage { position: absolute; inset: 0; z-index: 0; } | |
| #hexContent { position: relative; width: 100%; height: 100%; overflow: hidden; } | |
| /* Peel flap overlay — only used by the CSS fallback when html-in-canvas | |
| isn't supported; the real WebGL peel renders entirely to its own | |
| output canvas and this stays hidden. */ | |
| #peelFlap { | |
| position: absolute; left: 0; top: 0; bottom: 0; width: var(--peel-translate, 0px); z-index: 4; | |
| pointer-events: none; transform-origin: left center; backface-visibility: hidden; | |
| background: linear-gradient(90deg, rgba(8,10,16,0.98), rgba(20,24,36,0.94)); | |
| box-shadow: 0 40px 80px -40px rgba(0,0,0,0.8), inset -1px 0 0 rgba(255,255,255,0.02); | |
| transition: transform 160ms ease, width 160ms ease, opacity 160ms ease; | |
| opacity: 0; | |
| } | |
| #peelFlap .flap-inner { position: absolute; inset: 0; overflow: hidden; background: linear-gradient(90deg, rgba(16,22,36,0.98), rgba(30,36,56,0.96)); } | |
| #peelFlap .flap-shade { position: absolute; inset: 0; pointer-events: none; mix-blend-mode: multiply; background: linear-gradient(90deg, rgba(0,0,0,0.0), rgba(0,0,0,0.6)); opacity: var(--peel-shade); } | |
| #peelFlap .flap-shine { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; mix-blend-mode: screen; opacity: 0; transition: opacity 120ms linear; } | |
| /* plain, fully-interactive UI layer — sits above the hex canvas, | |
| completely outside the HexFloat html-in-canvas pipeline. It still | |
| gets carried along as part of #peelContent when the Peel engine | |
| moves things into its own source canvas. */ | |
| #uiLayer { position: absolute; inset: 0; z-index: 1; overflow: hidden; pointer-events: none; } | |
| /* ...but empty space should still let the mouse reach the hex bg below, | |
| so only the actual controls opt back in to receiving pointer events */ | |
| #uiLayer .neon-input, | |
| #uiLayer .send-btn, | |
| #uiLayer .chip, | |
| #uiLayer .hbtn, | |
| #uiLayer #chatWindow { pointer-events: auto; } | |
| /* ---------- home screen ---------- */ | |
| #homeScreen { | |
| display: none; min-height: 100%; | |
| flex-direction: column; align-items: center; justify-content: center; | |
| text-align: center; gap: 20px; padding: 44px 26px; | |
| overflow-y: auto; height: 100%; | |
| } | |
| #homeScreen.active { display: flex; } | |
| .hero-chip { | |
| display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; | |
| border-radius: 999px; border: 1px solid rgba(201, 206, 217,.35); | |
| background: rgba(201, 206, 217,.08); color: #bfe9ff; | |
| font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; | |
| } | |
| .hero-chip .pulse-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px var(--cyan); animation: blink2 1.4s ease-in-out infinite; } | |
| @keyframes blink2 { 0%,100% { opacity: 1; } 50% { opacity: .2; } } | |
| .hero-logo { position: relative; width: 112px; height: 112px; border-radius: 32px; padding: 12px; } | |
| .hero-logo img { width: 100%; height: 100%; object-fit: contain; filter: brightness(155%) drop-shadow(0 0 14px rgba(201, 206, 217,.85)); } | |
| .hero-logo::before { | |
| content: ""; position: absolute; inset: -2px; border-radius: 34px; | |
| border: 1px solid rgba(201, 206, 217,.55); animation: pulse 3s ease-in-out infinite; | |
| } | |
| @keyframes pulse { 0%,100% { opacity: .4; transform: scale(1); } 50% { opacity: .95; transform: scale(1.07); } } | |
| .hero-title { | |
| font-family: "Orbitron"; font-weight: 900; letter-spacing: .14em; | |
| font-size: clamp(42px, 6vw, 74px); margin: 0; | |
| background: linear-gradient(100deg, #c9ced9 0%, #8b91a5 45%, #5b6178 85%); | |
| -webkit-background-clip: text; background-clip: text; color: transparent; | |
| filter: drop-shadow(0 0 26px rgba(139, 145, 165,.45)); | |
| } | |
| .hero-sub { margin: 0; color: #9aa6c6; font-size: 15px; letter-spacing: .02em; } | |
| .hero-inputwrap { display: flex; align-items: flex-end; gap: 10px; width: 100%; max-width: 700px; margin-top: 8px; } | |
| textarea.neon-input { | |
| flex: 1; height: 56px; max-height: 160px; resize: none; | |
| padding: 16px 18px; border-radius: 18px; color: #eaf2ff; | |
| font: 15px/1.5 "Space Grotesk"; | |
| background: rgba(20, 20, 22,.72); border: 1px solid rgba(170, 175, 185,.22); | |
| outline: none; backdrop-filter: blur(14px); | |
| box-shadow: 0 18px 50px -26px rgba(0,0,0,.95), inset 0 1px 0 rgba(255,255,255,.05); | |
| transition: border-color .2s, box-shadow .2s; | |
| } | |
| textarea.neon-input:focus { border-color: rgba(201, 206, 217,.6); box-shadow: 0 0 0 3px rgba(201, 206, 217,.15), 0 18px 50px -26px rgba(0,0,0,.95); } | |
| textarea.neon-input::placeholder { color: #5d6782; } | |
| /* hide textarea scrollbars while keeping long input scrollable */ | |
| textarea.neon-input { scrollbar-width: none; -ms-overflow-style: none; } | |
| textarea.neon-input::-webkit-scrollbar { display: none; } | |
| .send-btn { | |
| flex: none; width: 56px; height: 56px; border-radius: 16px; border: none; cursor: pointer; | |
| display: grid; place-items: center; color: #fff; | |
| background: linear-gradient(135deg, #48b7a5, #3a77a5 62%, #142293); | |
| box-shadow: 0 10px 30px -8px rgba(139, 145, 165,.75), inset 0 1px 0 rgba(255,255,255,.4); | |
| transition: transform .18s, filter .18s; | |
| } | |
| .send-btn:hover { transform: translateY(-2px) scale(1.04); filter: brightness(1.14); } | |
| .send-btn:active { transform: translateY(0) scale(.96); } | |
| .chips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 6px; } | |
| .chip { | |
| padding: 9px 15px; border-radius: 999px; cursor: pointer; font-size: 12.5px; color: #b6c0da; | |
| border: 1px solid rgba(170, 175, 185,.2); background: rgba(255,255,255,.04); transition: .18s; | |
| } | |
| .chip:hover { | |
| border-color: rgba(201, 206, 217,.5); color: #eaf2ff; background: rgba(201, 206, 217,.1); | |
| box-shadow: 0 0 18px -6px rgba(201, 206, 217,.6); | |
| } | |
| /* ---------- chat view ---------- */ | |
| #chatView { display: none; height: 100%; flex-direction: column; } | |
| #chatView.active { display: flex; } | |
| .chat-header { | |
| flex: none; display: flex; align-items: center; gap: 12px; | |
| padding: 13px 20px; border-bottom: 1px solid rgba(170, 175, 185,.14); | |
| background: rgba(14, 14, 16,.55); backdrop-filter: blur(16px); | |
| } | |
| .hbtn { | |
| width: 38px; height: 38px; border-radius: 11px; cursor: pointer; display: grid; place-items: center; | |
| border: 1px solid rgba(170, 175, 185,.2); background: rgba(255,255,255,.04); | |
| color: #cfd8ee; transition: .16s; | |
| } | |
| .hbtn:hover { border-color: rgba(201, 206, 217,.5); color: #fff; box-shadow: 0 0 16px -4px rgba(201, 206, 217,.6); } | |
| #chatTitle { flex: 1; font: 600 15px "Space Grotesk"; color: #e6ecff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .chat-status { | |
| display: inline-flex; align-items: center; gap: 6px; flex: none; | |
| font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: #8fd9ff; | |
| padding: 5px 11px; border-radius: 999px; border: 1px solid rgba(201, 206, 217,.3); background: rgba(201, 206, 217,.08); | |
| } | |
| #chatWindow { | |
| flex: 1; overflow-y: auto; padding: 26px 22px 10px; | |
| display: flex; flex-direction: column; gap: 18px; | |
| } | |
| .user-bubble { | |
| align-self: flex-end; max-width: min(620px, 82%); | |
| padding: 12px 16px; border-radius: 18px 18px 6px 18px; | |
| background: #145893; | |
| color: #fff; font-size: 14.5px; line-height: 1.5; | |
| box-shadow: 0 12px 30px -12px rgba(139, 145, 165,.75); | |
| animation: rise .25s ease-out; white-space: pre-wrap; word-wrap: break-word; | |
| } | |
| .bot-row { display: flex; gap: 12px; align-items: flex-start; } | |
| .bot-avatar { | |
| flex: none; width: 34px; height: 34px; border-radius: 10px; margin-top: 2px; | |
| display: grid; place-items: center; font: 700 13px "Orbitron"; color: #fff; | |
| background: linear-gradient(135deg, #82858b, #454852); | |
| box-shadow: 0 0 16px -4px rgba(201, 206, 217,.75); | |
| } | |
| .bot-message { | |
| flex: 1; min-width: 0; padding: 14px 16px; | |
| border-radius: 4px 18px 18px 18px; | |
| border: 1px solid rgba(170, 175, 185,.16); background: rgba(20, 20, 22,.6); | |
| backdrop-filter: blur(12px); box-shadow: inset 0 1px 0 rgba(255,255,255,.04); | |
| animation: rise .25s ease-out; | |
| } | |
| @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } | |
| .typing-dots { display: flex; gap: 6px; padding: 8px 2px; } | |
| .typing-dots span { | |
| width: 8px; height: 8px; border-radius: 50%; | |
| background: linear-gradient(135deg, #c9ced9, #8b91a5); | |
| animation: bounce 1.2s ease-in-out infinite; | |
| } | |
| .typing-dots span:nth-child(2) { animation-delay: .15s; } | |
| .typing-dots span:nth-child(3) { animation-delay: .3s; } | |
| @keyframes bounce { 0%,60%,100% { transform: translateY(0); opacity: .45; } 30% { transform: translateY(-6px); opacity: 1; } } | |
| /* markdown */ | |
| .markdown-body { font-size: 14.5px; line-height: 1.65; color: #dbe2f4; } | |
| .markdown-body > :first-child { margin-top: 0; } | |
| .markdown-body > :last-child { margin-bottom: 0; } | |
| .markdown-body p { margin: .55em 0; } | |
| .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { color: #fff; margin: .9em 0 .45em; line-height: 1.3; } | |
| .markdown-body pre { | |
| position: relative; background: #05080f; border: 1px solid rgba(201, 206, 217,.18); | |
| border-radius: 12px; padding: 14px 46px 14px 14px; overflow-x: auto; white-space: pre; | |
| } | |
| .markdown-body code { font-family: "SF Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } | |
| .markdown-body :not(pre) > code { | |
| background: rgba(201, 206, 217,.12); color: #9fe8ff; padding: 2px 6px; border-radius: 6px; font-size: .9em; | |
| } | |
| .markdown-body a { color: #b8bcc8; } | |
| .markdown-body table { border-collapse: collapse; width: 100%; } | |
| .markdown-body th, .markdown-body td { border: 1px solid rgba(170, 175, 185,.25); padding: 6px 10px; } | |
| .markdown-body blockquote { border-left: 3px solid #8b91a5; margin: .6em 0 .6em 0; padding-left: 12px; color: #aab4d0; } | |
| .markdown-body ul, .markdown-body ol { padding-left: 1.4em; } | |
| .copy-btn { | |
| position: absolute; top: 8px; right: 8px; background: rgba(10,16,28,.85); color: #b9c5e4; | |
| border: 1px solid rgba(170, 175, 185,.25); border-radius: 8px; font-size: 11px; padding: 4px 8px; | |
| cursor: pointer; opacity: .8; transition: .15s; | |
| } | |
| .copy-btn:hover { opacity: 1; background: rgba(201, 206, 217,.15); color: #fff; } | |
| #inputBar { | |
| flex: none; display: flex; align-items: flex-end; gap: 10px; | |
| padding: 14px 20px 18px; | |
| } | |
| #inputBar textarea { flex: 1; } | |
| /* ---------- context menu ---------- */ | |
| #ctxMenu { | |
| position: absolute; z-index: 1000; min-width: 132px; padding: 6px; | |
| border-radius: 12px; border: 1px solid rgba(170, 175, 185,.25); | |
| background: rgba(20, 20, 22,.94); backdrop-filter: blur(18px); | |
| box-shadow: 0 18px 40px -12px rgba(0,0,0,.85); | |
| } | |
| #ctxMenu div { padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13px; color: #cfd8ee; transition: .12s; } | |
| #ctxMenu div:hover { background: rgba(201, 206, 217,.12); color: #fff; } | |
| #ctxMenu .danger:hover { background: rgba(255,79,90,.16); color: #ff97a1; } | |
| </style> | |
| <style>[data-canvasui-content], [data-canvasui-content] * { cursor: var(--canvasui-cursor, auto) ; }</style></head> | |
| <body> | |
| <div id="vignette"></div> | |
| <!-- ============ SIDEBAR ============ --> | |
| <aside id="sidebarPanel"> | |
| <div class="sidebar-logo"> | |
| <img src="logo.png" alt="EZ.AI"> | |
| <div> | |
| <div class="name">EZ.AI</div> | |
| <div class="tag">Neural Interface</div> | |
| </div> | |
| </div> | |
| <button id="newChatBtn"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"></path></svg> | |
| New Chat | |
| </button> | |
| <div id="chatList"><div class="chat-item"><span class="dot"></span><span class="txt">hi</span></div></div> | |
| <div class="sidebar-foot"> | |
| <div class="hint"> | |
| ◈ EZ.AI Neural Interface<br> | |
| FX: <b data-fx-state="">HEX</b> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Canvas UI–style Peel handle to reveal sidebar --> | |
| <div id="peel" role="button" tabindex="0" aria-label="Reveal sidebar"></div> | |
| <!-- ============ MAIN APP · HEX FLOAT BG ============ --> | |
| <div id="mainArea" style=""> | |
| <!-- Everything currently on screen — this is the real "sheet" the peel | |
| engine curls back. It gets MOVED (not copied) into the peel's own | |
| hidden source canvas once html-in-canvas support is confirmed, the | |
| same proven pattern HexFloat already uses for #hexContent. --> | |
| <!-- /peelContent --> | |
| <canvas layoutsubtree="" width="2990" height="2016" style="position: absolute; inset: 0px; width: 100%; height: 100%; pointer-events: none;"><div id="peelContent"> | |
| <div id="hexStage"> | |
| <!-- HexFloat only ever sees this ambient panel — it's the ONLY thing | |
| that gets textured onto hex tiles. No interactive UI lives in here, | |
| so nothing needs click/hover forwarding through the canvas. --> | |
| <!-- /hexContent --> | |
| <div id="peelFlap" aria-hidden="true" style="display: none;"><div class="flap-inner"><div class="flap-shade"></div><div class="flap-shine"></div></div></div> | |
| <canvas layoutsubtree="" width="2990" height="2016" style="position: absolute; inset: 0px; width: 100%; height: 100%;"><div id="hexContent" data-canvasui-content="" style="overflow: auto;"> | |
| <div id="bgFX"></div> | |
| </div></canvas><canvas aria-hidden="true" width="2990" height="2016" style="position: absolute; inset: 0px; width: 100%; height: 100%; pointer-events: none;"></canvas></div><!-- /hexStage --> | |
| <!-- Real, plain HTML UI — sits above the hex background as a normal DOM | |
| layer. Inputs, buttons, and scrolling behave exactly like any other | |
| webpage because none of it is routed through the HexFloat canvas. --> | |
| <div id="uiLayer"> | |
| <!-- ============ HOME SCREEN ============ --> | |
| <section id="homeScreen" class="active"> | |
| <div class="hero-chip"><span class="pulse-dot"></span> Online · EZ.AI Neural Interface</div> | |
| <div class="hero-logo"><img src="logo.png" alt=""></div> | |
| <h1 class="hero-title">EZ.AI</h1> | |
| <p class="hero-sub">What's on your mind today? I'm here to help.</p> | |
| <div class="hero-inputwrap"> | |
| <textarea class="neon-input" id="homeInput" placeholder="Send a message…" rows="1"></textarea> | |
| <button class="send-btn" id="homeSend" aria-label="Send"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 3v14M12 3l-5 5M12 3l5 5M5 20h14" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg> | |
| </button> | |
| </div> | |
| <div class="chips"> | |
| <button class="chip">Explain quantum computing like I'm 5</button> | |
| <button class="chip">Write a Python script to organize my downloads</button> | |
| <button class="chip">Draft a sci-fi movie premise</button> | |
| </div> | |
| </section> | |
| <!-- ============ CHAT VIEW ============ --> | |
| <section id="chatView" class=""> | |
| <header class="chat-header"> | |
| <div id="chatTitle">New conversation</div> | |
| <span class="chat-status"><span class="pulse-dot" style="width:6px;height:6px;border-radius:50%;background:#c9ced9;box-shadow:0 0 8px #c9ced9;display:inline-block;animation:blink2 1.4s ease-in-out infinite;"></span> EZ.AI</span> | |
| </header> | |
| <div id="chatWindow" style="pointer-events: none;"><div class="user-bubble">hi</div><div class="bot-row"><div class="bot-avatar">E</div><div class="bot-message"><div class="markdown-body"><p>Hey there! How’s it going today?</p> | |
| </div></div></div><div class="user-bubble">hi</div><div class="bot-row"><div class="bot-avatar">E</div><div class="bot-message"><div class="markdown-body"><p>{"error":"402 Payment Required","status":402,"deprecation_notice":"NOTE: The Pollinations legacy text API is being deprecated for authenticated users. Please migrate to <a href="https://enter.pollinations.ai">https://enter.pollinations.ai</a> for better performance and access to all the latest models. Anonymous requests to text.pollinations.ai are NOT affected.","details":{"success":false,"error":{"message":"API key budget too low. This request costs ~0.0004 pollen, but this key has 0.0000.","code":"PAYMENT_REQUIRED","timestamp":"2026-08-16T16:59:51.111Z"},"status":402}}</p> | |
| </div></div></div></div> | |
| <div id="inputBar"> | |
| <textarea class="neon-input" id="promptInput" placeholder="Send a message…" rows="1"></textarea> | |
| <button class="send-btn" id="sendBtn" aria-label="Send"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 3v14M12 3l-5 5M12 3l5 5M5 20h14" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg> | |
| </button> | |
| </div> | |
| </section> | |
| </div><!-- /uiLayer --> | |
| </div></canvas><canvas aria-hidden="true" width="2990" height="2016" style="position: absolute; inset: 0px; width: 100%; height: 100%; pointer-events: none;"></canvas></div><!-- /mainArea --> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script> | |
| /* ============================================================ | |
| Canvas UI — engine source (vanilla, inlined) | |
| HexFloat — MIT + Commons Clause, DavidHDev | |
| ============================================================ */ | |
| function supportsHtmlInCanvas() { | |
| if (typeof document === "undefined") return false; | |
| const probe = document.createElement("canvas"); | |
| const ctx = probe.getContext("2d"); | |
| return Boolean(ctx && typeof ctx.drawElementImage === "function" && typeof probe.requestPaint === "function"); | |
| } | |
| /* ===== Canvas UI Peel engine (ported from Canvas UI's PeelVanilla.ts) ===== | |
| Minimal JS port to provide `createPeel(elements, options)` for ai.html. | |
| This is a near-direct port (types removed) of the original engine's runtime | |
| so the real WebGL-based peel runs instead of the fallback. | |
| */ | |
| function createPeel(elements, options = {}) { | |
| const DEFAULTS = { | |
| side: "left", | |
| mode: "cursor", | |
| reveal: 250, | |
| zone: 200, | |
| curl: 300, | |
| bow: 75, | |
| shade: 0.25, | |
| shine: 1, | |
| shineColor: "auto", | |
| shineDistance: 1200, | |
| bulge: 50, | |
| perspective: 2000, | |
| smoothing: 0.3, | |
| }; | |
| const config = Object.assign({}, DEFAULTS, options); | |
| const source = elements.source; | |
| const content = elements.content; | |
| const output = elements.output; | |
| const under = elements.under; | |
| const gl = output.getContext("webgl2", { | |
| alpha: true, | |
| depth: true, | |
| stencil: false, | |
| antialias: true, | |
| premultipliedAlpha: true, | |
| }); | |
| if (!gl || gl.isContextLost()) return null; | |
| const sourceCtx = source.getContext("2d"); | |
| const paintable = source; | |
| const htmlInCanvas = Boolean(sourceCtx && typeof sourceCtx.drawElementImage === "function" && typeof paintable.requestPaint === "function"); | |
| // shader sources (vertex + fragment) — same as upstream | |
| const SHEET_VERT = `#version 300 es | |
| precision highp float; | |
| layout(location = 0) in vec2 aGrid; | |
| uniform vec2 uRes; | |
| uniform float uSide; | |
| uniform float uPeel; | |
| uniform float uReveal; | |
| uniform float uCurl; | |
| uniform float uBow; | |
| uniform float uFocal; | |
| uniform float uZone; | |
| uniform float uBulge; | |
| uniform vec2 uPointer; | |
| out vec2 vUv; | |
| out float vShade; | |
| out vec2 vSide; | |
| const float PI = 3.1415926; | |
| void main () { | |
| vUv = aGrid; | |
| vec2 p = aGrid * uRes; | |
| float crossLen = (uSide < 1.5) ? uRes.y : uRes.x; | |
| float u; float v; | |
| if (uSide < 0.5) { u = p.x; v = p.y; } | |
| else if (uSide < 1.5) { u = uRes.x - p.x; v = p.y; } | |
| else if (uSide < 2.5) { u = p.y; v = p.x; } | |
| else { u = uRes.y - p.y; v = p.x; } | |
| float A = clamp(uPeel, 0.0, 1.0); | |
| float f = A * uReveal; | |
| float R = max(uCurl * A, 0.001); | |
| float c0 = f + R; | |
| float dvB = (uPointer.y - v) / max(crossLen * 0.28, 1.0); | |
| float prox = clamp(1.0 - uPointer.x / max(c0 + uZone, 1.0), 0.0, 1.0); | |
| float c = c0 + uBulge * A * prox * prox * exp(-dvB * dvB); | |
| float x = u; | |
| float z = 0.0; | |
| float sh = 0.0; | |
| if (A > 0.001 && u < c) { | |
| float theta = (c - u) / R; | |
| if (theta <= PI) { | |
| x = c - R * sin(theta); | |
| z = R * (1.0 - cos(theta)); | |
| } else { | |
| x = c + (theta - PI) * R; | |
| z = 2.0 * R; | |
| } | |
| sh = sin(clamp(theta, 0.0, PI)); | |
| } | |
| z += uBow * A * sin(PI * v / max(crossLen, 1.0)) * clamp(z / max(R, 1.0), 0.0, 1.5); | |
| z = clamp(z, -uFocal * 0.2, uFocal * 0.45); | |
| vShade = sh * smoothstep(0.0, 0.08, A); | |
| vSide = vec2(u, v); | |
| vec2 q; | |
| if (uSide < 0.5) q = vec2(x, v); | |
| else if (uSide < 1.5) q = vec2(uRes.x - x, v); | |
| else if (uSide < 2.5) q = vec2(v, x); | |
| else q = vec2(v, uRes.y - x); | |
| vec2 ndc = (q / uRes) * 2.0 - 1.0; | |
| ndc.y = -ndc.y; | |
| float w = (uFocal - z) / uFocal; | |
| gl_Position = vec4(ndc, -z / uFocal, w); | |
| }`; | |
| const SHEET_FRAG = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in float vShade; | |
| in vec2 vSide; | |
| out vec4 outColor; | |
| uniform sampler2D uContent; | |
| uniform float uShade; | |
| uniform float uMaxX; | |
| uniform float uShine; | |
| uniform vec3 uShineColor; | |
| uniform float uCross; | |
| uniform float uSpan; | |
| uniform vec2 uPointer; | |
| void main() { | |
| vec2 uv = clamp(vUv, vec2(0.001), vec2(uMaxX - 0.001, 0.999)); | |
| vec4 tex = texture(uContent, uv); | |
| float sh = 1.0 - clamp(uShade, 0.0, 1.0) * 0.7 * pow(max(vShade, 0.0), 1.3); | |
| float du = max(vSide.x, 0.0); | |
| float line = exp(-du / 2.5) + exp(-du / 18.0) * 0.25; | |
| float dv = (vSide.y - uPointer.y) / max(uCross * 0.45, 1.0); | |
| float prox = clamp(1.0 - uPointer.x / max(uSpan, 1.0), 0.0, 1.0); | |
| float shine = uShine * line * exp(-dv * dv) * prox * prox; | |
| vec3 rgb = mix(tex.rgb * sh, uShineColor, clamp(shine, 0.0, 1.0)); | |
| outColor = vec4(rgb * tex.a, tex.a); | |
| }`; | |
| const SEG = 96; | |
| function compile(type, text) { | |
| const shader = gl.createShader(type); | |
| gl.shaderSource(shader, text); | |
| gl.compileShader(shader); | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
| console.error('Peel shader error:', gl.getShaderInfoLog(shader)); | |
| } | |
| return shader; | |
| } | |
| function link(vertText, fragText) { | |
| const vert = compile(gl.VERTEX_SHADER, vertText); | |
| const frag = compile(gl.FRAGMENT_SHADER, fragText); | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, vert); | |
| gl.attachShader(program, frag); | |
| gl.linkProgram(program); | |
| const uniforms = {}; | |
| const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); | |
| for (let i = 0; i < count; i++) { | |
| const info = gl.getActiveUniform(program, i); | |
| uniforms[info.name] = gl.getUniformLocation(program, info.name); | |
| } | |
| return { program, vert, frag, uniforms }; | |
| } | |
| const sheet = link(SHEET_VERT, SHEET_FRAG); | |
| const gridVerts = new Float32Array((SEG + 1) * (SEG + 1) * 2); | |
| for (let y = 0; y <= SEG; y++) { | |
| for (let x = 0; x <= SEG; x++) { | |
| const i = (y * (SEG + 1) + x) * 2; | |
| gridVerts[i] = x / SEG; | |
| gridVerts[i + 1] = y / SEG; | |
| } | |
| } | |
| const gridIndices = new Uint32Array(SEG * SEG * 6); | |
| let offset = 0; | |
| for (let y = 0; y < SEG; y++) { | |
| for (let x = 0; x < SEG; x++) { | |
| const a = y * (SEG + 1) + x; | |
| const b = a + 1; | |
| const c = a + SEG + 1; | |
| const d = c + 1; | |
| gridIndices[offset++] = a; | |
| gridIndices[offset++] = c; | |
| gridIndices[offset++] = b; | |
| gridIndices[offset++] = b; | |
| gridIndices[offset++] = c; | |
| gridIndices[offset++] = d; | |
| } | |
| } | |
| const sheetVao = gl.createVertexArray(); | |
| gl.bindVertexArray(sheetVao); | |
| const gridBuffer = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer); | |
| gl.bufferData(gl.ARRAY_BUFFER, gridVerts, gl.STATIC_DRAW); | |
| gl.enableVertexAttribArray(0); | |
| gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); | |
| const indexBuffer = gl.createBuffer(); | |
| gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); | |
| gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, gridIndices, gl.STATIC_DRAW); | |
| gl.bindVertexArray(null); | |
| const contentTexture = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0,0,0,0])); | |
| let contentMaxX = 1; | |
| let hasTexture = false; | |
| // Do not hide the underlying sidebar — keep it visible at all times. | |
| // Historically the peel engine hid `under` to avoid double-rendering when | |
| // html-in-canvas was used. For this integration we want the sidebar to | |
| // remain present even when the peel is active, so skip hiding it. | |
| function capture() { | |
| if (!htmlInCanvas) return; | |
| try { | |
| sourceCtx.reset(); | |
| sourceCtx.drawElementImage(content, 0, 0); | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); | |
| sourceCtx.reset(); | |
| hasTexture = true; | |
| wake(); | |
| } catch (e) { | |
| // swallow | |
| } | |
| } | |
| function syncCanvasSize() { | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const width = Math.max(1, Math.round(output.clientWidth * dpr)); | |
| const height = Math.max(1, Math.round(output.clientHeight * dpr)); | |
| if (output.width !== width || output.height !== height) { | |
| output.width = width; | |
| output.height = height; | |
| } | |
| contentMaxX = Math.min(1, Math.max(0.05, content.clientWidth / Math.max(output.clientWidth, 1))); | |
| if (htmlInCanvas) { | |
| const cssWidth = Math.max(1, Math.round(source.clientWidth)); | |
| const cssHeight = Math.max(1, Math.round(source.clientHeight)); | |
| if (source.width !== cssWidth * dpr || source.height !== cssHeight * dpr) { | |
| source.width = cssWidth * dpr; | |
| source.height = cssHeight * dpr; | |
| } | |
| paintable.requestPaint(); | |
| } | |
| } | |
| let peel = { a: 0, target: 0 }; | |
| const FAR = 1e4; | |
| const pointer = { u: FAR, v: 0, su: FAR, sv: 0 }; | |
| let shineRgb = [1,1,1]; | |
| const probe = document.createElement('canvas'); | |
| probe.width = probe.height = 1; | |
| const probeCtx = probe.getContext('2d', { willReadFrequently: true }); | |
| function syncShineColor() { | |
| if (config.shineColor !== 'auto') { shineRgb = config.shineColor; return; } | |
| let luminance = 1; | |
| if (probeCtx) { | |
| let el = content; | |
| while (el) { | |
| const bg = getComputedStyle(el).backgroundColor; | |
| if (bg && bg !== 'transparent') { | |
| probeCtx.clearRect(0,0,1,1); | |
| probeCtx.fillStyle = bg; | |
| probeCtx.fillRect(0,0,1,1); | |
| const d = probeCtx.getImageData(0,0,1,1).data; | |
| const [r,g,b,a] = d; | |
| if (a > 0) { luminance = (0.2126*r + 0.7152*g + 0.0722*b)/255; break; } | |
| } | |
| el = el.parentElement; | |
| } | |
| } | |
| shineRgb = luminance > 0.5 ? [0,0,0] : [1,1,1]; | |
| } | |
| const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); | |
| let reducedMotion = motionQuery.matches; | |
| syncCanvasSize(); | |
| syncShineColor(); | |
| // We'll toggle mainArea pointer-events when the peel is open so the | |
| // underlying sidebar can receive input; no overlay forwarding is used. | |
| let overlay = null; | |
| function render() { | |
| // Intentionally do not toggle `under.style.visibility` here; keep the | |
| // sidebar visible regardless of capture/render state. | |
| const w = Math.max(output.clientWidth, 1); | |
| const h = Math.max(output.clientHeight, 1); | |
| const side = (config.side === 'right') ? 1 : (config.side === 'top' ? 2 : (config.side === 'bottom' ? 3 : 0)); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| gl.viewport(0,0, output.width, output.height); | |
| gl.clearColor(0,0,0,0); | |
| gl.clearDepth(1); | |
| gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); | |
| gl.enable(gl.DEPTH_TEST); | |
| gl.depthFunc(gl.LEQUAL); | |
| gl.useProgram(sheet.program); | |
| gl.bindVertexArray(sheetVao); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.uniform1i(sheet.uniforms.uContent, 0); | |
| gl.uniform2f(sheet.uniforms.uRes, w, h); | |
| gl.uniform1f(sheet.uniforms.uSide, side); | |
| gl.uniform1f(sheet.uniforms.uPeel, peel.a); | |
| gl.uniform1f(sheet.uniforms.uReveal, Math.max(config.reveal, 0)); | |
| gl.uniform1f(sheet.uniforms.uCurl, Math.max(config.curl, 1)); | |
| gl.uniform1f(sheet.uniforms.uBow, config.bow); | |
| gl.uniform1f(sheet.uniforms.uFocal, Math.max(config.perspective, 200)); | |
| gl.uniform1f(sheet.uniforms.uShade, config.shade); | |
| gl.uniform1f(sheet.uniforms.uZone, Math.max(config.zone, 1)); | |
| gl.uniform1f(sheet.uniforms.uBulge, Math.max(config.bulge, 0)); | |
| gl.uniform1f(sheet.uniforms.uShine, Math.max(config.shine, 0)); | |
| gl.uniform3f(sheet.uniforms.uShineColor, shineRgb[0], shineRgb[1], shineRgb[2]); | |
| gl.uniform1f(sheet.uniforms.uCross, side < 1.5 ? h : w); | |
| gl.uniform1f(sheet.uniforms.uSpan, config.shineDistance > 0 ? config.shineDistance : (side < 1.5 ? w : h)); | |
| gl.uniform2f(sheet.uniforms.uPointer, pointer.su, pointer.sv); | |
| gl.uniform1f(sheet.uniforms.uMaxX, contentMaxX); | |
| gl.drawElements(gl.TRIANGLES, gridIndices.length, gl.UNSIGNED_INT, 0); | |
| gl.bindVertexArray(null); | |
| gl.disable(gl.DEPTH_TEST); | |
| } | |
| function updateTarget() { | |
| if (config.mode === 'hover') { | |
| const open = peel.target > 0.5; | |
| const limit = open ? peel.a * config.reveal + config.zone : config.zone; | |
| peel.target = pointer.u < limit ? 1 : 0; | |
| return; | |
| } | |
| const span = Math.max(config.zone + peel.a * config.reveal, 1); | |
| peel.target = Math.min(1, Math.max(0, 1 - pointer.u / span)); | |
| } | |
| let raf = 0; | |
| let lastTime = performance.now(); | |
| let destroyed = false; | |
| let running = false; | |
| let visible = true; | |
| let _lastLogTarget = null; | |
| let _lastLogA = null; | |
| function frame(now) { | |
| if (destroyed) return; | |
| if (!visible) { running = false; return; } | |
| const delta = Math.min((now - lastTime) / 1000, 1/30); | |
| lastTime = now; | |
| const tau = Math.max(config.smoothing, 1e-4); | |
| const k = reducedMotion ? 1 : 1 - Math.exp(-delta / tau); | |
| const kp = reducedMotion ? 1 : 1 - Math.exp(-delta / (tau * 0.45)); | |
| pointer.su += (pointer.u - pointer.su) * kp; | |
| pointer.sv += (pointer.v - pointer.sv) * kp; | |
| updateTarget(); | |
| peel.a += (peel.target - peel.a) * k; | |
| // debug: trace target/active peel values when they change | |
| if (_lastLogTarget !== peel.target || _lastLogA !== peel.a) { | |
| _lastLogTarget = peel.target; _lastLogA = peel.a; | |
| console.debug('[createPeel] target=', peel.target.toFixed(3), 'a=', peel.a.toFixed(3), 'pointer.u=', pointer.u.toFixed ? pointer.u.toFixed(1) : pointer.u); | |
| } | |
| // Toggle `#mainArea` pointer-events while peel is open so clicks reach | |
| // the sidebar underneath. Use a small threshold to avoid flicker. | |
| try { | |
| const main = document.getElementById('mainArea'); | |
| if (main) { | |
| const open = peel.a > 0.02; | |
| main.style.pointerEvents = open ? 'none' : ''; | |
| } | |
| } catch (e) {} | |
| render(); | |
| const settle = 0.5 / Math.max(config.reveal + config.curl, 1); | |
| if (Math.abs(peel.target - peel.a) < settle && Math.abs(pointer.u - pointer.su) < 0.5 && Math.abs(pointer.v - pointer.sv) < 0.5) { | |
| peel.a = peel.target; pointer.su = pointer.u; pointer.sv = pointer.v; running = false; return; | |
| } | |
| raf = requestAnimationFrame(frame); | |
| } | |
| function start() { if (destroyed || running || !visible) return; running = true; lastTime = performance.now(); raf = requestAnimationFrame(frame); } | |
| let wake = start; | |
| function onMotionChange() { reducedMotion = motionQuery.matches; start(); } | |
| motionQuery.addEventListener('change', onMotionChange); | |
| let themeTimer = 0; | |
| function onThemeShift() { syncShineColor(); start(); window.clearTimeout(themeTimer); themeTimer = window.setTimeout(() => { syncShineColor(); start(); }, 300); } | |
| const themeObserver = new MutationObserver(onThemeShift); | |
| themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }); | |
| const schemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); | |
| schemeQuery.addEventListener('change', onThemeShift); | |
| const observer = new ResizeObserver(() => { syncCanvasSize(); start(); }); | |
| observer.observe(output); | |
| observer.observe(content); | |
| const intersection = new IntersectionObserver((entries) => { | |
| const last = entries[entries.length - 1]; | |
| visible = last?.isIntersecting ?? true; | |
| console.debug('[createPeel] intersection entries=', entries.map(e => ({rootBounds: e.rootBounds, boundingClientRect: e.boundingClientRect, isIntersecting: e.isIntersecting})), 'visible=', visible); | |
| if (visible) start(); | |
| }); | |
| intersection.observe(output); | |
| const listenTarget = output.parentElement ?? output; | |
| console.debug('[createPeel] listenTarget=', listenTarget); | |
| function sideDistance(x,y,rect) { if (config.side === 'right') return rect.width - x; if (config.side === 'top') return y; if (config.side === 'bottom') return rect.height - y; return x; } | |
| function onPointerMove(event) { | |
| if (!htmlInCanvas) return; | |
| const rect = output.getBoundingClientRect(); | |
| const x = event.clientX - rect.left; | |
| const y = event.clientY - rect.top; | |
| pointer.u = sideDistance(x,y,rect); | |
| pointer.v = config.side === 'top' || config.side === 'bottom' ? x : y; | |
| updateTarget(); start(); | |
| console.debug('[createPeel] onPointerMove u=', pointer.u, 'v=', pointer.v); | |
| } | |
| function onPointerLeave() { pointer.u = FAR; peel.target = 0; start(); } | |
| listenTarget.addEventListener('pointermove', onPointerMove, { passive: true }); | |
| listenTarget.addEventListener('pointerleave', onPointerLeave, { passive: true }); | |
| // Ensure pointer events are captured even if topology/stacking sends | |
| // events to the sidebar or other elements — listen on window as a fallback. | |
| window.addEventListener('pointermove', onPointerMove, { passive: true }); | |
| window.addEventListener('pointerleave', onPointerLeave, { passive: true }); | |
| console.debug('[createPeel] pointer listeners attached (listenTarget + window)'); | |
| // Forward clicks/pointerdowns that occur in the revealed left zone to the | |
| // sidebar when the peel is open. This temporarily disables pointer-events | |
| // on blocking elements at the event point so elementFromPoint can find the | |
| // sidebar child beneath, then re-dispatches the event there. | |
| function forwardToSidebar(ev) { | |
| try { | |
| if (ev.clientX > (config.reveal || 300) + 16) return; // outside left zone | |
| if (peel.a <= 0.02) return; // peel not open | |
| const sidebar = document.getElementById('sidebarPanel'); | |
| if (!sidebar) return; | |
| const x = ev.clientX, y = ev.clientY; | |
| let elems = document.elementsFromPoint(x, y); | |
| // If any element in the chain is inside the sidebar, let the event proceed normally | |
| if (elems.some(e => e && e.closest && e.closest('#sidebarPanel'))) return; | |
| // Otherwise, temporarily disable pointer-events on top elements until we uncover sidebar | |
| const toRestore = []; | |
| for (const el of elems) { | |
| if (!el || el === document.documentElement || el === document.body) continue; | |
| if (el === sidebar || el.closest('#sidebarPanel')) { break; } | |
| const prev = el.style && el.style.pointerEvents; | |
| toRestore.push({ el, prev }); | |
| el.style.pointerEvents = 'none'; | |
| } | |
| // Find the target now | |
| const target = document.elementFromPoint(x, y) || sidebar; | |
| if (target && sidebar.contains(target)) { | |
| const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, pointerId: ev.pointerId || 1, button: ev.button || 0, buttons: ev.buttons || 1, pressure: ev.pressure || 0.5, pointerType: ev.pointerType || 'mouse' }; | |
| const pe = new PointerEvent(ev.type, opts); | |
| target.dispatchEvent(pe); | |
| ev.stopImmediatePropagation(); ev.preventDefault(); | |
| } | |
| // restore | |
| for (const r of toRestore) { try { r.el.style.pointerEvents = r.prev || ''; } catch (e) {} } | |
| } catch (e) { /* swallow */ } | |
| } | |
| window.addEventListener('pointerdown', forwardToSidebar, true); | |
| // when html-in-canvas is supported, hook paint callbacks | |
| if (htmlInCanvas) paintable.onpaint = capture; | |
| return { | |
| setOptions(next) { if (!Object.entries(next).some(([k,v]) => config[k] !== v)) return; Object.assign(config, next); syncShineColor(); start(); }, | |
| resize() { syncCanvasSize(); start(); }, | |
| destroy() { | |
| destroyed = true; cancelAnimationFrame(raf); observer.disconnect(); intersection.disconnect(); themeObserver.disconnect(); schemeQuery.removeEventListener('change', onThemeShift); window.clearTimeout(themeTimer); motionQuery.removeEventListener('change', onMotionChange); listenTarget.removeEventListener('pointermove', onPointerMove); listenTarget.removeEventListener('pointerleave', onPointerLeave); try { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerleave', onPointerLeave); window.removeEventListener('pointerdown', forwardToSidebar, true); } catch (e) {} content.style.pointerEvents = ''; /* keep `under` visibility as-is */ | |
| // overlay removal not needed; ensure any leftover overlay is removed | |
| try { if (overlay) { overlay.remove(); overlay = null; } } catch (e) {} | |
| gl.deleteTexture(contentTexture); gl.deleteProgram(sheet.program); gl.deleteShader(sheet.vert); gl.deleteShader(sheet.frag); gl.deleteBuffer(gridBuffer); gl.deleteBuffer(indexBuffer); gl.deleteVertexArray(sheetVao); if (htmlInCanvas) paintable.onpaint = null; | |
| console.debug('[createPeel] destroy() called, cleaned up'); | |
| } | |
| }; | |
| } | |
| /* ============================ HEX FLOAT ============================ */ | |
| (function () { | |
| const DEFAULTS = { | |
| size: 200, gap: 0, bevel: 1.5, tilt: 24, perspective: 0.5, float: 0, | |
| speed: 1, shine: 0.5, lift: 0.1, radius: 0, flow: 0, swirl: 0, trail: 0, | |
| iridescence: 1, bloom: 0, grain: 0, gapColor: "auto", | |
| }; | |
| const HOVER_ATTR = "data-canvasui-hover"; | |
| const CONTENT_ATTR = "data-canvasui-content"; | |
| const HOVER_REWRITE = `:is([${HOVER_ATTR}], :hover:where(:not([${CONTENT_ATTR}], [${CONTENT_ATTR}] *)))`; | |
| const VERT = `#version 300 es | |
| precision highp float; | |
| layout(location = 0) in vec2 aPos; | |
| out vec2 vUv; | |
| void main () { | |
| vUv = aPos * 0.5 + 0.5; | |
| gl_Position = vec4(aPos, 0.0, 1.0); | |
| }`; | |
| const FRAG = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uContent; | |
| uniform vec2 uRes; | |
| uniform float uSize; | |
| uniform float uGap; | |
| uniform float uBevel; | |
| uniform float uTilt; | |
| uniform float uDist; | |
| uniform float uFloat; | |
| uniform float uShine; | |
| uniform float uLift; | |
| uniform float uIrid; | |
| uniform sampler2D uFlow; | |
| uniform vec2 uScroll; | |
| uniform float uTime; | |
| uniform float uHasContent; | |
| uniform float uMaxX; | |
| uniform vec3 uBg; | |
| uniform vec3 uGapColor; | |
| const float TAU = 6.2831853; | |
| const float SQ3 = 1.7320508; | |
| float hash12 (vec2 p) { | |
| vec3 p3 = fract(vec3(p.xyx) * 0.1031); | |
| p3 += dot(p3, p3.yzx + 33.33); | |
| return fract((p3.x + p3.y) * p3.z); | |
| } | |
| vec2 hextile (inout vec2 p) { | |
| const vec2 sz = vec2(1.0, SQ3); | |
| const vec2 hsz = 0.5 * sz; | |
| vec2 p1 = mod(p, sz) - hsz; | |
| vec2 p2 = mod(p - hsz, sz) - hsz; | |
| vec2 p3 = dot(p1, p1) < dot(p2, p2) ? p1 : p2; | |
| vec2 n = (p3 - p + hsz) / sz; | |
| p = p3; | |
| n -= vec2(0.5); | |
| return round(n * 2.0) * 0.5; | |
| } | |
| float hexDist (vec2 p) { | |
| p = abs(p); | |
| return max(dot(p, vec2(0.5, 0.8660254)), p.x); | |
| } | |
| float flowAt (vec2 xy) { | |
| vec2 uv = (xy * uSize - uScroll) / uRes; | |
| if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 0.0; | |
| return clamp(texture(uFlow, uv).r, 0.0, 4.0); | |
| } | |
| float tileZ (vec2 center, float f) { | |
| vec2 id = center * vec2(1.0, 1.0 / SQ3); | |
| float h = hash12(id * 7.31 + 3.7); | |
| float focus = smoothstep(0.18, 0.85, f); | |
| float ring = smoothstep(0.02, 0.14, f) * (1.0 - smoothstep(0.14, 0.6, f)); | |
| float bob = uFloat * 0.4 * sin(uTime * 1.4 + h * TAU) * (1.0 - focus); | |
| float lift = uLift * ring; | |
| return -(bob + lift * 1.2); | |
| } | |
| vec4 page (vec2 px) { | |
| vec2 p = px / uRes; | |
| if (p.x < 0.0 || p.x > uMaxX || p.y < 0.0 || p.y > 1.0) return vec4(0.0); | |
| return texture(uContent, p); | |
| } | |
| vec4 shade (vec2 sUv) { | |
| float cell = max(uSize, 8.0); | |
| float hw = max(0.5 - (uGap / cell) * 0.5, 0.15); | |
| float bevW = clamp(uBevel / cell, 0.0, hw - 0.1); | |
| float th = 0.09; | |
| float aspect = uRes.x / uRes.y; | |
| vec2 ndc = vec2((sUv.x * 2.0 - 1.0) * aspect, sUv.y * 2.0 - 1.0); | |
| float sa = sin(uTilt); | |
| float ca = cos(uTilt); | |
| vec3 fwd = vec3(0.0, -sa, ca); | |
| vec3 upv = vec3(0.0, ca, sa); | |
| float H = uRes.y / cell; | |
| float D = H * uDist; | |
| float focal = (D + sqrt(D * D + H * H * sa * sa)) / (H * ca); | |
| float dy = 0.5 * H - sa * D | |
| - ca * D * (ca - focal * sa) / (sa + focal * ca); | |
| vec3 la = vec3(uScroll.x / cell + 0.5 * uRes.x / cell, | |
| uScroll.y / cell + 0.5 * H + dy, 0.0); | |
| vec3 ro = la - fwd * D; | |
| vec3 rd = normalize(vec3(ndc.x, 0.0, 0.0) + ndc.y * upv + focal * fwd); | |
| vec3 seam = uGapColor; | |
| if (rd.z < 0.02) { | |
| return uHasContent > 0.5 ? vec4(uBg, 1.0) : vec4(0.0); | |
| } | |
| float maxUp = uFloat * 0.4 + uLift * 1.2 + th; | |
| float floorZ = th + 0.06; | |
| float tFloor = (floorZ - ro.z) / rd.z; | |
| float t0 = max((-maxUp - ro.z) / rd.z, 0.0); | |
| vec2 oxy = ro.xy; | |
| vec2 rxy = rd.xy; | |
| vec2 sp = oxy + rxy * t0; | |
| vec2 local = sp; | |
| hextile(local); | |
| vec2 center = sp - local; | |
| vec2 N0 = vec2(1.0, 0.0); | |
| vec2 N1 = vec2(0.5, 0.8660254); | |
| vec2 N2 = vec2(-0.5, 0.8660254); | |
| bool hit = false; | |
| bool onTop = false; | |
| float tHit = 0.0; | |
| vec3 n = vec3(0.0, 0.0, -1.0); | |
| float zc = 0.0; | |
| float hwc = hw; | |
| float fCell = 0.0; | |
| for (int i = 0; i < 64; i++) { | |
| fCell = flowAt(center); | |
| zc = tileZ(center, fCell); | |
| hwc = mix(hw, 0.502, smoothstep(0.18, 0.85, fCell)); | |
| float zTop = zc - th; | |
| float tZin = (zTop - ro.z) / rd.z; | |
| float tZout = (zc + th - ro.z) / rd.z; | |
| float tIn = -1.0e9; | |
| float tOut = 1.0e9; | |
| vec2 inN = vec2(0.0); | |
| bool empty = false; | |
| for (int k = 0; k < 3; k++) { | |
| vec2 Nk = k == 0 ? N0 : (k == 1 ? N1 : N2); | |
| float d = dot(rxy, Nk); | |
| float o = dot(oxy - center, Nk); | |
| if (abs(d) < 1.0e-6) { | |
| if (abs(o) > hwc) { empty = true; break; } | |
| } else { | |
| float ta = (-hwc - o) / d; | |
| float tb = (hwc - o) / d; | |
| float lo = min(ta, tb); | |
| float hi = max(ta, tb); | |
| if (lo > tIn) { tIn = lo; inN = -sign(d) * Nk; } | |
| tOut = min(tOut, hi); | |
| } | |
| } | |
| if (!empty) { | |
| float lo = max(tIn, tZin); | |
| float hi = min(tOut, tZout); | |
| if (lo <= hi && hi > 0.0) { | |
| tHit = max(lo, 0.0); | |
| onTop = tZin >= tIn; | |
| n = onTop ? vec3(0.0, 0.0, -1.0) : vec3(inN, 0.0); | |
| hit = true; | |
| break; | |
| } | |
| } | |
| float tExit = 1.0e9; | |
| vec2 step2 = vec2(0.0); | |
| for (int k = 0; k < 3; k++) { | |
| vec2 Nk = k == 0 ? N0 : (k == 1 ? N1 : N2); | |
| float d = dot(rxy, Nk); | |
| if (abs(d) < 1.0e-6) continue; | |
| float o = dot(oxy - center, Nk); | |
| float te = (0.5 * sign(d) - o) / d; | |
| if (te < tExit) { tExit = te; step2 = sign(d) * Nk; } | |
| } | |
| if (tExit >= tFloor || step2 == vec2(0.0)) break; | |
| center += step2; | |
| } | |
| vec3 Ld = normalize(vec3(-0.35, -0.5, -0.78)); | |
| if (!hit) { | |
| vec2 fl = (oxy + rxy * tFloor); | |
| vec2 fLocal = fl; | |
| hextile(fLocal); | |
| float open = smoothstep(hw, hw + 0.22, hexDist(fLocal)); | |
| if (uHasContent < 0.5) { | |
| return vec4(0.0, 0.0, 0.0, 0.4 - 0.25 * open); | |
| } | |
| return vec4(seam * mix(0.6, 1.0, open), 1.0); | |
| } | |
| vec3 p = ro + rd * tHit; | |
| float fc = smoothstep(0.18, 0.85, fCell); | |
| if (onTop) { | |
| vec2 lp = p.xy - center; | |
| float e = hwc - hexDist(lp); | |
| if (e < bevW) { | |
| float ax = abs(lp.x); | |
| float a1 = abs(dot(lp, N1)); | |
| float a2 = abs(dot(lp, N2)); | |
| vec2 dir = ax > a1 && ax > a2 ? N0 : (a1 > a2 ? N1 : N2); | |
| dir *= sign(dot(lp, dir)); | |
| float k = (1.0 - smoothstep(0.0, max(bevW, 1.0e-4), e)) * (1.0 - fc); | |
| n = normalize(mix(vec3(0.0, 0.0, -1.0), vec3(dir * 0.85, -0.6), k)); | |
| } | |
| } | |
| float diff = max(dot(n, Ld), 0.0); | |
| vec3 refl = reflect(rd, n); | |
| vec3 Ld2 = normalize(vec3(0.55, -0.25, -0.8)); | |
| float glintL = pow(max(dot(refl, Ld), 0.0), 120.0); | |
| float sheenL = pow(max(dot(refl, Ld2), 0.0), 8.0) * 0.35; | |
| float spec = (glintL + sheenL) * uShine * (1.0 - fc); | |
| float fres = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * (1.0 - fc); | |
| float iridPh = dot(n, -rd) * 2.2 + (p.x + p.y) * 0.22; | |
| vec3 iridTint = 1.0 + uIrid * 0.3 * cos(vec3(0.0, 2.094, 4.188) + iridPh * 3.5); | |
| vec3 specCol = spec * iridTint; | |
| float raised = clamp(-zc, -0.6, 1.4); | |
| if (uHasContent < 0.5) { | |
| float glint = spec * (0.4 + 0.6 * (onTop ? 0.4 : 1.0)) + fres * 0.2 * uShine; | |
| float shadeSide = onTop ? 0.0 : 0.3; | |
| float a = clamp(glint * 0.85 + shadeSide, 0.0, 0.85) * (1.0 - fc); | |
| return vec4(vec3(min(glint, a)), a); | |
| } | |
| if (onTop) { | |
| vec4 c = page(p.xy * cell - uScroll); | |
| vec3 face = mix(uBg, c.rgb, c.a); | |
| vec3 lit = face * (0.86 + 0.14 * diff + raised * 0.06) | |
| + specCol * 0.9 + fres * iridTint * 0.12 * uShine; | |
| return vec4(mix(lit, face, fc), 1.0); | |
| } | |
| float wallAo = 1.0 - smoothstep(zc - th, floorZ, p.z) * 0.4; | |
| vec3 wallCol = seam * mix(0.55, 1.0, diff) * wallAo | |
| + specCol * 1.3 + fres * iridTint * 0.28 * uShine; | |
| return vec4(wallCol, 1.0); | |
| } | |
| void main () { | |
| vec2 sUv = vec2(vUv.x, 1.0 - vUv.y); | |
| vec2 px = 1.0 / uRes; | |
| vec4 a = shade(sUv + vec2( 0.125, 0.375) * px); | |
| vec4 b = shade(sUv + vec2(-0.125, -0.375) * px); | |
| vec4 c = a + b; | |
| if (dot(abs(a - b), vec4(1.0)) > 0.02) { | |
| c += shade(sUv + vec2(-0.375, 0.125) * px) | |
| + shade(sUv + vec2( 0.375, -0.125) * px); | |
| outColor = c * 0.25; | |
| } else { | |
| outColor = c * 0.5; | |
| } | |
| }`; | |
| const SIM_VERT = `#version 300 es | |
| precision highp float; | |
| layout(location = 0) in vec2 aPos; | |
| out vec2 vUv; | |
| out vec2 vL; | |
| out vec2 vR; | |
| out vec2 vT; | |
| out vec2 vB; | |
| uniform vec2 texelSize; | |
| void main () { | |
| vUv = aPos * 0.5 + 0.5; | |
| vL = vUv - vec2(texelSize.x, 0.0); | |
| vR = vUv + vec2(texelSize.x, 0.0); | |
| vT = vUv + vec2(0.0, texelSize.y); | |
| vB = vUv - vec2(0.0, texelSize.y); | |
| gl_Position = vec4(aPos, 0.0, 1.0); | |
| }`; | |
| const FRAG_SPLAT = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uTarget; | |
| uniform float uAspect; | |
| uniform vec3 uColor; | |
| uniform vec2 uPoint; | |
| uniform float uRadius; | |
| void main () { | |
| vec2 p = vUv - uPoint; | |
| p.x *= uAspect; | |
| vec3 splat = exp(-dot(p, p) / uRadius) * uColor; | |
| vec3 base = texture(uTarget, vUv).xyz; | |
| outColor = vec4(base + splat, 1.0); | |
| }`; | |
| const FRAG_ADVECT = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uVelocity; | |
| uniform sampler2D uSource; | |
| uniform vec2 texelSize; | |
| uniform float uDt; | |
| uniform float uDissipation; | |
| void main () { | |
| vec2 coord = vUv - uDt * texture(uVelocity, vUv).xy * texelSize; | |
| outColor = uDissipation * texture(uSource, coord); | |
| outColor.a = 1.0; | |
| }`; | |
| const FRAG_CLEAR = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uTexture; | |
| uniform float uValue; | |
| void main () { | |
| outColor = uValue * texture(uTexture, vUv); | |
| }`; | |
| const FRAG_DIVERGENCE = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in vec2 vL; | |
| in vec2 vR; | |
| in vec2 vT; | |
| in vec2 vB; | |
| out vec4 outColor; | |
| uniform sampler2D uVelocity; | |
| void main () { | |
| float L = texture(uVelocity, vL).x; | |
| float R = texture(uVelocity, vR).x; | |
| float T = texture(uVelocity, vT).y; | |
| float B = texture(uVelocity, vB).y; | |
| vec2 C = texture(uVelocity, vUv).xy; | |
| if (vL.x < 0.0) { L = -C.x; } | |
| if (vR.x > 1.0) { R = -C.x; } | |
| if (vT.y > 1.0) { T = -C.y; } | |
| if (vB.y < 0.0) { B = -C.y; } | |
| float div = 0.5 * (R - L + T - B); | |
| outColor = vec4(div, 0.0, 0.0, 1.0); | |
| }`; | |
| const FRAG_CURL = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in vec2 vL; | |
| in vec2 vR; | |
| in vec2 vT; | |
| in vec2 vB; | |
| out vec4 outColor; | |
| uniform sampler2D uVelocity; | |
| void main () { | |
| float L = texture(uVelocity, vL).y; | |
| float R = texture(uVelocity, vR).y; | |
| float T = texture(uVelocity, vT).x; | |
| float B = texture(uVelocity, vB).x; | |
| float vorticity = R - L - T + B; | |
| outColor = vec4(vorticity, 0.0, 0.0, 1.0); | |
| }`; | |
| const FRAG_VORTICITY = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in vec2 vL; | |
| in vec2 vR; | |
| in vec2 vT; | |
| in vec2 vB; | |
| out vec4 outColor; | |
| uniform sampler2D uVelocity; | |
| uniform sampler2D uCurl; | |
| uniform float uCurlStrength; | |
| uniform float uDt; | |
| void main () { | |
| float L = texture(uCurl, vL).x; | |
| float R = texture(uCurl, vR).x; | |
| float T = texture(uCurl, vT).x; | |
| float B = texture(uCurl, vB).x; | |
| float C = texture(uCurl, vUv).x; | |
| vec2 force = vec2(abs(T) - abs(B), abs(R) - abs(L)) * 0.5; | |
| force /= length(force) + 1.0; | |
| force *= uCurlStrength * C; | |
| force.y *= -1.0; | |
| vec2 velocity = texture(uVelocity, vUv).xy; | |
| outColor = vec4(velocity + force * uDt, 0.0, 1.0); | |
| }`; | |
| const FRAG_PRESSURE = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in vec2 vL; | |
| in vec2 vR; | |
| in vec2 vT; | |
| in vec2 vB; | |
| out vec4 outColor; | |
| uniform sampler2D uPressure; | |
| uniform sampler2D uDivergence; | |
| void main () { | |
| float L = texture(uPressure, vL).x; | |
| float R = texture(uPressure, vR).x; | |
| float T = texture(uPressure, vT).x; | |
| float B = texture(uPressure, vB).x; | |
| float divergence = texture(uDivergence, vUv).x; | |
| float pressure = (L + R + B + T - divergence) * 0.25; | |
| outColor = vec4(pressure, 0.0, 0.0, 1.0); | |
| }`; | |
| const FRAG_GRADIENT = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| in vec2 vL; | |
| in vec2 vR; | |
| in vec2 vT; | |
| in vec2 vB; | |
| out vec4 outColor; | |
| uniform sampler2D uPressure; | |
| uniform sampler2D uVelocity; | |
| void main () { | |
| float L = texture(uPressure, vL).x; | |
| float R = texture(uPressure, vR).x; | |
| float T = texture(uPressure, vT).x; | |
| float B = texture(uPressure, vB).x; | |
| vec2 velocity = texture(uVelocity, vUv).xy; | |
| velocity.xy -= vec2(R - L, T - B); | |
| outColor = vec4(velocity, 0.0, 1.0); | |
| }`; | |
| const FRAG_BRIGHT = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uScene; | |
| void main () { | |
| vec3 c = texture(uScene, vUv).rgb; | |
| float l = dot(c, vec3(0.299, 0.587, 0.114)); | |
| outColor = vec4(c * smoothstep(0.55, 0.95, l), 1.0); | |
| }`; | |
| const FRAG_BLUR = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uScene; | |
| uniform vec2 uDir; | |
| void main () { | |
| vec3 c = texture(uScene, vUv).rgb * 0.227027; | |
| c += texture(uScene, vUv + uDir * 1.3846154).rgb * 0.3162162; | |
| c += texture(uScene, vUv - uDir * 1.3846154).rgb * 0.3162162; | |
| c += texture(uScene, vUv + uDir * 3.2307692).rgb * 0.0702703; | |
| c += texture(uScene, vUv - uDir * 3.2307692).rgb * 0.0702703; | |
| outColor = vec4(c, 1.0); | |
| }`; | |
| const FRAG_COMPOSITE = `#version 300 es | |
| precision highp float; | |
| in vec2 vUv; | |
| out vec4 outColor; | |
| uniform sampler2D uScene; | |
| uniform sampler2D uBloomTex; | |
| uniform float uBloomAmt; | |
| uniform float uGrainAmt; | |
| uniform float uTime; | |
| void main () { | |
| vec4 scene = texture(uScene, vUv); | |
| vec3 bloom = texture(uBloomTex, vUv).rgb * uBloomAmt; | |
| vec3 col = scene.rgb + bloom; | |
| float g = fract(sin(dot(gl_FragCoord.xy + vec2(uTime * 61.7, uTime * 123.4), | |
| vec2(12.9898, 78.233))) * 43758.5453) - 0.5; | |
| col += g * uGrainAmt * 0.14; | |
| float ba = dot(bloom, vec3(0.333)); | |
| outColor = vec4(col, clamp(scene.a + ba, 0.0, 1.0)); | |
| }`; | |
| const SIM_RES = 96; | |
| const FLOW_RES = 256; | |
| const SIM_DT = 1 / 60; | |
| const VELOCITY_DISSIPATION = 0.985; | |
| const PRESSURE_DECAY = 0.8; | |
| const PRESSURE_ITERATIONS = 4; | |
| function patchHoverRules() { | |
| if (typeof document === "undefined") return; | |
| if (document.documentElement.dataset.canvasuiHoverRules === "") return; | |
| document.documentElement.dataset.canvasuiHoverRules = ""; | |
| const walk = (rules) => { | |
| for (const rule of Array.from(rules)) { | |
| if (rule instanceof CSSStyleRule) { | |
| if (rule.selectorText.includes(":hover")) { | |
| try { | |
| rule.selectorText = rule.selectorText.replace(/:hover\b/g, HOVER_REWRITE); | |
| } catch (e) {} | |
| } | |
| if (rule.cssRules.length) walk(rule.cssRules); | |
| } else if ("cssRules" in rule) { | |
| try { walk(rule.cssRules); } catch (e) {} | |
| } | |
| } | |
| }; | |
| for (const sheet of Array.from(document.styleSheets)) { | |
| try { walk(sheet.cssRules); } catch (e) {} | |
| } | |
| const style = document.createElement("style"); | |
| style.textContent = `[${CONTENT_ATTR}], [${CONTENT_ATTR}] * { cursor: var(--canvasui-cursor, auto) !important; }`; | |
| document.head.appendChild(style); | |
| } | |
| function createHexFloat(elements, options = {}) { | |
| const config = { ...DEFAULTS, ...options }; | |
| const { source, content, output } = elements; | |
| const gl = output.getContext("webgl2", { | |
| alpha: true, depth: false, stencil: false, antialias: false, premultipliedAlpha: true, | |
| }); | |
| if (!gl || gl.isContextLost()) return null; | |
| const supportsFloatTargets = Boolean( | |
| gl.getExtension("EXT_color_buffer_float") || gl.getExtension("EXT_color_buffer_half_float"), | |
| ); | |
| if (!supportsFloatTargets) return null; | |
| const sourceCtx = source.getContext("2d"); | |
| const paintable = source; | |
| const htmlInCanvas = Boolean( | |
| sourceCtx && | |
| typeof sourceCtx.drawElementImage === "function" && | |
| typeof paintable.requestPaint === "function", | |
| ); | |
| let contentDirty = false; | |
| let wake = () => {}; | |
| if (htmlInCanvas) { | |
| paintable.onpaint = () => { | |
| try { | |
| sourceCtx.reset(); | |
| sourceCtx.drawElementImage(content, 0, 0); | |
| contentDirty = true; | |
| wake(); | |
| } catch (e) {} | |
| }; | |
| } | |
| function compile(type, text) { | |
| const shader = gl.createShader(type); | |
| gl.shaderSource(shader, text); | |
| gl.compileShader(shader); | |
| if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
| console.error("HexFloat shader error:", gl.getShaderInfoLog(shader)); | |
| } | |
| return shader; | |
| } | |
| const vertexShader = compile(gl.VERTEX_SHADER, VERT); | |
| const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG); | |
| const program = gl.createProgram(); | |
| gl.attachShader(program, vertexShader); | |
| gl.attachShader(program, fragmentShader); | |
| gl.linkProgram(program); | |
| const uniforms = {}; | |
| const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); | |
| for (let i = 0; i < count; i++) { | |
| const info = gl.getActiveUniform(program, i); | |
| if (!info) continue; | |
| uniforms[info.name.replace("[0]", "")] = gl.getUniformLocation(program, info.name); | |
| } | |
| const quad = gl.createBuffer(); | |
| gl.bindBuffer(gl.ARRAY_BUFFER, quad); | |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); | |
| gl.enableVertexAttribArray(0); | |
| gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); | |
| const supportsLinear = Boolean(gl.getExtension("OES_texture_float_linear")); | |
| const filtering = supportsLinear ? gl.LINEAR : gl.NEAREST; | |
| const simShaders = []; | |
| const simPrograms = []; | |
| function compileSim(type, text) { | |
| const shader = compile(type, text); | |
| simShaders.push(shader); | |
| return shader; | |
| } | |
| const simVertexShader = compileSim(gl.VERTEX_SHADER, SIM_VERT); | |
| function createSimProgram(fragSource) { | |
| const prog = gl.createProgram(); | |
| gl.attachShader(prog, simVertexShader); | |
| gl.attachShader(prog, compileSim(gl.FRAGMENT_SHADER, fragSource)); | |
| gl.linkProgram(prog); | |
| simPrograms.push(prog); | |
| const u = {}; | |
| const n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS); | |
| for (let i = 0; i < n; i++) { | |
| const info = gl.getActiveUniform(prog, i); | |
| if (!info) continue; | |
| u[info.name] = gl.getUniformLocation(prog, info.name); | |
| } | |
| return { program: prog, uniforms: u }; | |
| } | |
| const splatProgram = createSimProgram(FRAG_SPLAT); | |
| const advectProgram = createSimProgram(FRAG_ADVECT); | |
| const clearProgram = createSimProgram(FRAG_CLEAR); | |
| const divergenceProgram = createSimProgram(FRAG_DIVERGENCE); | |
| const curlProgram = createSimProgram(FRAG_CURL); | |
| const vorticityProgram = createSimProgram(FRAG_VORTICITY); | |
| const pressureProgram = createSimProgram(FRAG_PRESSURE); | |
| const gradientProgram = createSimProgram(FRAG_GRADIENT); | |
| function createTarget(size, internalFormat, format, filter) { | |
| const texture = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, texture); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, size, size, 0, format, gl.HALF_FLOAT, null); | |
| const fbo = gl.createFramebuffer(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); | |
| gl.viewport(0, 0, size, size); | |
| gl.clearColor(0, 0, 0, 1); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| return { fbo, texture, width: size, height: size }; | |
| } | |
| function createDoubleTarget(size, internalFormat, format, filter) { | |
| let read = createTarget(size, internalFormat, format, filter); | |
| let write = createTarget(size, internalFormat, format, filter); | |
| return { | |
| get read() { return read; }, | |
| get write() { return write; }, | |
| swap() { | |
| const t = read; | |
| read = write; | |
| write = t; | |
| }, | |
| }; | |
| } | |
| const velocity = createDoubleTarget(SIM_RES, gl.RG16F, gl.RG, filtering); | |
| const flow = createDoubleTarget(FLOW_RES, gl.R16F, gl.RED, filtering); | |
| const divergence = createTarget(SIM_RES, gl.R16F, gl.RED, gl.NEAREST); | |
| const curl = createTarget(SIM_RES, gl.R16F, gl.RED, gl.NEAREST); | |
| const pressure = createDoubleTarget(SIM_RES, gl.R16F, gl.RED, gl.NEAREST); | |
| const simTexel = 1 / SIM_RES; | |
| function releaseSim() { | |
| [velocity.read, velocity.write, flow.read, flow.write, pressure.read, pressure.write, divergence, curl].forEach((t) => { | |
| gl.deleteFramebuffer(t.fbo); | |
| gl.deleteTexture(t.texture); | |
| }); | |
| simPrograms.forEach((p) => gl.deleteProgram(p)); | |
| simShaders.forEach((s) => gl.deleteShader(s)); | |
| } | |
| function blit(target) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); | |
| gl.viewport(0, 0, target.width, target.height); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); | |
| } | |
| function bindSimTexture(texture, unit) { | |
| gl.activeTexture(gl.TEXTURE0 + unit); | |
| gl.bindTexture(gl.TEXTURE_2D, texture); | |
| return unit; | |
| } | |
| function applySplat(x, y, dx, dy, dye) { | |
| const aspect = output.clientWidth / Math.max(output.clientHeight, 1); | |
| const rUv = Math.max(config.radius, 40) / Math.max(output.clientHeight, 1); | |
| const radius = rUv * rUv * 0.28; | |
| gl.useProgram(splatProgram.program); | |
| gl.uniform1f(splatProgram.uniforms.uAspect, aspect); | |
| gl.uniform2f(splatProgram.uniforms.uPoint, x, y); | |
| gl.uniform1f(splatProgram.uniforms.uRadius, radius); | |
| gl.uniform1i(splatProgram.uniforms.uTarget, bindSimTexture(velocity.read.texture, 0)); | |
| gl.uniform3f(splatProgram.uniforms.uColor, dx, dy, 0); | |
| blit(velocity.write); | |
| velocity.swap(); | |
| gl.uniform1i(splatProgram.uniforms.uTarget, bindSimTexture(flow.read.texture, 0)); | |
| gl.uniform3f(splatProgram.uniforms.uColor, dye, 0, 0); | |
| blit(flow.write); | |
| flow.swap(); | |
| } | |
| function stepSim(delta) { | |
| gl.disable(gl.BLEND); | |
| gl.useProgram(curlProgram.program); | |
| gl.uniform2f(curlProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(curlProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 0)); | |
| blit(curl); | |
| gl.useProgram(vorticityProgram.program); | |
| gl.uniform2f(vorticityProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(vorticityProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 0)); | |
| gl.uniform1i(vorticityProgram.uniforms.uCurl, bindSimTexture(curl.texture, 1)); | |
| gl.uniform1f(vorticityProgram.uniforms.uCurlStrength, Math.max(config.swirl, 0)); | |
| gl.uniform1f(vorticityProgram.uniforms.uDt, SIM_DT); | |
| blit(velocity.write); | |
| velocity.swap(); | |
| gl.useProgram(divergenceProgram.program); | |
| gl.uniform2f(divergenceProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(divergenceProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 0)); | |
| blit(divergence); | |
| gl.useProgram(clearProgram.program); | |
| gl.uniform1i(clearProgram.uniforms.uTexture, bindSimTexture(pressure.read.texture, 0)); | |
| gl.uniform1f(clearProgram.uniforms.uValue, Math.pow(PRESSURE_DECAY, delta * 60)); | |
| blit(pressure.write); | |
| pressure.swap(); | |
| gl.useProgram(pressureProgram.program); | |
| gl.uniform2f(pressureProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(pressureProgram.uniforms.uDivergence, bindSimTexture(divergence.texture, 0)); | |
| for (let i = 0; i < PRESSURE_ITERATIONS; i++) { | |
| gl.uniform1i(pressureProgram.uniforms.uPressure, bindSimTexture(pressure.read.texture, 1)); | |
| blit(pressure.write); | |
| pressure.swap(); | |
| } | |
| gl.useProgram(gradientProgram.program); | |
| gl.uniform2f(gradientProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(gradientProgram.uniforms.uPressure, bindSimTexture(pressure.read.texture, 0)); | |
| gl.uniform1i(gradientProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 1)); | |
| blit(velocity.write); | |
| velocity.swap(); | |
| gl.useProgram(advectProgram.program); | |
| gl.uniform2f(advectProgram.uniforms.texelSize, simTexel, simTexel); | |
| gl.uniform1i(advectProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 0)); | |
| gl.uniform1i(advectProgram.uniforms.uSource, bindSimTexture(velocity.read.texture, 0)); | |
| gl.uniform1f(advectProgram.uniforms.uDt, SIM_DT); | |
| gl.uniform1f(advectProgram.uniforms.uDissipation, Math.pow(VELOCITY_DISSIPATION, delta * 60)); | |
| blit(velocity.write); | |
| velocity.swap(); | |
| gl.uniform1i(advectProgram.uniforms.uVelocity, bindSimTexture(velocity.read.texture, 0)); | |
| gl.uniform1i(advectProgram.uniforms.uSource, bindSimTexture(flow.read.texture, 1)); | |
| const flowDissipation = 0.9 + Math.min(Math.max(config.trail, 0), 1) * 0.08; | |
| gl.uniform1f(advectProgram.uniforms.uDissipation, Math.pow(flowDissipation, delta * 60)); | |
| blit(flow.write); | |
| flow.swap(); | |
| } | |
| function createPostProgram(fragSource) { | |
| const prog = gl.createProgram(); | |
| gl.attachShader(prog, vertexShader); | |
| gl.attachShader(prog, compileSim(gl.FRAGMENT_SHADER, fragSource)); | |
| gl.linkProgram(prog); | |
| simPrograms.push(prog); | |
| const u = {}; | |
| const n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS); | |
| for (let i = 0; i < n; i++) { | |
| const info = gl.getActiveUniform(prog, i); | |
| if (!info) continue; | |
| u[info.name] = gl.getUniformLocation(prog, info.name); | |
| } | |
| return { program: prog, uniforms: u }; | |
| } | |
| const brightProgram = createPostProgram(FRAG_BRIGHT); | |
| const blurProgram = createPostProgram(FRAG_BLUR); | |
| const compositeProgram = createPostProgram(FRAG_COMPOSITE); | |
| let sceneTarget = null; | |
| let bloomA = null; | |
| let bloomB = null; | |
| function createPostTarget(width, height) { | |
| const texture = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, texture); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); | |
| const fbo = gl.createFramebuffer(); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); | |
| return { fbo, texture, width, height }; | |
| } | |
| function releasePost() { | |
| [sceneTarget, bloomA, bloomB].forEach((t) => { | |
| if (!t) return; | |
| gl.deleteFramebuffer(t.fbo); | |
| gl.deleteTexture(t.texture); | |
| }); | |
| sceneTarget = null; | |
| bloomA = null; | |
| bloomB = null; | |
| } | |
| function ensurePost() { | |
| const w = output.width; | |
| const h = output.height; | |
| if (sceneTarget && sceneTarget.width === w && sceneTarget.height === h) return; | |
| releasePost(); | |
| sceneTarget = createPostTarget(w, h); | |
| const bw = Math.max(1, w >> 2); | |
| const bh = Math.max(1, h >> 2); | |
| bloomA = createPostTarget(bw, bh); | |
| bloomB = createPostTarget(bw, bh); | |
| } | |
| const contentTexture = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0])); | |
| let contentMaxX = 1; | |
| let bg = [1, 1, 1]; | |
| const bgProbe = document.createElement("canvas"); | |
| bgProbe.width = bgProbe.height = 1; | |
| const bgCtx = bgProbe.getContext("2d", { willReadFrequently: true }); | |
| function syncBgColor() { | |
| if (!bgCtx) return; | |
| let el = content; | |
| while (el) { | |
| const css = getComputedStyle(el).backgroundColor; | |
| if (css && css !== "transparent") { | |
| bgCtx.clearRect(0, 0, 1, 1); | |
| bgCtx.fillStyle = css; | |
| bgCtx.fillRect(0, 0, 1, 1); | |
| const d = bgCtx.getImageData(0, 0, 1, 1).data; | |
| if (d[3] > 0) { | |
| bg = [d[0] / 255, d[1] / 255, d[2] / 255]; | |
| return; | |
| } | |
| } | |
| el = el.parentElement; | |
| } | |
| bg = [1, 1, 1]; | |
| } | |
| function syncCanvasSize() { | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const width = Math.max(1, Math.round(output.clientWidth * dpr)); | |
| const height = Math.max(1, Math.round(output.clientHeight * dpr)); | |
| if (output.width !== width || output.height !== height) { | |
| output.width = width; | |
| output.height = height; | |
| } | |
| contentMaxX = Math.min(1, Math.max(0.05, content.clientWidth / Math.max(output.clientWidth, 1))); | |
| if (htmlInCanvas) { | |
| const cssWidth = Math.max(1, Math.round(source.clientWidth)); | |
| const cssHeight = Math.max(1, Math.round(source.clientHeight)); | |
| if (source.width !== cssWidth * dpr || source.height !== cssHeight * dpr) { | |
| source.width = cssWidth * dpr; | |
| source.height = cssHeight * dpr; | |
| } | |
| paintable.requestPaint(); | |
| } | |
| } | |
| syncBgColor(); | |
| syncCanvasSize(); | |
| function uploadContent() { | |
| if (!htmlInCanvas || !contentDirty) return; | |
| contentDirty = false; | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); | |
| } | |
| let time = 0; | |
| let pointerOn = false; | |
| let pointerClientX = 0; | |
| let pointerClientY = 0; | |
| let prevFlowX = 0; | |
| let prevFlowY = 0; | |
| let hasPrevFlow = false; | |
| let simActiveUntil = 0; | |
| function gapColor() { | |
| if (config.gapColor !== "auto") return config.gapColor; | |
| const lum = 0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2]; | |
| const k = lum > 0.5 ? 0.55 : 0.35; | |
| return [bg[0] * k, bg[1] * k, bg[2] * k]; | |
| } | |
| function render() { | |
| uploadContent(); | |
| const dpr = output.width / Math.max(output.clientWidth, 1); | |
| const seam = gapColor(); | |
| gl.useProgram(program); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, contentTexture); | |
| gl.uniform1i(uniforms.uContent, 0); | |
| gl.uniform2f(uniforms.uRes, output.width, output.height); | |
| gl.uniform1f(uniforms.uSize, Math.max(config.size, 8) * dpr); | |
| gl.uniform1f(uniforms.uGap, Math.max(config.gap, 0) * dpr); | |
| gl.uniform1f(uniforms.uBevel, Math.max(config.bevel, 0) * dpr); | |
| gl.uniform1f(uniforms.uTilt, (Math.min(Math.max(config.tilt, -30), 30) * Math.PI) / 180); | |
| gl.uniform1f(uniforms.uDist, 2.6 - Math.min(Math.max(config.perspective, 0), 1) * 2.2); | |
| gl.uniform1f(uniforms.uFloat, Math.max(config.float, 0)); | |
| gl.uniform1f(uniforms.uShine, Math.max(config.shine, 0)); | |
| gl.uniform1f(uniforms.uLift, Math.max(config.lift, 0)); | |
| gl.uniform1f(uniforms.uIrid, Math.max(config.iridescence, 0)); | |
| gl.activeTexture(gl.TEXTURE1); | |
| gl.bindTexture(gl.TEXTURE_2D, flow.read.texture); | |
| gl.uniform1i(uniforms.uFlow, 1); | |
| gl.uniform2f(uniforms.uScroll, content.scrollLeft * dpr, content.scrollTop * dpr); | |
| gl.uniform1f(uniforms.uTime, time); | |
| gl.uniform1f(uniforms.uHasContent, htmlInCanvas ? 1 : 0); | |
| gl.uniform1f(uniforms.uMaxX, contentMaxX); | |
| gl.uniform3f(uniforms.uBg, bg[0], bg[1], bg[2]); | |
| gl.uniform3f(uniforms.uGapColor, seam[0], seam[1], seam[2]); | |
| const bloomOn = config.bloom > 0.001; | |
| const usePost = bloomOn || config.grain > 0.001; | |
| if (!usePost) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| gl.viewport(0, 0, output.width, output.height); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); | |
| return; | |
| } | |
| ensurePost(); | |
| blit(sceneTarget); | |
| if (bloomOn) { | |
| gl.useProgram(brightProgram.program); | |
| gl.uniform1i(brightProgram.uniforms.uScene, bindSimTexture(sceneTarget.texture, 0)); | |
| blit(bloomA); | |
| gl.useProgram(blurProgram.program); | |
| gl.uniform1i(blurProgram.uniforms.uScene, bindSimTexture(bloomA.texture, 0)); | |
| gl.uniform2f(blurProgram.uniforms.uDir, 1 / bloomA.width, 0); | |
| blit(bloomB); | |
| gl.uniform1i(blurProgram.uniforms.uScene, bindSimTexture(bloomB.texture, 0)); | |
| gl.uniform2f(blurProgram.uniforms.uDir, 0, 1 / bloomA.height); | |
| blit(bloomA); | |
| } | |
| gl.useProgram(compositeProgram.program); | |
| gl.uniform1i(compositeProgram.uniforms.uScene, bindSimTexture(sceneTarget.texture, 0)); | |
| gl.uniform1i(compositeProgram.uniforms.uBloomTex, bindSimTexture((bloomA || sceneTarget).texture, 1)); | |
| gl.uniform1f(compositeProgram.uniforms.uBloomAmt, bloomOn ? Math.min(Math.max(config.bloom, 0), 1) * 1.4 : 0); | |
| gl.uniform1f(compositeProgram.uniforms.uGrainAmt, Math.min(Math.max(config.grain, 0), 1)); | |
| gl.uniform1f(compositeProgram.uniforms.uTime, time); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| gl.viewport(0, 0, output.width, output.height); | |
| gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); | |
| } | |
| let raf = 0; | |
| let lastTime = performance.now(); | |
| let destroyed = false; | |
| let running = false; | |
| let visible = true; | |
| const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); | |
| let reducedMotion = motionQuery.matches; | |
| function animating() { | |
| if (reducedMotion) return false; | |
| if (config.float > 0) return true; | |
| if (config.grain > 0.001) return true; | |
| if (pointerOn) return true; | |
| if (performance.now() < simActiveUntil) return true; | |
| return false; | |
| } | |
| function frame(now) { | |
| if (destroyed) return; | |
| if (!visible) { | |
| running = false; | |
| return; | |
| } | |
| const delta = Math.min(Math.max((now - lastTime) / 1000, 0), 1 / 30); | |
| lastTime = now; | |
| if (!reducedMotion) { | |
| time += delta * Math.max(config.speed, 0); | |
| if (pointerOn) { | |
| const p = contentPoint(pointerClientX, pointerClientY); | |
| if (p) { | |
| const w = Math.max(output.clientWidth, 1); | |
| const h = Math.max(output.clientHeight, 1); | |
| const fx = p.x / w; | |
| const fy = p.y / h; | |
| const dx = hasPrevFlow ? (fx - prevFlowX) * w : 0; | |
| const dy = hasPrevFlow ? (fy - prevFlowY) * h : 0; | |
| const push = 1.6 * Math.max(config.flow, 0); | |
| applySplat(fx, fy, dx * push, dy * push, 10 * delta); | |
| prevFlowX = fx; | |
| prevFlowY = fy; | |
| hasPrevFlow = true; | |
| simActiveUntil = now + 4000; | |
| } | |
| } | |
| if (now < simActiveUntil || pointerOn) stepSim(delta); | |
| } | |
| render(); | |
| if (!animating() && !contentDirty) { | |
| running = false; | |
| return; | |
| } | |
| raf = requestAnimationFrame(frame); | |
| } | |
| function start() { | |
| if (destroyed || running || !visible) return; | |
| running = true; | |
| lastTime = performance.now(); | |
| raf = requestAnimationFrame(frame); | |
| } | |
| wake = start; | |
| start(); | |
| function onPointerMove(event) { | |
| pointerClientX = event.clientX; | |
| pointerClientY = event.clientY; | |
| pointerOn = true; | |
| simActiveUntil = performance.now() + 4000; | |
| updateHover(event.clientX, event.clientY); | |
| start(); | |
| } | |
| function onPointerLeave() { | |
| pointerOn = false; | |
| hasPrevFlow = false; | |
| setHoverTarget(null); | |
| start(); | |
| } | |
| content.addEventListener("pointermove", onPointerMove, { passive: true }); | |
| content.addEventListener("pointerleave", onPointerLeave, { passive: true }); | |
| // Also listen globally so the HexFloat responds even when UI layers sit above | |
| function onWindowPointerMove(event) { | |
| // update global pointer state and forward into the hex effect | |
| pointerClientX = event.clientX; | |
| pointerClientY = event.clientY; | |
| pointerOn = true; | |
| simActiveUntil = performance.now() + 4000; | |
| updateHover(event.clientX, event.clientY); | |
| start(); | |
| } | |
| window.addEventListener("pointermove", onWindowPointerMove, { passive: true }); | |
| function onScroll() { | |
| if (pointerOn) updateHover(pointerClientX, pointerClientY); | |
| start(); | |
| } | |
| content.addEventListener("scroll", onScroll, { passive: true }); | |
| function contentPoint(clientX, clientY) { | |
| const rect = output.getBoundingClientRect(); | |
| const dpr = output.width / Math.max(output.clientWidth, 1); | |
| const w = output.width; | |
| const hPx = output.height; | |
| if (w < 1 || hPx < 1) return null; | |
| const sx = (clientX - rect.left) * dpr; | |
| const sy = (clientY - rect.top) * dpr; | |
| const aspect = w / hPx; | |
| const ndcX = ((sx / w) * 2 - 1) * aspect; | |
| const ndcY = (sy / hPx) * 2 - 1; | |
| const tilt = (Math.min(Math.max(config.tilt, -30), 30) * Math.PI) / 180; | |
| const sa = Math.sin(tilt); | |
| const ca = Math.cos(tilt); | |
| const cell = Math.max(config.size, 8) * dpr; | |
| const h = hPx / cell; | |
| const dist = 2.6 - Math.min(Math.max(config.perspective, 0), 1) * 2.2; | |
| const d = h * dist; | |
| const focal = (d + Math.sqrt(d * d + h * h * sa * sa)) / (h * ca); | |
| const dy = 0.5 * h - sa * d - (ca * d * (ca - focal * sa)) / (sa + focal * ca); | |
| const scrollX = content.scrollLeft * dpr; | |
| const scrollY = content.scrollTop * dpr; | |
| const roX = scrollX / cell + (0.5 * w) / cell; | |
| const roY = scrollY / cell + 0.5 * h + dy + sa * d; | |
| const roZ = -ca * d; | |
| const rdX = ndcX; | |
| const rdY = ndcY * ca - focal * sa; | |
| const rdZ = ndcY * sa + focal * ca; | |
| if (rdZ < 1e-6) return null; | |
| const t = -roZ / rdZ; | |
| const px = (roX + rdX * t) * cell - scrollX; | |
| const py = (roY + rdY * t) * cell - scrollY; | |
| return { x: px / dpr, y: py / dpr }; | |
| } | |
| let forwarding = false; | |
| let hoverChain = []; | |
| let hoverTarget = null; | |
| if (htmlInCanvas) { | |
| patchHoverRules(); | |
| content.setAttribute(CONTENT_ATTR, ""); | |
| } | |
| function setHoverTarget(target) { | |
| if (target === hoverTarget) return; | |
| hoverTarget = target; | |
| const next = new Set(); | |
| for (let el = target; el; el = el.parentElement) { | |
| next.add(el); | |
| if (el === content) break; | |
| } | |
| for (const el of hoverChain) { | |
| if (!next.has(el)) el.removeAttribute(HOVER_ATTR); | |
| } | |
| for (const el of next) el.setAttribute(HOVER_ATTR, ""); | |
| hoverChain = Array.from(next); | |
| if (target) { | |
| content.style.setProperty("--canvasui-cursor", getComputedStyle(target).cursor); | |
| } else { | |
| content.style.removeProperty("--canvasui-cursor"); | |
| } | |
| } | |
| function updateHover(clientX, clientY) { | |
| if (!htmlInCanvas) return; | |
| const p = contentPoint(clientX, clientY); | |
| if (!p) { | |
| setHoverTarget(null); | |
| return; | |
| } | |
| const rect = content.getBoundingClientRect(); | |
| const target = document.elementFromPoint(rect.left + p.x, rect.top + p.y); | |
| setHoverTarget(target && content.contains(target) ? target : null); | |
| } | |
| function onClick(event) { | |
| if (forwarding || !htmlInCanvas) return; | |
| const p = contentPoint(event.clientX, event.clientY); | |
| if (!p) return; | |
| const rect = content.getBoundingClientRect(); | |
| const tx = rect.left + p.x; | |
| const ty = rect.top + p.y; | |
| if (Math.hypot(tx - event.clientX, ty - event.clientY) < 1.5) return; | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| const target = document.elementFromPoint(tx, ty); | |
| if (!target || !content.contains(target)) return; | |
| const focusable = target.closest("a, button, input, select, textarea, [tabindex]"); | |
| forwarding = true; | |
| try { | |
| if (focusable) focusable.focus(); | |
| target.dispatchEvent(new MouseEvent("click", { | |
| bubbles: true, cancelable: true, view: window, | |
| clientX: tx, clientY: ty, button: event.button, | |
| ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, | |
| altKey: event.altKey, metaKey: event.metaKey, | |
| })); | |
| } finally { | |
| forwarding = false; | |
| } | |
| } | |
| content.addEventListener("click", onClick, true); | |
| function caretAt(x, y) { | |
| const doc = document; | |
| if (typeof doc.caretPositionFromPoint === "function") { | |
| const c = doc.caretPositionFromPoint(x, y); | |
| return c ? { node: c.offsetNode, offset: c.offset } : null; | |
| } | |
| const r = doc.caretRangeFromPoint && doc.caretRangeFromPoint(x, y); | |
| return r ? { node: r.startContainer, offset: r.startOffset } : null; | |
| } | |
| function remapped(event) { | |
| const p = contentPoint(event.clientX, event.clientY); | |
| if (!p) return null; | |
| const rect = content.getBoundingClientRect(); | |
| const tx = rect.left + p.x; | |
| const ty = rect.top + p.y; | |
| if (Math.hypot(tx - event.clientX, ty - event.clientY) < 1.5) return null; | |
| return { x: tx, y: ty }; | |
| } | |
| let selecting = false; | |
| function onMouseDown(event) { | |
| if (forwarding || !htmlInCanvas || event.button !== 0) return; | |
| const m = remapped(event); | |
| if (!m) return; | |
| event.preventDefault(); | |
| const caret = caretAt(m.x, m.y); | |
| if (!caret || !content.contains(caret.node)) return; | |
| const sel = window.getSelection(); | |
| if (!sel) return; | |
| sel.removeAllRanges(); | |
| sel.collapse(caret.node, caret.offset); | |
| selecting = true; | |
| } | |
| function onSelMove(event) { | |
| if (!selecting) return; | |
| if (!(event.buttons & 1)) { | |
| selecting = false; | |
| return; | |
| } | |
| const m = remapped(event); | |
| const caret = m ? caretAt(m.x, m.y) : null; | |
| const sel = window.getSelection(); | |
| if (caret && sel && sel.anchorNode && content.contains(caret.node)) { | |
| sel.extend(caret.node, caret.offset); | |
| } | |
| } | |
| function onSelEnd() { | |
| selecting = false; | |
| } | |
| content.addEventListener("mousedown", onMouseDown, true); | |
| window.addEventListener("mousemove", onSelMove, true); | |
| window.addEventListener("mouseup", onSelEnd, true); | |
| function onMotionChange() { | |
| reducedMotion = motionQuery.matches; | |
| if (reducedMotion) { | |
| pointerOn = false; | |
| hasPrevFlow = false; | |
| simActiveUntil = 0; | |
| } | |
| start(); | |
| } | |
| motionQuery.addEventListener("change", onMotionChange); | |
| const observer = new ResizeObserver(() => { | |
| syncCanvasSize(); | |
| start(); | |
| }); | |
| observer.observe(output); | |
| observer.observe(content); | |
| const intersection = new IntersectionObserver((entries) => { | |
| visible = entries[entries.length - 1] ? entries[entries.length - 1].isIntersecting : true; | |
| if (visible) start(); | |
| }); | |
| intersection.observe(output); | |
| const mutation = new MutationObserver(() => { | |
| syncBgColor(); | |
| start(); | |
| }); | |
| mutation.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] }); | |
| return { | |
| setOptions(next) { | |
| if (!Object.entries(next).some(([key, value]) => config[key] !== value)) return; | |
| Object.assign(config, next); | |
| start(); | |
| }, | |
| resize() { | |
| syncCanvasSize(); | |
| start(); | |
| }, | |
| destroy() { | |
| destroyed = true; | |
| cancelAnimationFrame(raf); | |
| setHoverTarget(null); | |
| content.removeAttribute(CONTENT_ATTR); | |
| content.removeEventListener("pointermove", onPointerMove); | |
| content.removeEventListener("pointerleave", onPointerLeave); | |
| content.removeEventListener("scroll", onScroll); | |
| content.removeEventListener("click", onClick, true); | |
| content.removeEventListener("mousedown", onMouseDown, true); | |
| window.removeEventListener("pointermove", onWindowPointerMove, { passive: true }); | |
| window.removeEventListener("mousemove", onSelMove, true); | |
| window.removeEventListener("mouseup", onSelEnd, true); | |
| observer.disconnect(); | |
| intersection.disconnect(); | |
| mutation.disconnect(); | |
| motionQuery.removeEventListener("change", onMotionChange); | |
| gl.deleteTexture(contentTexture); | |
| releaseSim(); | |
| releasePost(); | |
| gl.deleteProgram(program); | |
| gl.deleteShader(vertexShader); | |
| gl.deleteShader(fragmentShader); | |
| gl.deleteBuffer(quad); | |
| if (htmlInCanvas) paintable.onpaint = null; | |
| }, | |
| }; | |
| } | |
| window.createHexFloat = createHexFloat; | |
| })(); | |
| </script> | |
| <script> | |
| /* ============================================================ | |
| EZ.AI — app logic + Canvas UI effect wiring | |
| ============================================================ */ | |
| const $ = (id) => document.getElementById(id); | |
| const chats = JSON.parse(localStorage.getItem("fakeChats") || "{}"); | |
| let currentChatId = null; | |
| let isAITyping = false; | |
| let typingBubble = null; | |
| /* ---------- Canvas UI effect setup ---------- */ | |
| function setupFX() { | |
| const native = supportsHtmlInCanvas(); | |
| const hexStage = $("hexStage"); | |
| const hexContent = $("hexContent"); | |
| const applyHex = () => { | |
| if (!native) return false; | |
| const source = document.createElement("canvas"); | |
| source.setAttribute("layoutsubtree", "true"); | |
| const output = document.createElement("canvas"); | |
| output.setAttribute("aria-hidden", "true"); | |
| source.style.position = "absolute"; | |
| source.style.inset = "0"; | |
| source.style.width = "100%"; | |
| source.style.height = "100%"; | |
| Object.assign(output.style, { | |
| position: "absolute", inset: "0", width: "100%", height: "100%", pointerEvents: "none", | |
| }); | |
| hexContent.style.overflow = "auto"; | |
| source.appendChild(hexContent); | |
| hexStage.appendChild(source); | |
| hexStage.appendChild(output); | |
| let inst = null; | |
| try { | |
| inst = window.createHexFloat( | |
| { source, content: hexContent, output }, | |
| { | |
| size: 100, gap: 0, bevel: 1.5, tilt: 30, perspective: 0.5, | |
| float: 1, speed: 1, shine: 0.5, lift: 0.15, | |
| radius: 700, flow: 1.2, swirl: 0.2, trail: 0.4, | |
| iridescence: 1, bloom: 0, grain: 0, gapColor: "auto", | |
| }, | |
| ); | |
| } catch (e) { console.error(e); } | |
| if (!inst) { | |
| hexStage.appendChild(hexContent); | |
| source.remove(); | |
| output.remove(); | |
| return false; | |
| } | |
| return true; | |
| }; | |
| let hexOk = false; | |
| if (native) { | |
| hexOk = applyHex(); | |
| } | |
| // HexFloat has no standalone WebGL fallback in the real Canvas UI library — | |
| // it needs the live html-in-canvas API. Without it, content just renders as | |
| // regular HTML (it already does, untouched, since applyHex() never ran). | |
| if (!hexOk) { | |
| document.body.classList.add("no-fx"); | |
| console.info( | |
| "[EZ.AI] HexFloat needs Chrome's html-in-canvas API. " + | |
| "Enable chrome://flags/#canvas-draw-element (Chrome Canary / Brave 147+) and restart to see the real effect locally. " + | |
| "In production, register a Chrome Origin Trial token for your domain instead.", | |
| ); | |
| } | |
| const badges = document.querySelectorAll("[data-fx-state]"); | |
| badges.forEach((b) => { | |
| b.textContent = hexOk ? "HEX" : "OFF · needs canvas-draw-element"; | |
| }); | |
| // Peel reveal wiring: the real Canvas UI `createPeel` engine, using the | |
| // same working pattern as HexFloat — MOVE the real #peelContent element | |
| // into the engine's hidden source canvas (not a lifeless innerHTML copy), | |
| // so the alpha-transparent curled-away region genuinely exposes the real, | |
| // clickable #sidebarPanel underneath instead of another opaque duplicate. | |
| const peelEl = $("peel"); | |
| if (peelEl) { | |
| const computed = getComputedStyle(document.documentElement); | |
| const defaults = { | |
| side: "left", | |
| mode: "cursor", | |
| reveal: parseInt(computed.getPropertyValue("--peel-reveal")) || 300, | |
| zone: parseInt(computed.getPropertyValue("--peel-zone")) || 200, | |
| curl: parseInt(computed.getPropertyValue("--peel-curl")) || 300, | |
| bow: parseInt(computed.getPropertyValue("--peel-bow")) || 75, | |
| shade: parseFloat(computed.getPropertyValue("--peel-shade")) || 0.25, | |
| shine: parseFloat(computed.getPropertyValue("--peel-shine")) || 1, | |
| shineDistance: parseInt(computed.getPropertyValue("--peel-shine-distance")) || 1200, | |
| bulge: parseInt(computed.getPropertyValue("--peel-bulge")) || 50, | |
| perspective: parseInt(computed.getPropertyValue("--peel-perspective")) || 2000, | |
| smoothing: parseFloat(computed.getPropertyValue("--peel-smoothing")) || 0.3, | |
| }; | |
| let peelOk = false; | |
| if (native && typeof createPeel === "function") { | |
| const mainAreaEl = document.getElementById("mainArea"); | |
| const peelContent = document.getElementById("peelContent"); | |
| const source = document.createElement("canvas"); | |
| source.setAttribute("layoutsubtree", "true"); | |
| // pointer-events:none here is essential: without it this canvas's full- | |
| // viewport hit-test box blocks clicks to the real #sidebarPanel (and to | |
| // the hex background) underneath, even in areas that render transparent. | |
| // Descendant controls (.neon-input, .send-btn, .chip, .hbtn, #chatWindow) | |
| // already opt back in via pointer-events:auto in the stylesheet. | |
| Object.assign(source.style, { position: "absolute", inset: "0", width: "100%", height: "100%", pointerEvents: "none" }); | |
| const output = document.createElement("canvas"); | |
| output.setAttribute("aria-hidden", "true"); | |
| Object.assign(output.style, { position: "absolute", inset: "0", width: "100%", height: "100%", pointerEvents: "none" }); | |
| const under = document.getElementById("sidebarPanel"); | |
| try { | |
| // Move (not copy) the real, live content into the source canvas — | |
| // its box in #mainArea goes empty, and the peel's output canvas | |
| // becomes the sole visual representation of that area. | |
| source.appendChild(peelContent); | |
| mainAreaEl.appendChild(source); | |
| mainAreaEl.appendChild(output); | |
| const peelInstance = createPeel({ source, content: peelContent, output, under }, defaults); | |
| if (!peelInstance) { | |
| throw new Error("createPeel returned null"); | |
| } | |
| window._canvasui_peel = peelInstance; | |
| const flap = document.getElementById("peelFlap"); | |
| if (flap) flap.style.display = "none"; | |
| peelOk = true; | |
| } catch (err) { | |
| console.error("Failed to initialize real Peel engine, reverting to plain content:", err); | |
| // undo the move so content is still visible/usable | |
| mainAreaEl.appendChild(peelContent); | |
| source.remove(); | |
| output.remove(); | |
| } | |
| } | |
| if (!peelOk) { | |
| // No html-in-canvas support (or the real engine failed): degrade to a | |
| // lightweight CSS flap so hovering the left edge still reveals the | |
| // sidebar, just without the WebGL curl/shine. | |
| console.info("Real Peel engine unavailable; enabling fallback peel visuals."); | |
| (function initFallbackPeel() { | |
| const zone = defaults.zone || 200; | |
| const revealPx = defaults.reveal || 300; | |
| let target = 0; | |
| let progress = 0; | |
| let persistent = false; | |
| const flap = document.getElementById('peelFlap'); | |
| const main = document.getElementById('mainArea'); | |
| let raf = null; | |
| function updateFrame() { | |
| const diff = target - progress; | |
| const step = Math.sign(diff) * Math.min(Math.abs(diff), 0.08 + Math.abs(diff) * (defaults.smoothing || 0.3)); | |
| progress += step; | |
| const px = Math.round(progress * revealPx); | |
| document.documentElement.style.setProperty('--peel-progress', String(px)); | |
| document.documentElement.style.setProperty('--peel-translate', `${px}px`); | |
| if (main) main.style.clipPath = `inset(0px 0px 0px ${px}px)`; | |
| if (flap) { | |
| flap.style.width = `${px}px`; | |
| const rot = -((defaults.curl || 300) / 20) * progress; | |
| const tx = px * 0.5; | |
| const bowOffset = ((defaults.bow || 75) / 200) * (0) * px; | |
| flap.style.transform = `translateX(${tx}px) translateY(${bowOffset}px) rotateY(${rot}deg)`; | |
| flap.style.opacity = progress > 0.01 ? '1' : '0'; | |
| } | |
| if (Math.abs(diff) > 0.001) raf = requestAnimationFrame(updateFrame); | |
| else raf = null; | |
| } | |
| function onPointer(e) { | |
| const rect = main ? main.getBoundingClientRect() : { left: 0, width: window.innerWidth, top: 0, height: window.innerHeight }; | |
| const localX = e.clientX - rect.left; | |
| const clamped = Math.max(0, zone - localX); | |
| const p = Math.min(1, clamped / Math.max(1, zone)); | |
| target = p; | |
| if (!raf) raf = requestAnimationFrame(updateFrame); | |
| if (p > 0.02) document.body.classList.add('peel-open'); | |
| else if (!persistent) document.body.classList.remove('peel-open'); | |
| } | |
| function onLeave() { | |
| target = 0; | |
| if (!persistent) { | |
| setTimeout(() => { if (!persistent) document.body.classList.remove('peel-open'); }, 180); | |
| } | |
| if (!raf) raf = requestAnimationFrame(updateFrame); | |
| } | |
| function onClick() { | |
| persistent = !persistent; | |
| document.body.classList.toggle('peel-open', persistent); | |
| if (!persistent) target = 0; | |
| } | |
| window.addEventListener('pointermove', onPointer, { passive: true }); | |
| window.addEventListener('pointerleave', onLeave, { passive: true }); | |
| peelEl.addEventListener('click', onClick); | |
| })(); | |
| } | |
| } | |
| } | |
| // ensure peelFlap has inner elements for shading/shine | |
| (() => { | |
| const flap = document.getElementById("peelFlap"); | |
| if (!flap) return; | |
| if (!flap.querySelector('.flap-inner')) { | |
| const inner = document.createElement('div'); inner.className = 'flap-inner'; | |
| const shade = document.createElement('div'); shade.className = 'flap-shade'; | |
| const shine = document.createElement('div'); shine.className = 'flap-shine'; | |
| inner.appendChild(shade); | |
| inner.appendChild(shine); | |
| flap.appendChild(inner); | |
| } | |
| })(); | |
| /* ---------- chat state ---------- */ | |
| function saveChats() { | |
| localStorage.setItem("fakeChats", JSON.stringify(chats)); | |
| } | |
| function renderChatList() { | |
| const list = $("chatList"); | |
| list.innerHTML = ""; | |
| Object.keys(chats).forEach((id) => { | |
| const div = document.createElement("div"); | |
| div.className = "chat-item"; | |
| const dot = document.createElement("span"); | |
| dot.className = "dot"; | |
| const txt = document.createElement("span"); | |
| txt.className = "txt"; | |
| txt.textContent = chats[id].name; | |
| div.appendChild(dot); | |
| div.appendChild(txt); | |
| div.onclick = () => loadChat(id); | |
| div.oncontextmenu = (e) => { | |
| e.preventDefault(); | |
| showContextMenu(e.pageX, e.pageY, id); | |
| }; | |
| list.appendChild(div); | |
| }); | |
| } | |
| function switchToChatUI() { | |
| $("homeScreen").classList.remove("active"); | |
| $("chatView").classList.add("active"); | |
| } | |
| function switchToHomeUI() { | |
| $("chatView").classList.remove("active"); | |
| $("homeScreen").classList.add("active"); | |
| } | |
| async function createChatAndSendFirstMessage(text) { | |
| const id = Date.now().toString(); | |
| chats[id] = { name: text.slice(0, 30) || "New Chat", messages: [] }; | |
| saveChats(); | |
| renderChatList(); | |
| loadChat(id); | |
| addMessage(text, "user"); | |
| await sendToAI(); | |
| } | |
| function buildPromptUnderLimit(messages, maxChars = 4600) { | |
| let total = 0; | |
| const result = []; | |
| for (let i = messages.length - 1; i >= 0; i--) { | |
| const m = messages[i]; | |
| const prefix = m.role === "user" ? "User: " : "Assistant: "; | |
| const block = prefix + m.text + "\n\n"; | |
| const encodedLength = encodeURIComponent(block).length; | |
| if (total + encodedLength > maxChars) break; | |
| result.unshift(block); | |
| total += encodedLength; | |
| } | |
| return result.join("").trim(); | |
| } | |
| async function sendToAI(latestUserText = null) { | |
| if (!currentChatId || !chats[currentChatId] || isAITyping) return; | |
| isAITyping = true; | |
| showTypingBubble(); | |
| let promptText; | |
| if (typeof latestUserText === "string" && latestUserText.trim() !== "") { | |
| const tempMessages = [ | |
| ...chats[currentChatId].messages, | |
| { role: "user", text: latestUserText }, | |
| ]; | |
| promptText = buildPromptUnderLimit(tempMessages); | |
| } else { | |
| promptText = buildPromptUnderLimit(chats[currentChatId].messages); | |
| } | |
| try { | |
| const res = await fetch("https://text.pollinations.ai/" + encodeURIComponent(promptText)); | |
| const reply = await res.text(); | |
| removeTypingBubble(); | |
| addMessage(reply, "bot"); | |
| } catch (err) { | |
| removeTypingBubble(); | |
| addMessage("⚠️ Error generating response.", "bot"); | |
| } finally { | |
| isAITyping = false; | |
| } | |
| } | |
| function showTypingBubble() { | |
| if (typingBubble) return; | |
| const win = $("chatWindow"); | |
| const row = document.createElement("div"); | |
| row.className = "bot-row"; | |
| const avatar = document.createElement("div"); | |
| avatar.className = "bot-avatar"; | |
| avatar.textContent = "E"; | |
| const dots = document.createElement("div"); | |
| dots.className = "typing-dots"; | |
| dots.innerHTML = "<span></span><span></span><span></span>"; | |
| row.appendChild(avatar); | |
| row.appendChild(dots); | |
| typingBubble = row; | |
| win.appendChild(row); | |
| win.scrollTop = win.scrollHeight; | |
| } | |
| function removeTypingBubble() { | |
| if (typingBubble) { | |
| typingBubble.remove(); | |
| typingBubble = null; | |
| } | |
| } | |
| function loadChat(id) { | |
| currentChatId = id; | |
| switchToChatUI(); | |
| $("chatTitle").textContent = chats[id].name; | |
| const win = $("chatWindow"); | |
| win.innerHTML = ""; | |
| chats[id].messages.forEach((msg) => addMessage(msg.text, msg.role, false)); | |
| } | |
| function addMessage(text, role, save = true) { | |
| const win = $("chatWindow"); | |
| let node; | |
| if (role === "bot") { | |
| const row = document.createElement("div"); | |
| row.className = "bot-row"; | |
| const avatar = document.createElement("div"); | |
| avatar.className = "bot-avatar"; | |
| avatar.textContent = "E"; | |
| const md = document.createElement("div"); | |
| md.className = "bot-message"; | |
| const body = document.createElement("div"); | |
| body.className = "markdown-body"; | |
| body.innerHTML = marked.parse(text); | |
| md.appendChild(body); | |
| row.appendChild(avatar); | |
| row.appendChild(md); | |
| node = row; | |
| } else { | |
| node = document.createElement("div"); | |
| node.className = "user-bubble"; | |
| node.textContent = text; | |
| } | |
| win.appendChild(node); | |
| win.scrollTop = win.scrollHeight; | |
| if (save && currentChatId && chats[currentChatId]) { | |
| chats[currentChatId].messages.push({ text, role }); | |
| saveChats(); | |
| } | |
| } | |
| /* ---------- input ---------- */ | |
| async function submit(text) { | |
| if (isAITyping) return; | |
| const t = text.trim(); | |
| if (!t) return; | |
| if (!currentChatId) { | |
| createChatAndSendFirstMessage(t); | |
| return; | |
| } | |
| addMessage(t, "user"); | |
| await sendToAI(); | |
| } | |
| function adjustHeight() { | |
| this.style.height = "auto"; | |
| this.style.height = this.scrollHeight + "px"; | |
| } | |
| $("sendBtn").onclick = () => { | |
| const input = $("promptInput"); | |
| const t = input.value; | |
| input.value = ""; | |
| input.style.height = "44px"; | |
| submit(t); | |
| }; | |
| $("homeSend").onclick = () => { | |
| const input = $("homeInput"); | |
| const t = input.value; | |
| input.value = ""; | |
| input.style.height = "44px"; | |
| submit(t); | |
| }; | |
| const promptInput = $("promptInput"); | |
| const homeInput = $("homeInput"); | |
| [promptInput, homeInput].forEach((ta) => { | |
| ta.addEventListener("input", adjustHeight); | |
| ta.addEventListener("keydown", (e) => { | |
| if (isAITyping) { e.preventDefault(); return; } | |
| if (e.key === "Enter") { | |
| if (e.shiftKey) { | |
| setTimeout(adjustHeight, 0); | |
| } else { | |
| e.preventDefault(); | |
| const t = ta.value; | |
| ta.value = ""; | |
| ta.style.height = "44px"; | |
| submit(t); | |
| } | |
| } | |
| }); | |
| }); | |
| document.querySelectorAll(".chip").forEach((chip) => { | |
| chip.onclick = () => { | |
| if (isAITyping) return; | |
| const t = chip.textContent; | |
| if (!currentChatId) { | |
| createChatAndSendFirstMessage(t); | |
| } else { | |
| addMessage(t, "user"); | |
| sendToAI(); | |
| } | |
| }; | |
| }); | |
| $("newChatBtn").onclick = () => { | |
| currentChatId = null; | |
| $("chatTitle").textContent = "New conversation"; | |
| switchToHomeUI(); | |
| }; | |
| /* ---------- context menu ---------- */ | |
| function showContextMenu(x, y, chatId) { | |
| let menu = $("ctxMenu"); | |
| if (!menu) { | |
| menu = document.createElement("div"); | |
| menu.id = "ctxMenu"; | |
| document.body.appendChild(menu); | |
| } | |
| menu.innerHTML = ""; | |
| const rename = document.createElement("div"); | |
| rename.textContent = "Rename"; | |
| rename.onclick = () => { | |
| const newName = prompt("Rename chat:", chats[chatId].name); | |
| if (newName) { | |
| chats[chatId].name = newName; | |
| saveChats(); | |
| renderChatList(); | |
| if (currentChatId === chatId) $("chatTitle").textContent = newName; | |
| } | |
| menu.remove(); | |
| }; | |
| const del = document.createElement("div"); | |
| del.textContent = "Delete"; | |
| del.className = "danger"; | |
| del.onclick = () => { | |
| delete chats[chatId]; | |
| saveChats(); | |
| renderChatList(); | |
| if (currentChatId === chatId) { | |
| currentChatId = null; | |
| $("chatTitle").textContent = "New conversation"; | |
| switchToHomeUI(); | |
| } | |
| menu.remove(); | |
| }; | |
| menu.appendChild(rename); | |
| menu.appendChild(del); | |
| menu.style.left = x + "px"; | |
| menu.style.top = y + "px"; | |
| document.addEventListener("click", () => menu.remove(), { once: true }); | |
| } | |
| /* ---------- init ---------- */ | |
| setupFX(); | |
| switchToHomeUI(); | |
| renderChatList(); | |
| /* ---------- self-test (temp, hash-gated) ---------- */ | |
| if (location.hash === "#selftest") { | |
| const out = {}; | |
| out.native = supportsHtmlInCanvas(); | |
| out.noFx = document.body.classList.contains("no-fx"); | |
| out.hexOutputCanvas = !!document.querySelector('#hexStage canvas[aria-hidden="true"]'); | |
| out.note = out.native | |
| ? "html-in-canvas is supported — HexFloat should be live." | |
| : "html-in-canvas NOT supported here. Needs chrome://flags/#canvas-draw-element (Chrome Canary / Brave 147+) locally, or an Origin Trial token on a real deployed domain."; | |
| const pre = document.createElement("pre"); | |
| pre.id = "selftest"; | |
| pre.style.cssText = "position:fixed;top:0;left:0;z-index:99999;color:#0f0;background:#000;padding:8px;font:12px monospace;max-width:90vw;white-space:pre-wrap;"; | |
| pre.textContent = JSON.stringify(out, null, 2); | |
| document.body.appendChild(pre); | |
| } | |
| </script> | |
| </body></html> |