Xenova's picture
Xenova HF Staff
v3
93c318a verified
Raw
History Blame Contribute Delete
65.2 kB
<!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 */
/* ====================================================================== */
#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;
}
/* used by the chat header's back-to-top link */
.brand-name {
font-family: var(--mono);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--t1);
}
/* hero body — anchored to the bottom, over the darkened fade for legibility */
.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); }
/* bottom row: description + stats (left), actions + credit (right) */
.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);
}
/* right column */
.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; }
/* kernel credit + experimental note, in the right column */
.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 — sits at the very bottom of the hero, below the content */
.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 */
/* ====================================================================== */
#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; }
/* Absolutely positioned over the header's bottom edge so it can never affect layout —
no element shifts when loading starts or ends; the fill just fades out on completion. */
.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; /* width is animated per-frame in JS (see stepProgressBar) */
}
.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; }
/* The composer already shows focus via .field:focus-within — suppress the textarea's own outline. */
textarea:focus, textarea:focus-visible { outline: none; }
/* ====================================================================== */
/* kernels overlay */
/* ====================================================================== */
.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;
}
/* fade hinting more kernels are scrollable below; fades out at the end of the list */
.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; }
/* WGSL syntax highlight */
.k-cm { color: #5d6b6f; font-style: italic; }
.k-kw { color: #c792ea; }
.k-ty { color: #6fb3ff; }
.k-at { color: #ffb074; }
.k-nu { color: #7ee787; }
/* explanation shown until a kernel is picked (swaps in/out with the source view) */
.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;
}
/* rainbow-shimmer icon — a slowly rotating conic ring + halo in the app's accent palette */
.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 { /* blurred halo that rotates with the ring → soft shimmer */
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; }
/* while the overlay is open, lock the page behind it so only the overlay's panes scroll */
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; }
}
/* ====================================================================== */
/* entrance + keyframes */
/* ====================================================================== */
.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; }
}
/* ====================================================================== */
/* responsive */
/* ====================================================================== */
@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>
<!-- ======================= LANDING ======================= -->
<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&nbsp;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&nbsp;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 &amp; optimized by Fable&nbsp;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>
<!-- ======================= CHAT ======================= -->
<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>
<!-- ============ KERNELS OVERLAY ============ -->
<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 &amp; 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>
<!-- landing background animation (WebGL / three.js) -->
<script type="module" src="./landing.js"></script>
<!-- entrance sequencer (runs independently of the background so the hero shows even if
the WebGL/three.js scene fails to load) -->
<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>
<!-- app logic -->
<script type="module">
import { Gemma4Mobile } from "./gemma-4-e2b.js";
// Streamed markdown → HTML. `streamdown` itself is React-only (peer deps react/react-dom plus a
// mermaid + unified/remark/rehype dep tree), so we use `marked` — the very parser streamdown is
// built on — loaded lazily from a CDN. Until it loads (or if the CDN is unreachable) we fall
// back to the tiny inline renderer below, so chat never depends on it.
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; // latest monotonic load fraction [0,1]
let shownProgress = 0; // currently rendered bar fraction (eased toward target each frame)
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…");
// Move the visitor down into the chat where progress + conversation live.
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;
// Drive the bar off the BYTE download only. The "tensors" stream counts materialized tensors,
// which races far ahead of the download (the many small tensors finish while the big embedding
// weights are still downloading by size), so it would leap the bar past the real progress.
// Tensor events still update the status text below.
if (kind !== "tensors") setPhaseProgress("weights", fraction);
setStatus("loading", formatWeightProgress(event, fraction));
}
// Map each load phase onto an increasing slice of the bar (weights — the byte download — owns the
// bulk). setProgressFraction's monotonic guard keeps it from ever dipping.
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";
}
// Record the latest (monotonic) target and let a single rAF loop ease the bar toward it. This
// coalesces bursts of byte-progress events into one write per frame and — unlike a fixed CSS
// width transition — never lags behind fast progress (a big remaining gap is closed quickly).
function setProgressFraction(value) {
if (!finiteNumber(value)) return;
targetProgress = Math.max(clamp(value, 0, 1), targetProgress); // monotonic — never hops backwards
if (!progressRaf) progressRaf = requestAnimationFrame(stepProgressBar);
}
function stepProgressBar() {
const gap = targetProgress - shownProgress;
shownProgress += gap < 0.0015 ? gap : gap * 0.3; // ~30% of the gap per frame; snap when nearly there
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();
}
// ---- Kernels viewer: the real rendered WGSL the model compiled on this GPU ----
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);
});
// Open on the explanation, with no kernel selected — the source shows only once one is clicked.
[...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);
}
// Toggle the bottom fade on the kernel list: shown while there is more to scroll, hidden at the end.
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; // swap the explanation out for the source
$("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 { /* clipboard blocked — ignore */ }
}
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; // cancel any pending coalesced render; show the final reply now
renderAssistant(bubble, reply, false);
appendMeta(assistant, { startedAt, firstTokenAt, endedAt, generatedTokens });
scrollDown(); // the meta line is added after the reply — keep it in view
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 };
}
// Coalesce streamed renders to one per animation frame — marked re-parses the full reply each
// call, so parsing per token (≈250/s) would tax decode; rAF caps it to the display rate.
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 { /* fall through to the simple renderer */ }
}
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);
}
// Strip anything executable from the model's markdown before inserting it (defence in depth — a
// local model is the only content source, but raw <script>/event handlers/javascript: URLs are out).
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++; }
// GB shows 2 decimals (1.12 GB) so it ticks finely past 1 GB; smaller units keep 0–1.
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
</script>
</body>
</html>