| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> |
| <meta name="color-scheme" content="dark" /> |
| <title>Gemma 4 E2B · WebGPU</title> |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚡️</text></svg>"> |
| <meta name="description" content="Gemma 4 E2B (QAT Mobile) running fully in your browser on WebGPU. Every kernel written and optimized by Fable 5." /> |
| <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=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet"> |
| <script type="importmap"> |
| { |
| "imports": { |
| "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js", |
| "three/examples/jsm/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/" |
| } |
| } |
| </script> |
| <style> |
| :root { |
| --bg: #020203; |
| --t1: rgba(255, 255, 255, 0.92); |
| --t2: rgba(255, 255, 255, 0.55); |
| --t3: rgba(255, 255, 255, 0.32); |
| --t4: rgba(255, 255, 255, 0.20); |
| --line: rgba(255, 255, 255, 0.08); |
| --line-soft: rgba(255, 255, 255, 0.06); |
| --panel: rgba(255, 255, 255, 0.03); |
| --panel-hover: rgba(255, 255, 255, 0.06); |
| --ok: #64ffa0; |
| --warn: #ffcd6b; |
| --danger: #ff7a6b; |
| --user-bubble: rgba(255, 255, 255, 0.065); |
| |
| --display: "Instrument Serif", Georgia, "Times New Roman", serif; |
| --body: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| --mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; |
| --maxw: 820px; |
| } |
| |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| html { height: 100%; } |
| |
| body { |
| background: var(--bg); |
| color: var(--t1); |
| font-family: var(--body); |
| font-size: 16px; |
| font-weight: 400; |
| line-height: 1.6; |
| -webkit-font-smoothing: antialiased; |
| height: 100dvh; |
| overflow-x: hidden; |
| overflow-y: scroll; |
| scroll-behavior: smooth; |
| } |
| |
| button, textarea, input { font: inherit; color: inherit; } |
| button { appearance: none; background: none; border: 0; cursor: pointer; } |
| a { color: inherit; text-decoration: none; } |
| |
| ::selection { background: rgba(255, 255, 255, 0.18); } |
| |
| body::-webkit-scrollbar { width: 0; } |
| |
| .screen { min-height: 100dvh; position: relative; width: 100%; } |
| |
| |
| |
| |
| |
| #landing { |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| #crt-frame { |
| position: absolute; |
| inset: 0; |
| z-index: 0; |
| overflow: hidden; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 2.4s cubic-bezier(0.25, 0.1, 0.25, 1) 0.6s; |
| } |
| #crt-frame.visible { opacity: 1; } |
| #crt-frame canvas { display: block; width: 100% !important; height: 100% !important; pointer-events: none; } |
| |
| .hero-fade { |
| position: absolute; |
| bottom: 0; left: 0; |
| width: 100%; height: 68%; |
| background: linear-gradient(to top, var(--bg) 0%, var(--bg) 14%, rgba(2, 2, 3, 0.55) 52%, transparent 100%); |
| z-index: 1; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 2s ease 1s; |
| } |
| .hero-fade.in { opacity: 1; } |
| |
| .hero-overlay { |
| position: relative; |
| z-index: 2; |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| pointer-events: none; |
| min-height: 100dvh; |
| } |
| |
| |
| .brand-name { |
| font-family: var(--mono); |
| font-size: 12px; |
| font-weight: 500; |
| letter-spacing: 0.22em; |
| text-transform: uppercase; |
| color: var(--t1); |
| } |
| |
| |
| .hero-content { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| justify-content: flex-end; |
| padding: 0 48px 18px; |
| pointer-events: none; |
| } |
| .eyebrow { |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| width: fit-content; |
| font-family: var(--mono); |
| font-size: 11px; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| color: var(--t3); |
| margin-bottom: 22px; |
| text-shadow: 0 2px 16px var(--bg); |
| } |
| .eyebrow::before { |
| content: ""; |
| flex-shrink: 0; |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--ok); |
| box-shadow: 0 0 12px rgba(100, 255, 160, 0.5); |
| animation: statusPulse 2.4s ease-in-out infinite; |
| } |
| .hero-h1 { |
| font-family: var(--display); |
| font-size: clamp(44px, 7.6vw, 100px); |
| font-weight: 400; |
| line-height: 0.98; |
| letter-spacing: -0.015em; |
| color: #fff; |
| margin-bottom: 30px; |
| max-width: 26ch; |
| text-shadow: 0 3px 40px var(--bg), 0 1px 14px rgba(2, 2, 3, 0.85); |
| } |
| .hero-h1 .thin { color: var(--t3); } |
| |
| |
| .hero-row { |
| display: flex; |
| align-items: flex-end; |
| justify-content: space-between; |
| gap: 56px; |
| } |
| .hero-main { display: flex; flex-direction: column; min-width: 0; } |
| .hero-sub { |
| font-size: 15px; |
| font-weight: 300; |
| line-height: 1.7; |
| color: var(--t2); |
| max-width: 52ch; |
| text-shadow: 0 2px 16px var(--bg), 0 1px 30px var(--bg); |
| margin-bottom: 26px; |
| } |
| .hero-sub b { color: var(--t1); font-weight: 500; } |
| .hero-stats { display: flex; flex-wrap: wrap; gap: 36px; } |
| .stat { display: flex; flex-direction: column; gap: 5px; } |
| .stat-val { |
| font-family: var(--display); |
| font-size: 30px; |
| font-weight: 400; |
| letter-spacing: -0.01em; |
| color: var(--t1); |
| line-height: 1; |
| text-shadow: 0 2px 16px var(--bg); |
| } |
| .stat-label { |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| color: var(--t3); |
| } |
| |
| |
| .hero-side { |
| display: flex; |
| flex-direction: column; |
| align-items: flex-end; |
| gap: 18px; |
| flex-shrink: 0; |
| text-align: right; |
| } |
| .hero-actions { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| pointer-events: auto; |
| } |
| |
| .btn-primary { |
| position: relative; |
| display: inline-flex; |
| align-items: center; |
| gap: 10px; |
| padding: 14px 28px; |
| color: #000; |
| font-size: 13.5px; |
| font-weight: 500; |
| letter-spacing: 0.01em; |
| border-radius: 100px; |
| border: 1px solid #fff; |
| white-space: nowrap; |
| overflow: hidden; |
| isolation: isolate; |
| transition: opacity 0.2s ease; |
| } |
| .btn-primary::before { |
| content: ""; |
| position: absolute; |
| inset: 0; |
| background: #fff; |
| border-radius: inherit; |
| transform: scaleX(1); |
| transform-origin: left center; |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
| z-index: -1; |
| } |
| .btn-primary:hover:not(:disabled)::before { transform: scaleX(0); transform-origin: right center; } |
| .btn-primary:hover:not(:disabled) { color: #fff; } |
| .btn-primary svg { width: 14px; height: 14px; } |
| .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| .btn-secondary { |
| display: inline-flex; |
| align-items: center; |
| gap: 9px; |
| padding: 14px 26px; |
| color: var(--t2); |
| font-size: 13.5px; |
| font-weight: 400; |
| border-radius: 100px; |
| border: 1px solid var(--line); |
| white-space: nowrap; |
| transition: border-color 0.25s ease, color 0.25s ease; |
| } |
| .btn-secondary:hover { border-color: rgba(255, 255, 255, 0.4); color: var(--t1); } |
| .btn-secondary svg { width: 12px; height: 12px; } |
| |
| |
| .hero-foot { |
| display: flex; |
| flex-direction: column; |
| align-items: flex-end; |
| gap: 8px; |
| pointer-events: auto; |
| } |
| .kernel-note-cta { |
| width: fit-content; |
| font-size: 13px; |
| font-weight: 400; |
| color: var(--t2); |
| border-bottom: 1px solid var(--line); |
| padding-bottom: 2px; |
| transition: color 0.2s ease, border-color 0.2s ease; |
| } |
| .kernel-note-cta b { color: var(--t1); font-weight: 600; } |
| .kernel-note-cta:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.4); } |
| .hero-foot-note { |
| font-family: var(--mono); |
| font-size: 10px; |
| letter-spacing: 0.06em; |
| color: var(--t4); |
| } |
| |
| |
| .scroll-cue { |
| flex: 0 0 auto; |
| align-self: center; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 9px; |
| padding-bottom: 18px; |
| pointer-events: none; |
| opacity: 0; |
| transition: opacity 0.8s ease 1.8s; |
| } |
| .scroll-cue.in { opacity: 1; } |
| .scroll-cue span { |
| font-family: var(--mono); |
| font-size: 9.5px; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| color: var(--t4); |
| } |
| .scroll-cue-line { width: 1px; height: 30px; background: var(--line); position: relative; overflow: hidden; } |
| .scroll-cue-line::after { |
| content: ""; |
| position: absolute; |
| top: -100%; left: 0; |
| width: 100%; height: 100%; |
| background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.4), transparent); |
| animation: scrollDrop 2s ease-in-out infinite; |
| } |
| |
| |
| |
| |
| |
| #chat { |
| display: flex; |
| flex-direction: column; |
| height: 100dvh; |
| background: |
| radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.035), transparent 55%), |
| var(--bg); |
| } |
| |
| .chat-head { |
| position: relative; |
| flex: 0 0 auto; |
| border-bottom: 1px solid var(--line-soft); |
| background: rgba(2, 2, 3, 0.72); |
| backdrop-filter: blur(12px); |
| -webkit-backdrop-filter: blur(12px); |
| } |
| .chat-head-inner { |
| margin: 0 auto; |
| max-width: var(--maxw); |
| padding: 16px 28px; |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| } |
| .to-top { |
| display: inline-flex; |
| align-items: center; |
| gap: 9px; |
| flex-shrink: 0; |
| transition: opacity 0.2s ease; |
| } |
| .to-top:hover { opacity: 0.7; } |
| .to-top svg { width: 13px; height: 13px; color: var(--t3); } |
| .to-top .brand-name { font-size: 11px; } |
| |
| .status { |
| flex: 1; |
| min-width: 0; |
| display: flex; |
| align-items: center; |
| gap: 9px; |
| color: var(--t2); |
| font-size: 13px; |
| } |
| .status-dot { |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--t4); |
| flex-shrink: 0; |
| transition: background 0.3s ease, box-shadow 0.3s ease; |
| } |
| .status.loading .status-dot { background: var(--warn); box-shadow: 0 0 10px rgba(255, 205, 107, 0.5); } |
| .status.ready .status-dot { background: var(--ok); box-shadow: 0 0 10px rgba(100, 255, 160, 0.5); } |
| .status.busy .status-dot { background: var(--ok); box-shadow: 0 0 10px rgba(100, 255, 160, 0.5); animation: statusPulse 1.1s ease-in-out infinite; } |
| .status.error .status-dot { background: var(--danger); box-shadow: 0 0 10px rgba(255, 122, 107, 0.5); } |
| .status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } |
| .status-text strong { color: var(--t1); font-weight: 500; } |
| |
| .chat-head-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } |
| .head-btn { |
| display: inline-flex; |
| align-items: center; |
| gap: 7px; |
| padding: 8px 15px; |
| font-family: var(--mono); |
| font-size: 11px; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| color: var(--t2); |
| border: 1px solid var(--line); |
| border-radius: 100px; |
| transition: border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease; |
| } |
| .head-btn:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.35); color: var(--t1); } |
| .head-btn:disabled { opacity: 0.35; cursor: not-allowed; } |
| .head-btn[hidden] { display: none; } |
| .head-btn.solid { background: #fff; color: #000; border-color: #fff; } |
| .head-btn.solid:hover:not(:disabled) { opacity: 0.85; color: #000; } |
| |
| |
| |
| .bar { |
| position: absolute; |
| left: 0; right: 0; bottom: 0; |
| height: 2px; |
| overflow: hidden; |
| pointer-events: none; |
| } |
| .bar > div { |
| height: 100%; |
| width: 0%; |
| background: linear-gradient(90deg, var(--t2), #fff); |
| transition: opacity 0.4s ease; |
| } |
| .bar.done > div { opacity: 0; } |
| |
| .thread-scroll { |
| flex: 1 1 auto; |
| min-height: 0; |
| overflow-y: auto; |
| scroll-behavior: smooth; |
| } |
| .thread { |
| margin: 0 auto; |
| max-width: var(--maxw); |
| min-height: 100%; |
| padding: 40px 28px 24px; |
| display: flex; |
| flex-direction: column; |
| gap: 32px; |
| } |
| |
| .welcome { |
| margin: auto 0; |
| text-align: center; |
| padding: 4vh 0; |
| animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; |
| } |
| .welcome h2 { |
| font-family: var(--display); |
| font-size: clamp(30px, 6vw, 46px); |
| font-weight: 400; |
| line-height: 1.05; |
| color: #fff; |
| margin-bottom: 14px; |
| } |
| .welcome h2 .thin { color: var(--t3); } |
| .welcome p { color: var(--t2); max-width: 46ch; margin: 0 auto 28px; font-weight: 300; } |
| .seeds { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } |
| .seed { |
| padding: 9px 16px; |
| font-size: 13.5px; |
| color: var(--t2); |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 100px; |
| transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.15s ease, opacity 0.2s ease; |
| } |
| .seed:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.3); color: var(--t1); background: var(--panel-hover); transform: translateY(-1px); } |
| .seed:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| .msg { display: flex; flex-direction: column; animation: rise 0.4s cubic-bezier(0.2, 0.7, 0.2, 1) both; } |
| .role { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.16em; |
| text-transform: uppercase; |
| margin-bottom: 10px; |
| } |
| .role::before { content: ""; width: 5px; height: 5px; border-radius: 50%; } |
| .msg.user { align-items: flex-end; } |
| .msg.user .role { color: var(--t3); } |
| .msg.user .role::before { background: var(--t3); } |
| .msg.assistant .role { color: var(--ok); } |
| .msg.assistant .role::before { background: var(--ok); } |
| |
| .bubble { overflow-wrap: anywhere; } |
| .bubble.user { |
| background: var(--user-bubble); |
| border: 1px solid var(--line); |
| border-radius: 16px 16px 4px 16px; |
| padding: 12px 17px; |
| line-height: 1.55; |
| max-width: min(82%, 600px); |
| white-space: pre-wrap; |
| color: var(--t1); |
| } |
| .bubble.assistant { font-size: 16px; line-height: 1.72; color: var(--t1); max-width: 100%; } |
| .bubble.assistant > :first-child { margin-top: 0; } |
| .bubble.assistant > :last-child { margin-bottom: 0; } |
| .bubble.assistant p { margin: 0 0 0.9em; } |
| .bubble.assistant strong { color: #fff; font-weight: 600; } |
| .bubble.assistant em { font-style: italic; } |
| .bubble.assistant a { color: #8ab4ff; text-decoration: underline; text-underline-offset: 2px; } |
| .bubble.assistant a:hover { color: #aecbff; } |
| .bubble.assistant h1, .bubble.assistant h2, .bubble.assistant h3, |
| .bubble.assistant h4, .bubble.assistant h5, .bubble.assistant h6 { |
| color: #fff; font-weight: 600; line-height: 1.25; margin: 1.3em 0 0.6em; |
| } |
| .bubble.assistant h1 { font-size: 1.5em; } |
| .bubble.assistant h2 { font-size: 1.3em; } |
| .bubble.assistant h3 { font-size: 1.13em; } |
| .bubble.assistant h4, .bubble.assistant h5, .bubble.assistant h6 { font-size: 1em; } |
| .bubble.assistant ul, .bubble.assistant ol { margin: 0 0 0.9em; padding-left: 1.5em; } |
| .bubble.assistant li { margin: 0.25em 0; } |
| .bubble.assistant li::marker { color: var(--t3); } |
| .bubble.assistant blockquote { |
| margin: 0 0 0.9em; padding: 2px 0 2px 16px; |
| border-left: 2px solid var(--line); color: var(--t2); |
| } |
| .bubble.assistant hr { border: 0; border-top: 1px solid var(--line); margin: 1.3em 0; } |
| .bubble.assistant code { |
| font-family: var(--mono); |
| font-size: 0.85em; |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 5px; |
| padding: 1px 5px; |
| } |
| .bubble.assistant pre { |
| margin: 0 0 0.9em; |
| padding: 14px 16px; |
| background: #0a0a0c; |
| border: 1px solid var(--line); |
| border-radius: 12px; |
| overflow-x: auto; |
| } |
| .bubble.assistant pre code { background: none; border: 0; padding: 0; font-size: 0.82em; line-height: 1.6; } |
| .bubble.assistant table { border-collapse: collapse; margin: 0 0 0.9em; font-size: 0.92em; display: block; overflow-x: auto; } |
| .bubble.assistant th, .bubble.assistant td { border: 1px solid var(--line); padding: 6px 11px; text-align: left; } |
| .bubble.assistant th { background: var(--panel); color: var(--t1); font-weight: 600; } |
| .bubble.assistant pre::-webkit-scrollbar, .bubble.assistant table::-webkit-scrollbar { height: 8px; } |
| .bubble.assistant pre::-webkit-scrollbar-thumb, .bubble.assistant table::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; } |
| |
| .meta { |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.04em; |
| color: var(--t3); |
| margin-top: 12px; |
| } |
| |
| .thinking { display: inline-flex; gap: 5px; padding: 4px 0; } |
| .thinking span { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); opacity: 0.5; animation: bob 1.3s ease-in-out infinite; } |
| .thinking span:nth-child(2) { animation-delay: 0.18s; } |
| .thinking span:nth-child(3) { animation-delay: 0.36s; } |
| |
| .caret { display: inline-block; width: 2px; height: 1.05em; margin-left: 2px; vertical-align: -0.16em; background: var(--ok); animation: blink 1s steps(2) infinite; } |
| |
| .composer-wrap { |
| flex: 0 0 auto; |
| background: linear-gradient(rgba(2, 2, 3, 0), var(--bg) 30%); |
| padding-top: 8px; |
| } |
| .composer { margin: 0 auto; max-width: var(--maxw); padding: 0 28px 22px; } |
| .field { |
| display: grid; |
| grid-template-columns: 1fr 44px; |
| align-items: flex-end; |
| gap: 8px; |
| background: var(--panel); |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| padding: 8px 8px 8px 18px; |
| transition: border-color 0.2s ease, background 0.2s ease; |
| } |
| .field:focus-within { border-color: rgba(255, 255, 255, 0.28); background: rgba(255, 255, 255, 0.05); } |
| textarea { |
| background: transparent; |
| border: 0; |
| outline: none; |
| resize: none; |
| width: 100%; |
| min-height: 42px; |
| max-height: 180px; |
| padding: 8px 0; |
| color: var(--t1); |
| } |
| textarea::placeholder { color: var(--t3); } |
| |
| .icon-button { |
| display: grid; |
| place-items: center; |
| width: 42px; |
| height: 42px; |
| border-radius: 13px; |
| transition: background 0.2s ease, opacity 0.2s ease, transform 0.1s ease; |
| } |
| .icon-button svg { width: 19px; height: 19px; } |
| .icon-button:active:not(:disabled) { transform: scale(0.94); } |
| .icon-button:disabled { opacity: 0.3; cursor: not-allowed; } |
| .send-button { background: #fff; color: #000; } |
| .send-button:hover:not(:disabled) { opacity: 0.86; } |
| .stop-button { background: rgba(255, 122, 107, 0.14); color: var(--danger); border: 1px solid rgba(255, 122, 107, 0.3); display: none; } |
| .stop-button:hover:not(:disabled) { background: rgba(255, 122, 107, 0.22); } |
| |
| .composer-meta { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| margin: 10px 4px 0; |
| min-height: 22px; |
| font-family: var(--mono); |
| font-size: 10.5px; |
| letter-spacing: 0.04em; |
| text-transform: uppercase; |
| color: var(--t3); |
| } |
| |
| .thread-scroll::-webkit-scrollbar { width: 10px; } |
| .thread-scroll::-webkit-scrollbar-thumb { background: var(--line); border: 3px solid var(--bg); border-radius: 10px; } |
| |
| :focus-visible { outline: 1px solid rgba(255, 255, 255, 0.45); outline-offset: 3px; } |
| |
| textarea:focus, textarea:focus-visible { outline: none; } |
| |
| |
| |
| |
| |
| .kx { |
| position: fixed; |
| inset: 0; |
| z-index: 50; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 28px; |
| } |
| .kx[hidden] { display: none; } |
| .kx-backdrop { |
| position: absolute; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.62); |
| backdrop-filter: blur(6px); |
| -webkit-backdrop-filter: blur(6px); |
| animation: kxFade 0.25s ease; |
| } |
| .kx-panel { |
| position: relative; |
| display: flex; |
| flex-direction: column; |
| width: min(1080px, 100%); |
| height: min(86vh, 920px); |
| background: #0a0a0c; |
| border: 1px solid var(--line); |
| border-radius: 18px; |
| overflow: hidden; |
| box-shadow: 0 40px 120px -30px rgba(0, 0, 0, 0.9); |
| animation: kxRise 0.3s cubic-bezier(0.2, 0.7, 0.2, 1); |
| } |
| .kx-head { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| gap: 16px; |
| padding: 20px 22px; |
| border-bottom: 1px solid var(--line-soft); |
| } |
| .kx-title h3 { font-family: var(--display); font-weight: 400; font-size: 26px; color: #fff; line-height: 1; } |
| .kx-sub { display: block; margin-top: 7px; font-size: 12.5px; color: var(--t3); } |
| .kx-close { |
| display: grid; |
| place-items: center; |
| width: 34px; height: 34px; |
| flex-shrink: 0; |
| border-radius: 9px; |
| color: var(--t2); |
| border: 1px solid var(--line); |
| transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; |
| } |
| .kx-close:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.35); background: var(--panel); } |
| .kx-close svg { width: 15px; height: 15px; } |
| .kx-body { flex: 1; display: grid; grid-template-columns: 236px 1fr; min-height: 0; } |
| .kx-side { |
| position: relative; |
| min-width: 0; |
| min-height: 0; |
| border-right: 1px solid var(--line-soft); |
| } |
| .kx-list { |
| height: 100%; |
| overflow-y: auto; |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| gap: 2px; |
| } |
| |
| .kx-side::after { |
| content: ""; |
| position: absolute; |
| left: 0; right: 1px; bottom: 0; |
| height: 54px; |
| background: linear-gradient(transparent, #0a0a0c 88%); |
| pointer-events: none; |
| opacity: 1; |
| transition: opacity 0.25s ease; |
| } |
| .kx-side.at-end::after { opacity: 0; } |
| .kx-item { |
| flex-shrink: 0; |
| text-align: left; |
| padding: 9px 12px; |
| border-radius: 8px; |
| font-family: var(--mono); |
| font-size: 12px; |
| line-height: 1.5; |
| color: var(--t2); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| transition: background 0.15s ease, color 0.15s ease; |
| } |
| .kx-item:hover { background: var(--panel); color: var(--t1); } |
| .kx-item.active { background: var(--panel-hover); color: #fff; } |
| .kx-view { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; } |
| .kx-view-head { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 12px; |
| padding: 12px 18px; |
| border-bottom: 1px solid var(--line-soft); |
| } |
| .kx-name { font-family: var(--mono); font-size: 13px; color: var(--t1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| .kx-view-actions { display: flex; align-items: center; gap: 14px; flex-shrink: 0; } |
| .kx-lines { font-family: var(--mono); font-size: 11px; color: var(--t4); } |
| .kx-copy { |
| font-family: var(--mono); |
| font-size: 11px; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| color: var(--t2); |
| border: 1px solid var(--line); |
| border-radius: 100px; |
| padding: 5px 13px; |
| transition: color 0.2s ease, border-color 0.2s ease; |
| } |
| .kx-copy:hover { color: var(--t1); border-color: rgba(255, 255, 255, 0.35); } |
| .kx-code { |
| flex: 1; |
| min-height: 0; |
| min-width: 0; |
| overflow: auto; |
| margin: 0; |
| padding: 18px 20px; |
| font-family: var(--mono); |
| font-size: 12.5px; |
| line-height: 1.65; |
| color: var(--t2); |
| tab-size: 2; |
| } |
| .kx-code code { white-space: pre; } |
| |
| .k-cm { color: #5d6b6f; font-style: italic; } |
| .k-kw { color: #c792ea; } |
| .k-ty { color: #6fb3ff; } |
| .k-at { color: #ffb074; } |
| .k-nu { color: #7ee787; } |
| |
| |
| .kx-source { flex: 1; min-height: 0; display: flex; flex-direction: column; } |
| .kx-source[hidden] { display: none; } |
| .kx-intro { |
| flex: 1; |
| min-height: 0; |
| overflow-y: auto; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: 34px 30px; |
| } |
| .kx-intro[hidden] { display: none; } |
| .kx-intro-inner { |
| max-width: 540px; |
| text-align: center; |
| animation: kxIntroRise 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; |
| } |
| |
| |
| .kx-spark { position: relative; width: 60px; height: 60px; margin: 0 auto 24px; } |
| .kx-spark-ring { |
| position: absolute; inset: 0; border-radius: 50%; |
| background: conic-gradient(from 0deg, #ff7a6b, #ffcd6b, #64ffa0, #6fb3ff, #c792ea, #ff7a6b); |
| animation: kxSpin 6s linear infinite; |
| } |
| .kx-spark-ring::after { |
| content: ""; position: absolute; inset: -7px; border-radius: 50%; |
| background: inherit; filter: blur(13px); opacity: 0.5; |
| } |
| .kx-spark-core { position: absolute; inset: 2px; border-radius: 50%; background: #0a0a0c; } |
| .kx-spark-icon { position: absolute; inset: 0; margin: auto; width: 32px; height: 32px; color: #fff; } |
| |
| .kx-intro-title { |
| font-family: var(--display); |
| font-weight: 400; |
| font-size: clamp(26px, 3vw, 34px); |
| line-height: 1.12; |
| color: #fff; |
| margin-bottom: 16px; |
| } |
| .kx-intro-lead { |
| font-size: 14.5px; |
| font-weight: 300; |
| line-height: 1.7; |
| color: var(--t2); |
| margin: 0 auto 26px; |
| max-width: 48ch; |
| } |
| .kx-points { |
| list-style: none; |
| display: flex; |
| flex-direction: column; |
| gap: 13px; |
| text-align: left; |
| margin-bottom: 28px; |
| } |
| .kx-points li { |
| position: relative; |
| padding-left: 22px; |
| font-size: 13.5px; |
| line-height: 1.62; |
| color: var(--t2); |
| } |
| .kx-points li::before { |
| content: ""; |
| position: absolute; left: 2px; top: 0.6em; |
| width: 6px; height: 6px; border-radius: 50%; |
| } |
| .kx-points li:nth-child(1)::before { background: #ff7a6b; box-shadow: 0 0 8px rgba(255, 122, 107, 0.7); } |
| .kx-points li:nth-child(2)::before { background: #6fb3ff; box-shadow: 0 0 8px rgba(111, 179, 255, 0.7); } |
| .kx-points li:nth-child(3)::before { background: #64ffa0; box-shadow: 0 0 8px rgba(100, 255, 160, 0.7); } |
| .kx-points b { color: var(--t1); font-weight: 600; } |
| .kx-intro-hint { |
| font-family: var(--mono); |
| font-size: 11px; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| color: var(--t3); |
| } |
| |
| .kx-code::-webkit-scrollbar, .kx-list::-webkit-scrollbar { width: 10px; height: 10px; } |
| .kx-code::-webkit-scrollbar-thumb, .kx-list::-webkit-scrollbar-thumb { background: var(--line); border: 3px solid #0a0a0c; border-radius: 10px; } |
| |
| body.kx-locked { overflow: hidden; } |
| @keyframes kxFade { from { opacity: 0; } to { opacity: 1; } } |
| @keyframes kxRise { from { opacity: 0; transform: translateY(12px) scale(0.99); } to { opacity: 1; transform: none; } } |
| @keyframes kxSpin { to { transform: rotate(360deg); } } |
| @keyframes kxIntroRise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } |
| |
| @media (max-width: 760px) { |
| .kx { padding: 0; } |
| .kx-panel { width: 100%; height: 100%; border-radius: 0; border: 0; } |
| .kx-body { grid-template-columns: 1fr; grid-template-rows: auto 1fr; } |
| .kx-side { border-right: 0; border-bottom: 1px solid var(--line-soft); } |
| .kx-side::after { display: none; } |
| .kx-list { flex-direction: row; height: auto; overflow-x: auto; overflow-y: hidden; } |
| .kx-item { flex-shrink: 0; } |
| } |
| |
| |
| |
| |
| |
| .anim { opacity: 0; transition: opacity 0.9s cubic-bezier(0.25, 0.1, 0.25, 1); } |
| .anim.in { opacity: 1; } |
| .eyebrow { transition-delay: 0.35s; } |
| .hero-h1 { transition-delay: 0.5s; } |
| .hero-row { transition-delay: 0.78s; } |
| |
| @keyframes statusPulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.3; } |
| } |
| @keyframes scrollDrop { |
| 0% { top: -100%; } |
| 55% { top: 100%; } |
| 100% { top: 100%; } |
| } |
| @keyframes rise { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: none; } |
| } |
| @keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } } |
| @keyframes bob { |
| 0%, 60%, 100% { opacity: 0.5; transform: translateY(0); } |
| 30% { opacity: 1; transform: translateY(-5px); } |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } |
| .anim, #crt-frame, .hero-fade, .scroll-cue { opacity: 1 !important; } |
| .caret { opacity: 1; } |
| } |
| |
| |
| |
| |
| |
| @media (max-width: 760px) { |
| .hero-content { padding: 0 22px 22px; } |
| .hero-h1 { font-size: clamp(38px, 12vw, 60px); margin-bottom: 24px; max-width: none; } |
| .hero-row { flex-direction: column; align-items: stretch; gap: 28px; } |
| .hero-side { align-items: flex-start; text-align: left; } |
| .hero-foot { align-items: flex-start; } |
| .hero-actions { flex-wrap: wrap; } |
| .hero-stats { gap: 22px 30px; } |
| .stat-val { font-size: 24px; } |
| .scroll-cue { display: none; } |
| .chat-head-inner { padding: 13px 18px; gap: 12px; } |
| .to-top .brand-name { display: none; } |
| .thread { padding: 28px 18px 20px; } |
| .composer { padding: 0 18px 18px; } |
| .bubble.user { max-width: 88%; } |
| .head-btn { padding: 8px 12px; } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <section id="landing" class="screen"> |
| <div id="crt-frame"></div> |
| <div class="hero-fade"></div> |
|
|
| <div class="hero-overlay"> |
| <div class="hero-content"> |
| <div class="eyebrow anim">On-device · WebGPU · agentic kernel optimization</div> |
| <h1 class="hero-h1 anim">Gemma 4 in your browser.<br><span class="thin">Kernels written by Fable 5.</span></h1> |
| <div class="hero-row anim"> |
| <div class="hero-main"> |
| <p class="hero-sub"> |
| <b>Gemma 4 E2B (QAT Mobile)</b> — a powerful open-source model — runs |
| fully on-device with WebGPU. Weights cache locally after the first load, and nothing you |
| type ever leaves your machine. |
| </p> |
| <div class="hero-stats"> |
| <div class="stat"><span class="stat-val">2.3B</span><span class="stat-label">Effective params</span></div> |
| <div class="stat"><span class="stat-val">128K</span><span class="stat-label">Context window</span></div> |
| <div class="stat"><span class="stat-val">~250</span><span class="stat-label">tok/s · M4 Max</span></div> |
| <div class="stat"><span class="stat-val">100%</span><span class="stat-label">On-device</span></div> |
| </div> |
| </div> |
| <div class="hero-side"> |
| <div class="hero-actions"> |
| <button class="btn-primary" id="loadBtn" type="button"> |
| <span id="loadBtnLabel">Load model</span> |
| <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4"/></svg> |
| </button> |
| <a class="btn-secondary" href="https://huggingface.co/google/gemma-4-E2B-it-qat-mobile-transformers" target="_blank" rel="noopener">Model card</a> |
| </div> |
| <div class="hero-foot"> |
| <a class="kernel-note-cta" href="https://x.com/xenovacom/status/2065656427117437213" target="_blank" rel="noopener">WebGPU kernels <b>100% written & optimized by Fable 5</b> →</a> |
| <span class="hero-foot-note">Tuned for Apple M4 Max · experimental</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="scroll-cue" id="scrollCue"> |
| <span>Chat below</span> |
| <div class="scroll-cue-line"></div> |
| </div> |
| </div> |
| </section> |
|
|
| |
| <section id="chat" class="screen"> |
| <header class="chat-head"> |
| <div class="chat-head-inner"> |
| <a class="to-top" href="#landing" id="toTop" title="Back to top"> |
| <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8 13V4M4 8l4-4 4 4"/></svg> |
| <span class="brand-name">Gemma 4 · E2B</span> |
| </a> |
| <div class="status" id="status"> |
| <span class="status-dot"></span> |
| <span class="status-text" id="statusText">Not loaded</span> |
| </div> |
| <div class="chat-head-actions"> |
| <button class="head-btn solid" id="headLoadBtn" type="button">Load model</button> |
| <button class="head-btn" id="kernelsBtn" type="button" hidden>View Kernels</button> |
| <button class="head-btn" id="clearBtn" type="button" disabled>Clear</button> |
| </div> |
| </div> |
| <div class="bar" id="bar"><div></div></div> |
| </header> |
|
|
| <div class="thread-scroll" id="threadScroll"> |
| <div class="thread" id="thread"> |
| <div class="welcome" id="welcome"> |
| <h2>What's on your <span class="thin">mind today?</span></h2> |
| <p>Model runs entirely on your device.</p> |
| <div class="seeds"> |
| <button class="seed" type="button" disabled>How does WebGPU differ from WebGL?</button> |
| <button class="seed" type="button" disabled>Write a haiku about on-device AI</button> |
| <button class="seed" type="button" disabled>What is quantization-aware training?</button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <footer class="composer-wrap"> |
| <div class="composer"> |
| <div class="field"> |
| <textarea id="input" rows="1" placeholder="Load the model to start chatting..." disabled></textarea> |
| <button class="icon-button send-button" id="sendBtn" type="button" disabled aria-label="Send message"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg> |
| </button> |
| <button class="icon-button stop-button" id="stopBtn" type="button" aria-label="Stop generation"> |
| <svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg> |
| </button> |
| </div> |
| <div class="composer-meta"> |
| <span id="hint">Runs fully on-device — nothing leaves your machine</span> |
| <span id="liveStat"></span> |
| </div> |
| </div> |
| </footer> |
| </section> |
|
|
| |
| <div class="kx" id="kernelsOverlay" hidden> |
| <div class="kx-backdrop" data-close></div> |
| <div class="kx-panel" role="dialog" aria-modal="true" aria-label="Rendered kernels"> |
| <div class="kx-head"> |
| <div class="kx-title"> |
| <h3>Kernels</h3> |
| <span class="kx-sub" id="kxSub"></span> |
| </div> |
| <button class="kx-close" id="kxClose" type="button" aria-label="Close" data-close> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg> |
| </button> |
| </div> |
| <div class="kx-body"> |
| <div class="kx-side"><div class="kx-list" id="kxList"></div></div> |
| <div class="kx-view"> |
| <div class="kx-intro" id="kxIntro"> |
| <div class="kx-intro-inner"> |
| <div class="kx-spark" aria-hidden="true"> |
| <span class="kx-spark-ring"></span> |
| <span class="kx-spark-core"></span> |
| <svg class="kx-spark-icon" viewBox="0 0 20 20" fill="currentColor"><path d="M11.7858 1.66699C11.9437 1.66699 12.0957 1.72951 12.2074 1.84115C12.3189 1.95276 12.3815 2.10483 12.3815 2.2627V3.45247H15.3568C15.6725 3.45247 15.9767 3.57757 16.1999 3.80078C16.4231 4.02403 16.5482 4.32813 16.5482 4.64388V7.61914H17.738C17.8957 7.61914 18.0479 7.68181 18.1595 7.79329C18.2712 7.90491 18.3337 8.05698 18.3337 8.21484C18.3336 8.37247 18.2709 8.52323 18.1595 8.63477C18.0478 8.7464 17.8958 8.81055 17.738 8.81055H16.5482V11.1901H17.738C17.8958 11.1901 18.0478 11.2543 18.1595 11.3659C18.2709 11.4774 18.3336 11.6282 18.3337 11.7858C18.3337 11.9437 18.2712 12.0957 18.1595 12.2074C18.0479 12.3188 17.8957 12.3815 17.738 12.3815H16.5482V15.3568C16.5482 15.6725 16.4231 15.9767 16.1999 16.1999C15.9767 16.4231 15.6725 16.5482 15.3568 16.5482H12.3815V17.738C12.3815 17.8959 12.3188 18.0479 12.2074 18.1595C12.0957 18.271 11.9437 18.3337 11.7858 18.3337C11.6282 18.3336 11.4774 18.2707 11.3659 18.1595C11.2543 18.0478 11.1901 17.896 11.1901 17.738V16.5482H8.81055V17.738C8.81055 17.896 8.7464 18.0478 8.63477 18.1595C8.52323 18.2707 8.37247 18.3336 8.21484 18.3337C8.05698 18.3337 7.90491 18.271 7.79329 18.1595C7.68181 18.0479 7.61914 17.8958 7.61914 17.738V16.5482H4.64388C4.32813 16.5482 4.02403 16.4231 3.80078 16.1999C3.57756 15.9767 3.45247 15.6725 3.45247 15.3568V12.3815H2.2627C2.10485 12.3815 1.95276 12.3189 1.84115 12.2074C1.72951 12.0957 1.66699 11.9437 1.66699 11.7858C1.66708 11.6283 1.72987 11.4774 1.84115 11.3659C1.95276 11.2543 2.10483 11.1901 2.2627 11.1901H3.45247V8.81055H2.2627C2.10483 8.81055 1.95276 8.7464 1.84115 8.63477C1.72987 8.52324 1.66704 8.37243 1.66699 8.21484C1.66699 8.05698 1.72951 7.90491 1.84115 7.79329C1.95275 7.68175 2.1049 7.61914 2.2627 7.61914H3.45247V4.64388C3.45247 4.32813 3.57751 4.02403 3.80078 3.80078C4.02403 3.57753 4.32813 3.45247 4.64388 3.45247H7.61914V2.2627C7.61914 2.10488 7.68175 1.95275 7.79329 1.84115C7.90491 1.72951 8.05698 1.66699 8.21484 1.66699C8.37243 1.66704 8.52324 1.72987 8.63477 1.84115C8.7464 1.95276 8.81055 2.10481 8.81055 2.2627V3.45247H11.1901V2.2627C11.1901 2.10481 11.2543 1.95276 11.3659 1.84115C11.4774 1.72987 11.6283 1.66708 11.7858 1.66699ZM6.14616 5.31445C5.6863 5.31489 5.31445 5.68782 5.31445 6.14779V13.8529C5.31445 14.3131 5.68755 14.6862 6.14779 14.6862H13.8529C14.3131 14.6862 14.6862 14.3131 14.6862 13.8529V6.14779C14.6862 5.68755 14.3131 5.31445 13.8529 5.31445H6.14616Z" fill-opacity="0.4"></path><rect x="5.31348" y="5.31445" width="9.37256" height="9.37256" rx="0.833333" fill-opacity="0.15"></rect><path d="M8.19238 7.91225V12.254C8.19238 12.585 8.55699 12.7862 8.83777 12.606L12.2491 10.4351C12.3088 10.3973 12.358 10.3451 12.3921 10.2831C12.4262 10.2212 12.4441 10.1517 12.4441 10.081C12.4441 10.0103 12.4262 9.9408 12.3921 9.87889C12.358 9.81697 12.3088 9.76468 12.2491 9.72688L8.83777 7.56022C8.77456 7.51934 8.70149 7.49627 8.62626 7.49346C8.55103 7.49064 8.47644 7.50819 8.41035 7.54423C8.34426 7.58028 8.28913 7.6335 8.25077 7.69827C8.2124 7.76304 8.19223 7.83697 8.19238 7.91225Z" fill-opacity="0.8"></path></svg> |
| </div> |
| <h2 class="kx-intro-title">What are Kernels?</h2> |
| <p class="kx-intro-lead">Kernels are the low-level GPU programs that do the model's actual math — the matrix multiplications, attention, and normalization behind every token. And how well they're optimized can dramatically speed up inference.</p> |
| <ul class="kx-points"> |
| <li><b>WebGPU & WGSL.</b> Each kernel is a WebGPU compute shader, written in WGSL — the language that runs general-purpose math on the GPU — entirely locally in your browser.</li> |
| <li><b>Agentic Kernel Optimization.</b> Every kernel was generated by AI (in this case, Fable 5, before it was shut down), benchmarked on an Apple M4 Max, and refined through an evolutionary, genetic-style search toward the fastest version.</li> |
| <li><b>Blazingly Fast.</b> This means we are able to run Gemma 4 E2B at ~250 tokens/sec on an M4 Max, pushing your device to its limits.</li> |
| </ul> |
| <div class="kx-intro-hint">Select a kernel to read its real source</div> |
| </div> |
| </div> |
| <div class="kx-source" id="kxSource" hidden> |
| <div class="kx-view-head"> |
| <span class="kx-name" id="kxName"></span> |
| <div class="kx-view-actions"> |
| <span class="kx-lines" id="kxLines"></span> |
| <button class="kx-copy" id="kxCopy" type="button">Copy</button> |
| </div> |
| </div> |
| <pre class="kx-code"><code id="kxCode"></code></pre> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <script type="module" src="./landing.js"></script> |
|
|
| |
| |
| <script> |
| (function () { |
| const fire = () => { |
| document.getElementById("crt-frame")?.classList.add("visible"); |
| document.querySelector(".hero-fade")?.classList.add("in"); |
| document.querySelectorAll(".anim").forEach((el) => el.classList.add("in")); |
| document.getElementById("scrollCue")?.classList.add("in"); |
| }; |
| requestAnimationFrame(() => requestAnimationFrame(fire)); |
| })(); |
| </script> |
|
|
| |
| <script type="module"> |
| import { Gemma4Mobile } from "./gemma-4-e2b.js"; |
| |
| |
| |
| |
| |
| let marked = null; |
| import("https://esm.sh/marked@17") |
| .then((m) => { marked = m.marked; marked.use({ gfm: true, breaks: true }); }) |
| .catch(() => { marked = null; }); |
| |
| const $ = (id) => document.getElementById(id); |
| const landing = $("landing"); |
| const chat = $("chat"); |
| const threadScroll = $("threadScroll"); |
| const thread = $("thread"); |
| const loadBtn = $("loadBtn"); |
| const loadBtnLabel = $("loadBtnLabel"); |
| const headLoadBtn = $("headLoadBtn"); |
| const statusEl = $("status"); |
| const statusText = $("statusText"); |
| const bar = $("bar"); |
| const barFill = bar.firstElementChild; |
| const input = $("input"); |
| const sendBtn = $("sendBtn"); |
| const stopBtn = $("stopBtn"); |
| const clearBtn = $("clearBtn"); |
| const hint = $("hint"); |
| const liveStat = $("liveStat"); |
| const kernelsBtn = $("kernelsBtn"); |
| const kernelsOverlay = $("kernelsOverlay"); |
| |
| let model = null; |
| let kernels = []; |
| let kxCopySource = ""; |
| let messages = []; |
| let abortController = null; |
| let isGenerating = false; |
| let isLoading = false; |
| let renderScheduled = false; |
| let renderState = null; |
| let targetProgress = 0; |
| let shownProgress = 0; |
| let progressRaf = 0; |
| |
| if (!navigator.gpu) { |
| const msg = "WebGPU isn't available here. Try a recent Chrome, Edge, or Safari Technology Preview."; |
| loadBtn.disabled = true; |
| headLoadBtn.disabled = true; |
| setStatus("error", msg); |
| } |
| |
| loadBtn.addEventListener("click", loadModel); |
| headLoadBtn.addEventListener("click", loadModel); |
| sendBtn.addEventListener("click", send); |
| stopBtn.addEventListener("click", () => abortController?.abort()); |
| clearBtn.addEventListener("click", clearChat); |
| kernelsBtn.addEventListener("click", openKernels); |
| kernelsOverlay.addEventListener("click", (e) => { if (e.target.closest("[data-close]")) closeKernels(); }); |
| $("kxList").addEventListener("scroll", updateListFade, { passive: true }); |
| $("kxCopy").addEventListener("click", copyKernel); |
| document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !kernelsOverlay.hidden) closeKernels(); }); |
| $("toTop").addEventListener("click", (e) => { e.preventDefault(); landing.scrollIntoView({ behavior: "smooth" }); }); |
| |
| input.addEventListener("input", () => { autoGrow(); refreshSend(); }); |
| input.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!sendBtn.disabled) send(); } |
| }); |
| document.addEventListener("click", (e) => { |
| const seed = e.target.closest(".seed"); |
| if (!seed || seed.disabled || !model || isGenerating) return; |
| input.value = seed.textContent; |
| send(); |
| }); |
| |
| function setStatus(state, text) { |
| statusEl.className = "status" + (state ? " " + state : ""); |
| if (text !== undefined) statusText.innerHTML = text; |
| } |
| |
| async function loadModel() { |
| if (model || isLoading) return; |
| isLoading = true; |
| loadBtn.disabled = true; |
| headLoadBtn.disabled = true; |
| loadBtnLabel.textContent = "Loading…"; |
| bar.classList.remove("done"); |
| setProgressImmediate(0.02); |
| setStatus("loading", "Requesting WebGPU device…"); |
| |
| |
| chat.scrollIntoView({ behavior: "smooth" }); |
| |
| const started = performance.now(); |
| try { |
| model = await Gemma4Mobile.load(null, { onProgress: updateLoadProgress }); |
| setStatus("loading", "Warming up kernels…"); |
| await model.warmup(); |
| |
| const seconds = ((performance.now() - started) / 1000).toFixed(1); |
| setStatus("ready", `Ready in <strong>${seconds}s</strong> · on-device`); |
| setProgressImmediate(1); |
| bar.classList.add("done"); |
| loadBtnLabel.textContent = "Model loaded"; |
| headLoadBtn.style.display = "none"; |
| enableChat(); |
| } catch (error) { |
| console.error(error); |
| setStatus("error", `Failed to load: ${escapeHtml(String(error?.message ?? error))}`); |
| bar.classList.add("done"); |
| loadBtn.disabled = false; |
| headLoadBtn.disabled = false; |
| loadBtnLabel.textContent = "Load model"; |
| isLoading = false; |
| } |
| } |
| |
| function labelFor(status) { |
| return { |
| init: "Requesting WebGPU device…", |
| tokenizer: "Loading tokenizer…", |
| weights: "Downloading weights…", |
| ready: "Ready.", |
| }[status] ?? status; |
| } |
| |
| function updateLoadProgress(event) { |
| if (event.status !== "weights") { |
| setStatus("loading", labelFor(event.status)); |
| setPhaseProgress(event.status, event.fraction); |
| return; |
| } |
| const kind = event.kind ?? inferProgressKind(event); |
| const fraction = finiteNumber(event.fraction) ? clamp(event.fraction, 0, 1) : null; |
| |
| |
| |
| |
| if (kind !== "tensors") setPhaseProgress("weights", fraction); |
| setStatus("loading", formatWeightProgress(event, fraction)); |
| } |
| |
| |
| |
| function setPhaseProgress(status, frac) { |
| const [lo, hi] = status === "weights" |
| ? [0.04, 1.0] |
| : ({ init: [0, 0.02], tokenizer: [0.02, 0.04], ready: [1, 1] }[status] ?? [0, 1]); |
| const f = finiteNumber(frac) ? clamp(frac, 0, 1) : 0; |
| setProgressFraction(lo + (hi - lo) * f); |
| } |
| |
| function formatWeightProgress(event, fraction) { |
| const kind = event.kind ?? inferProgressKind(event); |
| const pct = fraction === null ? "" : ` (${Math.round(fraction * 100)}%)`; |
| const loaded = finiteNumber(event.loaded) ? event.loaded : null; |
| const total = finiteNumber(event.total) ? event.total : null; |
| if (kind === "bytes") { |
| const verb = event.fromCache ? "Loading cached weights" : "Downloading weights"; |
| if (loaded !== null && total !== null) return `${verb}: ${formatBytes(loaded)} / ${formatBytes(total)}${pct}`; |
| if (total !== null) return `${verb}: ${formatBytes(total)} total`; |
| return `${escapeHtml(event.message || verb)}…`; |
| } |
| if (loaded !== null && total !== null) { |
| const label = event.message ? ` (${escapeHtml(event.message)})` : ""; |
| return `Preparing GPU weights: ${formatInteger(loaded)} / ${formatInteger(total)} tensors${pct}${label}`; |
| } |
| return event.message ? `Preparing GPU weights: ${escapeHtml(event.message)}` : "Preparing GPU weights…"; |
| } |
| |
| function inferProgressKind(event) { |
| if (event.kind === "bytes" || event.kind === "tensors") return event.kind; |
| if (finiteNumber(event.total) && event.total > 1_000_000) return "bytes"; |
| return "tensors"; |
| } |
| |
| |
| |
| |
| function setProgressFraction(value) { |
| if (!finiteNumber(value)) return; |
| targetProgress = Math.max(clamp(value, 0, 1), targetProgress); |
| if (!progressRaf) progressRaf = requestAnimationFrame(stepProgressBar); |
| } |
| |
| function stepProgressBar() { |
| const gap = targetProgress - shownProgress; |
| shownProgress += gap < 0.0015 ? gap : gap * 0.3; |
| barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; |
| progressRaf = shownProgress < targetProgress ? requestAnimationFrame(stepProgressBar) : 0; |
| } |
| |
| function setProgressImmediate(value) { |
| if (progressRaf) { cancelAnimationFrame(progressRaf); progressRaf = 0; } |
| targetProgress = shownProgress = clamp(value, 0, 1); |
| barFill.style.width = `${(shownProgress * 100).toFixed(2)}%`; |
| } |
| |
| function enableChat() { |
| isLoading = false; |
| input.disabled = false; |
| input.placeholder = "Ask anything…"; |
| clearBtn.disabled = false; |
| kernelsBtn.hidden = false; |
| setSeedButtonsEnabled(true); |
| refreshSend(); |
| input.focus(); |
| } |
| |
| |
| function openKernels() { |
| if (!model) return; |
| kernels = model.runtime.getRenderedShaders?.() ?? []; |
| const list = $("kxList"); |
| list.replaceChildren(); |
| $("kxSub").textContent = kernels.length |
| ? `${kernels.length} WGSL compute shaders · written & optimized by Fable 5 · running on your GPU` |
| : "No kernels compiled yet — send a message first."; |
| kernels.forEach((k, i) => { |
| const item = document.createElement("button"); |
| item.className = "kx-item"; |
| item.type = "button"; |
| item.textContent = k.name; |
| item.addEventListener("click", () => selectKernel(i)); |
| list.appendChild(item); |
| }); |
| |
| [...list.children].forEach((el) => el.classList.remove("active")); |
| $("kxSource").hidden = true; |
| $("kxIntro").hidden = false; |
| kxCopySource = ""; |
| kernelsOverlay.hidden = false; |
| document.body.classList.add("kx-locked"); |
| list.scrollTop = 0; |
| requestAnimationFrame(updateListFade); |
| } |
| |
| |
| function updateListFade() { |
| const list = $("kxList"); |
| const atEnd = list.scrollHeight <= list.clientHeight + 4 |
| || list.scrollTop >= list.scrollHeight - list.clientHeight - 4; |
| list.parentElement.classList.toggle("at-end", atEnd); |
| } |
| |
| function selectKernel(i) { |
| const k = kernels[i]; |
| if (!k) return; |
| $("kxIntro").hidden = true; |
| $("kxSource").hidden = false; |
| [...$("kxList").children].forEach((el, j) => el.classList.toggle("active", j === i)); |
| $("kxName").textContent = k.name; |
| $("kxLines").textContent = `${k.source.split("\n").length} lines`; |
| $("kxCode").innerHTML = highlightWgsl(k.source); |
| $("kxCode").parentElement.scrollTop = 0; |
| kxCopySource = k.source; |
| } |
| |
| function closeKernels() { |
| kernelsOverlay.hidden = true; |
| document.body.classList.remove("kx-locked"); |
| } |
| |
| async function copyKernel() { |
| if (!kxCopySource) return; |
| try { |
| await navigator.clipboard.writeText(kxCopySource); |
| const btn = $("kxCopy"); |
| btn.textContent = "Copied"; |
| setTimeout(() => { btn.textContent = "Copy"; }, 1200); |
| } catch { } |
| } |
| |
| const WGSL_KEYWORDS = new Set(["fn","let","var","const","const_assert","struct","if","else","for","loop","return","break","continue","switch","case","default","while","override","enable","requires","discard","alias","true","false","workgroup","storage","uniform","function","private","read","write","read_write","bitcast"]); |
| const WGSL_TYPES = new Set(["u32","i32","f32","f16","bool","vec2","vec3","vec4","mat2x2","mat3x3","mat4x4","mat2x3","mat3x2","mat2x4","mat4x2","mat3x4","mat4x3","array","atomic","ptr","sampler"]); |
| const WGSL_TOKEN = /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(@[A-Za-z_]\w*)|([A-Za-z_]\w*)|(\d[\w.]*)|(\s+)|([\s\S])/g; |
| |
| function highlightWgsl(src) { |
| let out = ""; |
| WGSL_TOKEN.lastIndex = 0; |
| let m; |
| while ((m = WGSL_TOKEN.exec(src))) { |
| const [tok, comment, attr, ident, num, ws] = m; |
| if (comment) out += `<span class="k-cm">${escapeHtml(comment)}</span>`; |
| else if (attr) out += `<span class="k-at">${escapeHtml(attr)}</span>`; |
| else if (ident) { |
| const cls = WGSL_KEYWORDS.has(ident) ? "k-kw" : WGSL_TYPES.has(ident) ? "k-ty" : null; |
| out += cls ? `<span class="${cls}">${ident}</span>` : escapeHtml(ident); |
| } |
| else if (num) out += `<span class="k-nu">${escapeHtml(num)}</span>`; |
| else if (ws) out += ws; |
| else out += escapeHtml(tok); |
| } |
| return out; |
| } |
| |
| async function send() { |
| const text = input.value.trim(); |
| if (!text || !model || isGenerating) return; |
| |
| removeWelcome(); |
| input.value = ""; |
| autoGrow(); refreshSend(); |
| |
| appendUserMessage(text); |
| messages.push({ role: "user", content: text }); |
| |
| const assistant = appendAssistantMessage(); |
| const bubble = assistant.querySelector(".bubble"); |
| bubble.innerHTML = '<span class="thinking"><span></span><span></span><span></span></span>'; |
| scrollDown(); |
| |
| setGenerating(true); |
| abortController = new AbortController(); |
| |
| let reply = ""; |
| let startedAt = 0, firstTokenAt = 0, endedAt = 0, generatedTokens = 0; |
| |
| try { |
| const stream = model.generate(messages, { maxNewTokens: 4096, signal: abortController.signal }); |
| startedAt = performance.now(); |
| for await (const { text: full } of stream) { |
| const now = performance.now(); |
| if (!firstTokenAt) firstTokenAt = now; |
| generatedTokens++; |
| reply = full; |
| scheduleAssistantRender(bubble, reply); |
| updateLiveStat({ startedAt, firstTokenAt, now, generatedTokens }); |
| } |
| } catch (error) { |
| console.error(error); |
| if (!reply) reply = `_Stopped: ${String(error?.message ?? error)}_`; |
| } finally { |
| endedAt = performance.now(); |
| renderState = null; |
| renderAssistant(bubble, reply, false); |
| appendMeta(assistant, { startedAt, firstTokenAt, endedAt, generatedTokens }); |
| scrollDown(); |
| messages.push({ role: "assistant", content: reply }); |
| setGenerating(false); |
| liveStat.textContent = ""; |
| abortController = null; |
| input.focus(); |
| } |
| } |
| |
| function setGenerating(on) { |
| isGenerating = on; |
| input.disabled = on; |
| clearBtn.disabled = on; |
| sendBtn.style.display = on ? "none" : ""; |
| stopBtn.style.display = on ? "grid" : "none"; |
| setStatus(on ? "busy" : "ready", on ? "Generating…" : "Ready · on-device"); |
| hint.textContent = on ? "Generating on-device…" : "Runs fully on-device — nothing leaves your machine"; |
| refreshSend(); |
| } |
| |
| function updateLiveStat({ startedAt, firstTokenAt, now, generatedTokens }) { |
| if (generatedTokens <= 1) { liveStat.textContent = `TTFT ${(firstTokenAt - startedAt).toFixed(0)} ms`; return; } |
| const decodeSeconds = Math.max((now - firstTokenAt) / 1000, 1e-9); |
| const tps = (generatedTokens - 1) / decodeSeconds; |
| liveStat.textContent = `${tps.toFixed(0)} tok/s`; |
| } |
| |
| function clearChat() { |
| messages = []; |
| model?.reset(); |
| thread.replaceChildren(createWelcome()); |
| clearBtn.disabled = !model; |
| setSeedButtonsEnabled(Boolean(model)); |
| input.focus(); |
| } |
| |
| function appendUserMessage(text) { |
| const msg = document.createElement("div"); |
| msg.className = "msg user"; |
| msg.appendChild(roleLabel("You")); |
| const bubble = document.createElement("div"); |
| bubble.className = "bubble user"; |
| bubble.textContent = text; |
| msg.appendChild(bubble); |
| thread.appendChild(msg); |
| scrollDown(); |
| return msg; |
| } |
| |
| function appendAssistantMessage() { |
| const msg = document.createElement("div"); |
| msg.className = "msg assistant"; |
| msg.appendChild(roleLabel("Gemma")); |
| const bubble = document.createElement("div"); |
| bubble.className = "bubble assistant"; |
| msg.appendChild(bubble); |
| thread.appendChild(msg); |
| return msg; |
| } |
| |
| function roleLabel(text) { |
| const label = document.createElement("div"); |
| label.className = "role"; |
| label.textContent = text; |
| return label; |
| } |
| |
| function appendMeta(msg, timing) { |
| if (timing.generatedTokens <= 0) return; |
| const stats = generationStats(timing); |
| const meta = document.createElement("div"); |
| meta.className = "meta"; |
| const parts = [`${timing.generatedTokens} tok`, `TTFT ${stats.ttftMs.toFixed(0)} ms`]; |
| if (stats.decodeTokensPerSecond > 0) parts.push(`${stats.decodeTokensPerSecond.toFixed(1)} tok/s`); |
| meta.textContent = parts.join(" · "); |
| msg.appendChild(meta); |
| } |
| |
| function generationStats({ startedAt, firstTokenAt, endedAt, generatedTokens }) { |
| if (generatedTokens <= 0 || !startedAt || !firstTokenAt || !endedAt) return { ttftMs: 0, decodeTokensPerSecond: 0 }; |
| const decodeTokens = Math.max(generatedTokens - 1, 0); |
| const decodeSeconds = Math.max((endedAt - firstTokenAt) / 1000, 1e-9); |
| return { ttftMs: firstTokenAt - startedAt, decodeTokensPerSecond: decodeTokens > 0 ? decodeTokens / decodeSeconds : 0 }; |
| } |
| |
| |
| |
| function scheduleAssistantRender(bubble, raw) { |
| renderState = { bubble, raw }; |
| if (renderScheduled) return; |
| renderScheduled = true; |
| requestAnimationFrame(() => { |
| renderScheduled = false; |
| if (!renderState) return; |
| renderAssistant(renderState.bubble, renderState.raw, true); |
| scrollDown(); |
| }); |
| } |
| |
| function renderAssistant(bubble, raw, withCaret) { |
| if (marked) { |
| try { |
| bubble.innerHTML = sanitizeHtml(marked.parse(raw || "")); |
| if (withCaret) appendCaret(bubble); |
| return; |
| } catch { } |
| } |
| const safe = escapeHtml(raw || ""); |
| const paragraphs = safe.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean); |
| if (paragraphs.length === 0) { bubble.textContent = ""; return; } |
| bubble.innerHTML = paragraphs.map((p) => `<p>${formatInline(p).replace(/\n/g, "<br>")}</p>`).join(""); |
| if (withCaret) appendCaret(bubble); |
| } |
| |
| function appendCaret(bubble) { |
| const caret = document.createElement("span"); |
| caret.className = "caret"; |
| (bubble.querySelector("p:last-of-type") || bubble).appendChild(caret); |
| } |
| |
| |
| |
| function sanitizeHtml(html) { |
| const tpl = document.createElement("template"); |
| tpl.innerHTML = html; |
| tpl.content.querySelectorAll("script,style,iframe,object,embed,link,meta,form").forEach((el) => el.remove()); |
| tpl.content.querySelectorAll("*").forEach((el) => { |
| for (const attr of [...el.attributes]) { |
| const name = attr.name.toLowerCase(); |
| if (name.startsWith("on") || ((name === "href" || name === "src") && /^\s*(javascript|data):/i.test(attr.value))) { |
| el.removeAttribute(attr.name); |
| } |
| } |
| }); |
| return tpl.innerHTML; |
| } |
| |
| function formatInline(text) { |
| return text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+?)`/g, "<code>$1</code>"); |
| } |
| |
| function removeWelcome() { $("welcome")?.remove(); } |
| |
| function createWelcome() { |
| const welcome = document.createElement("div"); |
| welcome.className = "welcome"; |
| welcome.id = "welcome"; |
| welcome.innerHTML = ` |
| <h2>What's on your <span class="thin">mind today?</span></h2> |
| <p>Model runs entirely on your device.</p> |
| <div class="seeds"> |
| <button class="seed" type="button">How does WebGPU differ from WebGL?</button> |
| <button class="seed" type="button">Write a haiku about on-device AI</button> |
| <button class="seed" type="button">What is quantization-aware training?</button> |
| </div>`; |
| return welcome; |
| } |
| |
| function setSeedButtonsEnabled(enabled) { |
| document.querySelectorAll(".seed").forEach((s) => { s.disabled = !enabled; }); |
| } |
| function refreshSend() { sendBtn.disabled = isGenerating || !model || input.value.trim() === ""; } |
| function autoGrow() { input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 180)}px`; } |
| function scrollDown() { threadScroll.scrollTop = threadScroll.scrollHeight; } |
| function finiteNumber(v) { return typeof v === "number" && Number.isFinite(v); } |
| function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); } |
| function formatInteger(v) { return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(v); } |
| function formatBytes(bytes) { |
| const units = ["B", "KB", "MB", "GB"]; let v = bytes, u = 0; |
| while (v >= 1024 && u < units.length - 1) { v /= 1024; u++; } |
| |
| const digits = u === 3 ? 2 : (v >= 10 || u === 0 ? 0 : 1); |
| return `${v.toFixed(digits)} ${units[u]}`; |
| } |
| function escapeHtml(v) { |
| return v.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); |
| } |
| </script> |
| </body> |
| </html> |
|
|