EZAI / index.html
Sebebeb's picture
Update index.html
a191aca verified
Raw History Blame Contribute Delete
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&amp;family=Space+Grotesk:wght@400;500;600;700&amp;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) !important; }</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>