diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..0d8842c798d6250a1b613dd769955a6127b02c9d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text diff --git a/404.html b/404.html new file mode 100644 index 0000000000000000000000000000000000000000..aa1e978d5f89e5c88dddb0a8c48b54e84836cfd3 --- /dev/null +++ b/404.html @@ -0,0 +1,29 @@ + + + + + + + 404 β€” Zero-TVM + + + + +
+

404 β€” no such page

+

zerotvm.com β€” pick a model, it runs in your tab.

+
+ + diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..54ec500cb2a762420ae018dd09194d107ef4f5e2 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +--- +title: Zero-TVM +emoji: 🧠 +colorFrom: indigo +colorTo: purple +sdk: static +pinned: true +license: mit +thumbnail: https://huggingface.co/spaces/abgunaydin/zero-tvm/resolve/main/og.png +short_description: Phi-3 to a 35B MoE in the browser, on hand-written WGSL +tags: + - webgpu + - llm + - inference + - on-device + - wgsl +models: + - microsoft/Phi-3-mini-4k-instruct + - Qwen/Qwen3-4B + - Qwen/Qwen3.5-4B + - meta-llama/Llama-3.2-1B-Instruct + - mlc-ai/Phi-3-mini-4k-instruct-q4f16_1-MLC + - mlc-ai/Qwen3-4B-q4f16_1-MLC + - mlc-ai/Qwen3.5-4B-q4f16_1-MLC + - mlx-community/Qwen3-4B-4bit + - mlx-community/Qwen3-30B-A3B-4bit + - mlx-community/Llama-3.2-1B-Instruct-4bit + - lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit + - abgunaydin/Qwen3.6-35B-A3B-MLX-q3exp +--- + +# Zero-TVM + +Models from a 3.8B dense to a 35B sparse MoE, running in the browser on hand-written WGSL. No TVM, no ONNX, no WASM runtime. + +The standard way to run a modern LLM in a browser is [WebLLM / MLC-LLM](https://webllm.mlc.ai/), which ships an Apache-TVM compiler pipeline that autotunes its WGSL β€” 85 shaders captured across a session, around 11 of them on the decode path. This Space replaces that entire stack with **10 kernel roles** of hand-written WGSL β€” and runs models WebLLM ships (same weights, measured faster) as well as one it does not: Qwen3.6-35B-A3B, a 256-expert sparse MoE. + +The whole forward pass β€” 32 transformer layers, paged KV cache, int4-dequant matmul, RoPE, fused FFN, RMSNorm, paged attention, argmax sampling β€” is readable end-to-end in a single sitting. That is the point. + +## Headline result + +Measured on an Apple M2 Max (Chrome 150, WebLLM v0.2.80, identical Phi-3-mini-q4f16_1 weights, same-session head-to-head, 2026-07-30). Two numbers, always both: **total** is wall-clock throughput with prefill included (both engines doing identical work), **decode** excludes prefill. + +| | WebLLM (TVM) | Zero-TVM | +|---|---|---| +| WGSL kernels (decode path) | **~11 TVM-generated** (85 captured across a session) | **10 hand-written roles** | +| Dispatches per decode step | **342** | **260** default (228 with `?splitk=0`) | +| Total throughput (prefill + decode) | **59.95 tok/s** | **69.55 tok/s** β€” **+16.0%** | +| Decode only | **63.23 tok/s** (self-reported) | **83.10 tok/s** β€” **+31.4%** | +| Time to first token (~35-tok prompt) | **~150 ms** (implied) | **291 ms** β€” *WebLLM wins this one* | +| JS bundle (excl. weights) | **~6.0 MB** / ~2.2 MB gz | **~460 kB** / ~126 kB gz | + +**The hand-written kernels don't just keep up with the autotuning compiler β€” on this hardware they beat it on throughput.** For decoder-only LLMs of this shape, most of the compiler's complexity budget isn't buying much β€” the expensive parts are matmul, attention, and int4 dequant. Everything else is plumbing, and ten hand-written roles cover it. + +**The gap grows with how recent the architecture is** β€” +16% / +31% (total / decode) on 2024's Phi-3, +32% / +58% on 2025's Qwen3-4B, +100% / +114% on 2026's Qwen3.5-4B. Consistent with compiler stacks having had less time to tune newer architectures; an observation across three points on one machine, not a proven law. + +**Honest negative, published not buried:** WebLLM reaches the first token faster than we do on short prompts (~150 ms implied vs our 291 ms on Phi-3; 453 ms on Qwen3-4B). We win sustained throughput and lose the first-token sprint on short inputs β€” our chunked prefill is strong on long ones (202 tok/s at 816 tokens) and prefix reuse makes follow-up turns free. + +Exact medians, full methodology, and the optimization experiments that were measured and *dropped* live in [BENCH.md](https://github.com/abgnydn/zero-tvm/blob/main/BENCH.md). (An earlier measurement on an M2 Pro with an older, buggier engine read 22% *behind* β€” that history is preserved there too, not retconned. So are two 2026-07-29 Qwen pairs that a bench-harness defect made non-comparable; they are marked withdrawn with the reason, not deleted.) + +## Run it + +The Space opens on a character-select entrance: pick a model and it loads and chats in place. [`zero-tvm.html`](./zero-tvm.html) is the same chat as a direct link, if you want to deep-link a specific model. + +Picking a character starts the download immediately β€” the sheet shows the weight size before you click, and there is no second confirmation. It streams ~2 GB of Phi-3-mini-q4f16_1 weights from the Hugging Face mirror at [`mlc-ai/Phi-3-mini-4k-instruct-q4f16_1-MLC`](https://huggingface.co/mlc-ai/Phi-3-mini-4k-instruct-q4f16_1-MLC) into OPFS (Origin Private File System). + +Subsequent loads are instant β€” the weights stay in OPFS, so returning to a model you have already downloaded starts it straight away. + +**Requirements**: Chrome / Edge with WebGPU enabled and the `shader-f16` feature available (default on macOS Apple-Silicon, enabled on most modern Windows / Linux GPUs). + +## Models + +All three pairs re-measured 2026-07-30 under the corrected protocol β€” same session, identical local weight bytes, both engines paying a full prefill on every run, total and decode reported side by side. + +- **Phi-3-mini-4k-instruct (3.8B, q4f16_1)** β€” the default; every headline number above is Phi-3, no URL flag needed. Zero-TVM **69.55 tok/s total** (TTFT 291 ms, decode 83.10) vs WebLLM 0.2.80's **59.95 tok/s total** (self-reported decode 63.23) β€” **+16.0% total, +31.4% decode**. +- **Qwen3-4B (q4f16_1)** β€” append `?model=qwen3` to `zero-tvm.html` or `validate.html`; weights stream from [`mlc-ai/Qwen3-4B-q4f16_1-MLC`](https://huggingface.co/mlc-ai/Qwen3-4B-q4f16_1-MLC) (~2.3 GB). A port on the spec-parameterized engine: GQA 32/8, per-head QK-norm, byte-level BPE, tied lm_head, with the tuning round's fused post-matmul chain (`qk_norm_rope_append`, 8 dispatches/layer) and K%512 wide loads. Zero-TVM **59.85 tok/s total** (TTFT 453 ms, decode 75.49) vs WebLLM 0.2.84's prebuilt Qwen3-4B at **45.46 tok/s total** (self-reported decode 47.77) β€” **+31.7% total, +58.0% decode**. This is the model where WebLLM most clearly beats us on first-token latency (~150 ms implied vs our 453 ms). The previously published "75.7 vs 43.8, +73%" pair is **withdrawn** β€” a bench-harness defect had stopped our half paying prefill; and the 2026-07-28 pair (25.43 vs 14.15) was a degraded session that did not reproduce. Both are kept as dated history with the reason in [BENCH.md](https://github.com/abgnydn/zero-tvm/blob/main/BENCH.md). +- **Qwen3.5-4B (q4f16_1)** β€” append `?model=qwen35` to `zero-tvm.html` or `validate.html`; weights stream from [`mlc-ai/Qwen3.5-4B-q4f16_1-MLC`](https://huggingface.co/mlc-ai/Qwen3.5-4B-q4f16_1-MLC) (~2.6 GB). The first *hybrid* on the engine: 24 gated-DeltaNet (linear-attention) layers + 8 gated-attention layers (GQA 16/4, head_dim 256, partial RoPE, sigmoid attention gate) β€” to our knowledge the first hand-written-kernel int4 gated-DeltaNet hybrid in a browser. Zero-TVM **65.28 tok/s total** (TTFT 171 ms, decode 73.30) vs WebLLM 0.2.84's prebuilt Qwen3.5-4B at **32.56 tok/s total** (self-reported decode 34.32) β€” **+100.5% total, +113.6% decode**, and the one model where first-token latency is a wash rather than a loss. The GDN decode kernels are still scalar (non-subgroup), so the decode number is a floor; prefill runs in chunks of ≀64 (202 tok/s on an 816-token prompt) and every model reuses its cross-turn prefix (turn-3 first token 14.3 s β†’ 0.19 s, logits verified bit-identical to a fresh prefill). The previously published "65.7 vs 34.0, +93%" cross-check is **withdrawn** (same harness defect); the earlier 53.07/32.36 (+64%) and 47.99/31.99 (+50%) pairs were like-for-like but are superseded. One machine, one pair each; caveats in [BENCH.md](https://github.com/abgnydn/zero-tvm/blob/main/BENCH.md). + +## Pages in this Space + +- [`index.html`](./index.html) β€” the entrance: pick a model, chat in place (start here) +- [`zero-tvm.html`](./zero-tvm.html) β€” the chat surface as a direct link +- [`validate.html`](./validate.html) β€” multi-prompt smoke test +- [`docs.html`](./docs.html) β€” annotated reference, including the kernel walkthrough + +Six pages were removed on 2026-08-14 β€” `architecture`, `demo`, `compiler-chat`, +`dump`, `shaders`, `webllm-bench`. Three of them started multi-gigabyte +downloads on page load. `docs.html` carries what the first two explained. + +## URL flags + +`zero-tvm.html` accepts query flags for A/B-ing shader variants without rebuilding: + +- `?sg=0` β€” disable all subgroup shaders (argmax / attention / QKV matmul) +- `?sgqkv=0` / `?sgattn=0` / `?sgargmax=0` β€” disable one at a time +- `?qkvtile=1` / `?qkvtile2=1` β€” opt into tiled QKV variants +- `?ffnsg=1` β€” opt into the tiled-subgroup fused FFN +- `?kv8=1` β€” opt into the int8 KV cache path + +## How it relates to a published paper + +Zero-TVM is the LLM-decoding application of a broader thesis: **per-dispatch overhead is the dominant tax on browser GPU compute, and most workloads collapse from "needs a server" to "runs in a browser tab" once you fuse the loop.** Two preprints document the underlying technique: + +- [Single-Kernel Fusion for Sequential Fitness Evaluation via WebGPU Compute Shaders](https://doi.org/10.5281/zenodo.19342888) (the foundational result) +- [Single-Kernel Fusion for Autoregressive Transformer Decoding via WebGPU Compute Shaders](https://doi.org/10.5281/zenodo.19344277) (the LLM application β€” the work this Space embodies) + +## Source / canonical site + +- **GitHub**: [github.com/abgnydn/zero-tvm](https://github.com/abgnydn/zero-tvm) +- **Canonical site**: [zerotvm.com](https://zerotvm.com) +- **Research umbrella**: [kernelfusion.dev](https://kernelfusion.dev) +- **Companion benchmarks**: [gpubench.dev](https://gpubench.dev) + +## License + +MIT. Reference comparison code (WebLLM compiler-chat path) belongs to its respective authors and is governed by their licenses. + +## Author + +Ahmet Barış GΓΌnaydΔ±n Β· [barisgunaydin.com](https://barisgunaydin.com) Β· senior full-stack consultant by day, independent researcher by night. diff --git a/_headers b/_headers new file mode 100644 index 0000000000000000000000000000000000000000..f223119b309fe0abf4ca23aa85420c40e2d93ee2 --- /dev/null +++ b/_headers @@ -0,0 +1,8 @@ +# Cloudflare Pages headers (migrated from the old vercel.json). +# Cross-origin isolation is required for SharedArrayBuffer (WebGPU itself does not need it). +/* + Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: credentialless + +/assets/* + Cache-Control: public, max-age=31536000, immutable diff --git a/_redirects b/_redirects new file mode 100644 index 0000000000000000000000000000000000000000..f016ca946e9d6a435295d96c610c18f3da64304c --- /dev/null +++ b/_redirects @@ -0,0 +1,16 @@ +# Six pages deleted in the 2026-08-14 redesign, five of them in the sitemap +# Google is crawling at priority 0.6-0.9. Without these, every bookmark, README +# link and search result for them lands on a bare 404 and the accumulated link +# equity is dropped instead of passed to the successor page. +/demo / 301 +/demo.html / 301 +/architecture /docs 301 +/architecture.html /docs 301 +/shaders /docs#shaders 301 +/shaders.html /docs#shaders 301 +/dump /docs 301 +/dump.html /docs 301 +/compiler-chat /zero-tvm 301 +/compiler-chat.html /zero-tvm 301 +/webllm-bench /docs#vs-webllm 301 +/webllm-bench.html /docs#vs-webllm 301 diff --git a/agent-host.html b/agent-host.html new file mode 100644 index 0000000000000000000000000000000000000000..0441f2b1d7e6744ee72b2b75001c5045275c1e39 --- /dev/null +++ b/agent-host.html @@ -0,0 +1,78 @@ + + + + + + + + + +zero-tvm Β· agent host + + + + + + + +

zero-tvm Β· agent host

+
This tab runs the model. scripts/agent-server.mjs gives it an OpenAI-shaped address on :8017. +
Alternative, and the one to prefer for real use: npm run station runs the engine in-process on + dawn.node (no browser tab, no throttling) and takes the same :8017. They cannot both hold the port β€” run one.
+ +
+
booting…
+
+
+ +
+
Point a client at http://127.0.0.1:8017/v1
+
+ Cline β€” API Provider β€œOpenAI Compatible”, Base URL above, any model id.
+ pi β€” ~/.pi/agent/models.json, api: "openai-completions", that baseUrl.
+ curl β€” curl 127.0.0.1:8017/v1/chat/completions -d '{"messages":[{"role":"user","content":"hi"}]}' -H 'content-type: application/json' +
+
+ +
+
Keep this tab visible.
+
Chrome throttles background tabs hard β€” share.ts measured ~23 tok/s + backgrounded against ~65 focused. An agent run is long and unattended, which is exactly when a tab + stops being focused.
+
+ +
+ + +
+ diff --git a/assets/agent-host-eDUnNJBhJx.js b/assets/agent-host-eDUnNJBhJx.js new file mode 100644 index 0000000000000000000000000000000000000000..04d09595af98c8669f33a0c29b014da57b469e39 --- /dev/null +++ b/assets/agent-host-eDUnNJBhJx.js @@ -0,0 +1,84 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/cache-probe-BYX_n9OPRB.js","assets/weight-loader-ByOLMd9f5C.js","assets/model-registry-BoK-ObgACo.js"])))=>i.map(i=>d[i]); +var te=Object.defineProperty;var ne=(e,t,n)=>t in e?te(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var P=(e,t,n)=>ne(e,typeof t!="symbol"?t+"":t,n);import{m as re}from"./model-registry-BoK-ObgACo.js";import{_ as oe}from"./preload-helper-ckwbz45pO3.js";import{bootEngine as ae}from"./loading-ui-CjCwSRuXvh.js";import{buildDecodeEngine as se,allocKVPages as ie}from"./engine-core-CLR3cxsqf4.js";import{parseVariantFlags as le}from"./variants-De-VVc9BRz.js";import{specFromSearch as ce,buildChatPromptFor as ue}from"./model-select-DhhV9mCi0T.js";import"./weight-loader-ByOLMd9f5C.js";const M=0xffffffffffffffffn,fe=0x243f6a8885a308d3n,me=0x13198a2e03707344n;function he(e){return e=(e^e>>30n)*0xbf58476d1ce4e5b9n&M,e=(e^e>>27n)*0x94d049bb133111ebn&M,(e^e>>31n)&M}function X(e,t){let n=e;for(let o=0;o>>0)+0x9e3779b97f4a7c15n&M);return n}function pe(e){const t=X(fe,e),n=X(me,e);return t.toString(16).padStart(16,"0")+n.toString(16).padStart(16,"0")}const de=new Set(["splitK"]),ge="zero-tvm/kv-prefix/1";function ye(e){const t=e.variants,n=Object.keys(t).filter(o=>!de.has(o)).sort().map(o=>` ${o}=${String(t[o])}`);return[ge,`spec=${e.specId}`,`weights=${e.weightRevision}`,`fused=${e.fused}`,`int8kv=${e.int8KV}`,"layers=all",`prefill=${e.prefillPath}`,`adapter=${e.adapter.vendor}|${e.adapter.architecture}`,"variants:",...n].join(` +`)}function we(e){const t=ye(e),n=new Array(t.length);for(let o=0;ol.byteLength),savedAt:Date.now()},a=await(await o.getFileHandle("entry.bin",{create:!0})).createWritable();for(const l of n.layers)await a.write(l);for(const l of n.scales)await a.write(l);for(const l of n.gdn)await a.write(l);await a.close();const i=await(await o.getFileHandle("meta.json",{create:!0})).createWritable();return await i.write(JSON.stringify(r)),await i.close(),{tokens:n.tokens}}async function xe(e,t,n){const o=await z(Y(t),!1);if(!o)return{restored:0,reason:"no entry for this fingerprint"};let r,a;try{r=JSON.parse(await(await(await o.getFileHandle("meta.json")).getFile()).text()),a=await(await(await o.getFileHandle("entry.bin")).getFile()).arrayBuffer()}catch{return{restored:0,reason:"no entry for this fingerprint"}}if(r.format!==F)return{restored:0,reason:`entry format ${r.format} != ${F}`};if(r.tokens>=n.length)return{restored:0,reason:"prompt does not extend the entry"};for(let u=0;uu+g,0);if(a.byteLength!==i)return{restored:0,reason:"entry truncated"};const f=[];let s=0;for(let u=0;u0)for(let u=0;u XML tags: +`,ve=` + + +For each function call, return a json object with function name and arguments within XML tags: + +{"name": , "arguments": } +`,_e=`# Tools + +You have access to the following functions: + +`,Oe=` + + +If you choose to call a function ONLY reply in the following format with NO suffix: + + + + +value_1 + + +This is the value for the second parameter +that can span +multiple lines + + + + + +Reminder: +- Function calls MUST follow the specified format: an inner block must be nested within XML tags +- Required parameters MUST be specified +- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after +- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls +`,Se=`Given the following functions, please respond with a JSON for a function call with its proper arguments that best answers the given prompt. + +Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}.Do not use variables. + +`;function Le(e,t,n){var s;if(n.length===0)return t.slice();const o=n.map(l=>` +${_(l)}`).join("");if(e==="llama3"){const l=t.slice(),h=l.findIndex(u=>u.role==="user");if(h<0)throw new Error("Cannot put tools in the first user message when there is no user message");const $=n.map(u=>`${JSON.stringify(u,null,4)} + +`).join("");return l[h]={role:"user",content:Se+$+l[h].content.trim()},l}const r=e==="chatml-xml"?_e+o+Oe:ke+o+ve,a=t.slice(),i=((s=a[0])==null?void 0:s.role)==="system"?a[0].content:null;let f;if(e==="chatml-xml"){const l=(i??"").trim();f=l?`${r} + +${l}`:r}else f=i?`${i} + +${r}`:r;return i===null?a.unshift({role:"system",content:f}):a[0]={role:"system",content:f},a}function Ee(e,t,n){if(n.length===0)return t;if(e==="llama3"){if(n.length>1)throw new Error("llama3 renders a single tool call per assistant message");return`{"name": "${n[0].name}", "parameters": ${_(n[0].arguments)}}`}if(e==="chatml-xml"){let r=t.trim();return n.forEach((a,i)=>{r+=i===0?r?` + +`:"":` +`,r+=` + +`;for(const[f,s]of Object.entries(a.arguments))r+=` +${typeof s=="string"?s:_(s)} + +`;r+=` +`}),r}let o=t;return n.forEach((r,a)=>{(a>0||t)&&(o+=` +`),o+=` +{"name": "${r.name}", "arguments": ${_(r.arguments)}} +`}),o}const B="",K="";function G(e,t){const n=e.indexOf("{",t);if(n<0)return null;let o=0,r=!1,a=!1;for(let i=n;i0;r--)if(e.endsWith(n.slice(0,r)))return r;return 0}function Q(e,t){if(typeof e!="object"||e===null||Array.isArray(e))return{name:"",arguments:{},raw:t,error:"tool call is not a JSON object"};const n=e;if(typeof n.name!="string"||n.name==="")return{name:"",arguments:{},raw:t,error:'tool call has no "name"'};let o=n.arguments??n.parameters;if(typeof o=="string")try{o=JSON.parse(o)}catch{}return o==null?{name:n.name,arguments:{},raw:t}:typeof o!="object"||Array.isArray(o)?{name:n.name,arguments:{},raw:t,error:"tool call arguments are not an object"}:{name:n.name,arguments:o,raw:t}}function Ce(e){const t=G(e,0);if(!t||t.end<0)return{name:"",arguments:{},raw:e,error:"no JSON object in block"};const n=e.slice(t.start,t.end);let o;try{o=JSON.parse(n)}catch(r){return{name:"",arguments:{},raw:e,error:`invalid JSON in block: ${r.message}`}}return Q(o,e)}function Ae(e){const t=e.indexOf("",t);if(t<0||n<0)return{name:"",arguments:{},raw:e,error:"no in block"};const o=e.slice(t+10,n),r=e.indexOf("",n);if(r<0)return{name:o,arguments:{},raw:e,error:"unterminated block"};const a={},i=e.slice(n+1,r);let f=0;for(;;){const s=i.indexOf("",s);if(l<0)return{name:o,arguments:a,raw:e,error:"unterminated tag"};let h=-1;for(let g=i.indexOf("",l);g>=0;g=i.indexOf("",g+1)){const C=i.slice(g+12).trim();if(C===""||C.startsWith(" value"};const $=i.slice(s+11,l);let u=i.slice(l+1,h);u.startsWith(` +`)&&(u=u.slice(1)),u.endsWith(` +`)&&(u=u.slice(0,-1)),a[$]=Ne(u),f=h+12}return{name:o,arguments:a,raw:e}}function Ne(e){try{const t=JSON.parse(e);return typeof t=="string"?e:t}catch{return e}}function Re(e,t){return e==="llama3"?Pe(t):Me(e,t)}function Me(e,t){const n=[];let o="",r=!1,a=0;for(;a=2;break}o+=t.slice(a,i);const f=i+B.length,s=t.indexOf(K,f);if(s<0){r=!0;break}const l=t.slice(f,s);n.push(e==="chatml-xml"?Ae(l):Ce(l)),a=s+K.length}return{text:o,calls:n,partial:r}}function Pe(e){const t=[];let n="",o=!1,r=0;for(;r")&&(i=i.slice(0,-14)),a.end<0){n+=i,o=!0;break}const f=e.slice(a.start,a.end);let s=null;try{const l=Q(JSON.parse(f),f);l.error||(s=l)}catch{}n+=s?i:e.slice(r,a.end),s&&t.push(s),r=a.end}return{text:n,calls:t,partial:o}}const R=new URL(location.href).searchParams.get("server")??"http://127.0.0.1:8017",J=e=>document.getElementById(e),T=(e,t="")=>{const n=J("status");n.textContent=e,n.className=t},w=e=>{const t=J("log");t.textContent=`${new Date().toLocaleTimeString()} ${e} +${t.textContent}`.split(` +`).slice(0,200).join(` +`)},m=ce(location.search),U=re(m);J("model").textContent=`${U.name} β€” ${U.params}`;const Be=new Set(["qwen36","qwen3-8-27b"]);function Fe(e){return e==="llama3"?"llama3":[...Be].some(t=>m.id.startsWith(t))?"chatml-xml":"chatml-json"}async function Je(){const{isModelCached:e}=await oe(async()=>{const{isModelCached:n}=await import("./cache-probe-BYX_n9OPRB.js");return{isModelCached:n}},__vite__mapDeps([0,1,2]));if(await e(m))return;const t=document.createElement("dialog");t.id="agent-gate",t.style.cssText="width:calc(100vw - 2rem);max-width:32rem;background:#0f1216;color:#d6dbe1;border:1px solid #21262d;border-radius:10px;padding:1.5rem 1.6rem;font:inherit",t.innerHTML=` +

Run ${m.id} in this tab?

+

+ This is a developer surface. It downloads the model + and then talks to an agent-server on 127.0.0.1:8017 β€” + start it with npm run agent from the repo. Without that + server running, this page has nothing to serve. +

+

Weights download once and cache locally.

+
+ Not now + +
`,document.body.appendChild(t),t.showModal(),await new Promise(n=>{var o;(o=t.querySelector("#agent-gate-go"))==null||o.addEventListener("click",()=>{t.close(),t.remove(),n()},{once:!0})})}async function Ie(){let e=null;if(!("gpu"in navigator)){T("cannot boot: this browser has no WebGPU β€” Chrome and Edge ship it","err");return}await Je();const t=await ae({spec:m,optionalFeatures:["subgroups","chromium-experimental-subgroup-matrix"],probeSubgroups:!0,buildEngine:({device:c,weights:d,sgSizeOk:O,spec:S})=>(e=le(location.search,{hasSubgroupsFeature:c.features.has("subgroups"),sgSizeOk:O}),se(c,d,ie(c,S),{spec:S,variants:e}))});if(!t.ok)throw T(`cannot boot: ${t.reason}`,"err"),new Error(t.reason);const{engine:n,tokenizer:o}=t,r=Fe(m.chatTemplateId),a=new URL(location.href).searchParams.get("pool")!=="0",i=()=>({spec:m,weightRevision:m.hfRepo,variants:e,fused:!1,int8KV:!1,prefillPath:!m.moe&&e.subgroups?"chunked":"per-token",adapter:{vendor:"browser",architecture:"webgpu"}});let f=!1,s=null,l=null;const h=()=>{a&&(s!==null&&clearTimeout(s),s=self.setTimeout(()=>{s=null,!g&&(l=(async()=>{try{const c=await $e(n,i());c.tokens&&w(`pool: saved ${c.tokens} tokens`)}catch(c){w(`pool: save failed (${c.message})`)}})().finally(()=>{l=null}))},1500))};T(`ready β€” ${m.maxContext.toLocaleString()} token context, ${r}`,"ok"),w(`engine up: ${m.id}, dialect ${r}`);class ${constructor(d){P(this,"buf","");P(this,"timer",null);this.id=d}push(d){this.buf+=d,this.timer===null&&(this.timer=self.setTimeout(()=>{this.timer=null,this.flush()},60))}async flush(){if(this.timer!==null&&(clearTimeout(this.timer),this.timer=null),!this.buf)return;const d=this.buf;this.buf="",await u({type:"delta",id:this.id,text:d})}}async function u(c){try{await fetch(`${R}/agent/emit`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(c)})}catch(d){w(`emit failed: ${d.message}`)}}let g=!1;async function C(c){var S,I;if(g){await u({type:"error",id:c.id,message:"busy: this tab serves one request at a time"});return}g=!0,s!==null&&(clearTimeout(s),s=null),l&&await l;const d=performance.now(),O=new $(c.id);try{let x=c.messages.map(p=>{var E;if(p.role!=="assistant"||!((E=p.toolCalls)!=null&&E.length))return{role:p.role,content:p.content};const k=p.toolCalls.map(v=>{var j,q;let W={};try{W=JSON.parse(((j=v.function)==null?void 0:j.arguments)||"{}")}catch{}return{name:((q=v.function)==null?void 0:q.name)??"",arguments:W}});return{role:p.role,content:Ee(r,p.content??"",k)}});(S=c.tools)!=null&&S.length&&(x=Le(r,x,c.tools));const y=ue(m,x,o);if(a&&!f){f=!0;const p=await xe(n,i(),y);w(p.restored?`pool: restored ${p.restored} tokens`:`pool: miss (${p.reason})`)}if(y.length>=m.maxContext)throw new Error(`prompt is ${y.length} tokens, context is ${m.maxContext}. Lower the client's context window, or use a spec with a bigger one (qwen35 and llama32 are 32,768).`);const D=m.maxContext-y.length,H=Math.max(1,Math.min(c.maxTokens??D,D));n.setSampling(c.temperature!=null&&c.temperature>0?{temperature:c.temperature,topP:c.topP??1}:null);let b="";const A={stop:null},L=await n.generatePipelined(y,H,p=>{var E;const k=o.decode([p]);if(b+=k,O.push(k),(E=c.stop)!=null&&E.length){for(const v of c.stop)if(v&&b.endsWith(v)){A.stop=v;break}}},()=>A.stop!==null);await O.flush(),A.stop&&(b=b.slice(0,b.length-A.stop.length));const V=(I=c.tools)!=null&&I.length?Re(r,b):{text:b,calls:[]},N=V.calls.map((p,k)=>({id:`call_${c.id.slice(0,8)}_${k}`,type:"function",function:{name:p.name,arguments:JSON.stringify(p.arguments??{})}})),ee=(performance.now()-d)/1e3;w(`${c.id.slice(0,8)} ${y.length} in / ${L.length} out ${(L.length/ee).toFixed(1)} tok/s`+(N.length?` ${N.length} tool call(s)`:"")),await u({type:"done",id:c.id,text:V.text,toolCalls:N,finishReason:N.length?"tool_calls":L.length>=H?"length":"stop",usage:{prompt_tokens:y.length,completion_tokens:L.length,total_tokens:y.length+L.length}})}catch(x){await O.flush(),w(`${c.id.slice(0,8)} ERROR ${x.message}`),await u({type:"error",id:c.id,message:x.message})}finally{g=!1,h()}}function Z(){const c=new EventSource(`${R}/agent/jobs?model=${encodeURIComponent(m.id)}`);c.onopen=()=>{T(`hosting ${m.id} β†’ ${R}/v1`,"ok"),w("connected to agent-server")},c.onmessage=d=>{C(JSON.parse(d.data))},c.onerror=()=>{T(`agent-server unreachable at ${R} β€” retrying`,"err")}}Z()}Ie(); diff --git a/assets/cache-probe-BYX_n9OPRB.js b/assets/cache-probe-BYX_n9OPRB.js new file mode 100644 index 0000000000000000000000000000000000000000..06af5ff0a2f8e60dffa37f1d73b8d700da238a09 --- /dev/null +++ b/assets/cache-probe-BYX_n9OPRB.js @@ -0,0 +1 @@ +import{p as s,a as l,o as f}from"./weight-loader-ByOLMd9f5C.js";import{o as c}from"./model-registry-BoK-ObgACo.js";async function g(t){return t.weightFormat!=="mlx-safetensors",!1}async function y(t){var n;if(typeof navigator>"u"||!((n=navigator.storage)!=null&&n.getDirectory))return!1;try{const o=await(await navigator.storage.getDirectory()).getDirectoryHandle(c(t));if(t.weightFormat==="mlx-safetensors"){const e=s(t),r=e[e.length-1];await o.getFileHandle(l(r.plan,r.layer))}else{const e=await o.getFileHandle(t.manifestName??"ndarray-cache.json"),r=JSON.parse(await(await e.getFile()).text()),i=[...new Set((r.records??[]).map(a=>a.dataPath).filter(a=>!!a))];if(i.length===0)return!1;for(const a of i)await o.getFileHandle(f(a))}return!0}catch{return await g(t)}}export{y as isModelCached}; diff --git a/assets/chat-flow-eTAzKAlIYe.js b/assets/chat-flow-eTAzKAlIYe.js new file mode 100644 index 0000000000000000000000000000000000000000..6d1b164145c7a450c966b8635c19e8197c62e9a2 --- /dev/null +++ b/assets/chat-flow-eTAzKAlIYe.js @@ -0,0 +1,6 @@ +import{buildChatPromptFor as B}from"./model-select-DhhV9mCi0T.js";import{allocKVPagesInt8 as ce,allocKVPages as de,buildDecodeEngine as ue}from"./engine-core-CLR3cxsqf4.js";import{parseVariantFlags as he}from"./variants-De-VVc9BRz.js";import{bootEngine as pe,setBadge as _,log as O}from"./loading-ui-CjCwSRuXvh.js";import{s as fe,q as me,a as Y,w as ge,b as ee,c as te,o as ke}from"./chat-ui-CQAxV1po6S.js";import{m as we}from"./model-registry-BoK-ObgACo.js";function be(t,o,u=3,l=3){let i=o,p=0,g=0,r=0;for(;i=0;a--){if(a+u>e)continue;let m=!0;for(let d=0;d=t.length||d>=t.length||t[m]!==t[d])break;s++}g+=s,i+=1+s}return{totalSteps:p,totalAccepted:g,totalHits:r}}function ye(t,o,u,l=3,i=3){const{totalSteps:p,totalAccepted:g,totalHits:r}=be(o,u,l,i),e=p>0?g/p:0,c=i>0?e/i:0,s=2*(1+e)/(i+1),a=p>0?r/p:0;return{name:t,N:l,K:i,totalSteps:p,totalAccepted:g,meanAcceptedPerStep:e,acceptanceRate:c,theoreticalSpeedup:s,hitRate:a}}const xe="Section %d. The transport of water through a plant begins at the root hairs, where dissolved minerals enter by active transport and water follows by osmosis. Cohesion between water molecules and adhesion to the xylem walls maintain a continuous column that is pulled upward as vapour escapes from the stomata. The rate of that escape depends on humidity, temperature, and wind, so a plant that closes its stomata to conserve water also limits the carbon dioxide available to its chloroplasts. ";function q(t){let o="";for(let u=1;u<=t;u++)o+=xe.replace("%d",String(u));return o}function $e(t,o,u=4){const i=o(q(4)).length,p=o("").length,g=Math.max(1,(i-p)/4);let r=Math.max(0,Math.round((t-p)/g)),e=o(q(r));for(let a=0;a<200&&Math.abs(e.length-t)>u&&!(e.length>t&&r===0||(r+=e.lengtht+u&&s.length>1;a++)s=s.slice(0,-1),c=s.join(" "),e=o(c);for(let a=0;a<400&&e.length{if(t.isBusy())return console.warn("[bench] decode already in flight β€” skipping"),null;t.setBusy(!0);try{const r=[{role:"system",content:"You are a helpful assistant."},{role:"user",content:"Write a four-sentence explanation of how photosynthesis works."}],e=B(o.spec,r,u);if(p){const f=await o.profileStep(e);return f?(console.log(`[profile] total = ${f.totalMs.toFixed(3)} ms/token across ${f.kernels.reduce((w,T)=>w+T.calls,0)} dispatches`),console.table(f.kernels.map(w=>({kernel:w.label,calls:w.calls,"ms/token":+w.totalMs.toFixed(3),"ms/call":+(w.totalMs/w.calls).toFixed(4),"% total":+w.pctOfTotal.toFixed(1)}))),f):(console.warn("[bench] profile requested but timestamp-query feature unavailable"),null)}const c=[];for(let f=0;f<=i;f++){const w=f===0?"warmup":`run${f}/${i}`;o.resetKVTracking();const T=performance.now();let $=0,P=0;await o.generatePipelined(e,l,()=>{$===0&&(P=performance.now()),$++});const C=performance.now(),A=(C-T)/1e3,R=$/A,D=P?P-T:A*1e3,z=$>1&&P?($-1)/((C-P)/1e3):R;console.log(`[bench] ${w}: ${$} tok / ${A.toFixed(2)}s = ${R.toFixed(2)} tok/s total Β· ttft ${D.toFixed(0)}ms Β· decode ${z.toFixed(2)} tok/s`),f>0&&c.push({tokens:$,seconds:A,tokPerS:R,ttftMs:D,decodeTokPerS:z})}const s=f=>f.slice().sort((w,T)=>w-T)[Math.floor(f.length/2)],a=c.map(f=>f.tokPerS).sort((f,w)=>f-w),m=a[Math.floor(a.length/2)],d=a.reduce((f,w)=>f+w,0)/a.length,b=a[0],S=a[a.length-1],k=s(c.map(f=>f.decodeTokPerS)),x=s(c.map(f=>f.ttftMs)),y={runs:c,median:m,mean:d,min:b,max:S,medianDecode:k,medianTtftMs:x};return console.log(`[bench] summary: median=${m.toFixed(2)} mean=${d.toFixed(2)} min=${b.toFixed(2)} max=${S.toFixed(2)} tok/s Β· decode=${k.toFixed(2)} tok/s Β· ttft=${x.toFixed(0)}ms`),y}finally{t.setBusy(!1),t.onIdle()}},window.benchTtft=async(l=Se,i=8,p=3)=>{if(t.isBusy())return console.warn("[ttft] decode already in flight β€” skipping"),null;t.setBusy(!0);try{const g=s=>s.slice().sort((a,m)=>a-m)[Math.floor(s.length/2)],r=s=>B(o.spec,[{role:"system",content:"You are a helpful assistant."},{role:"user",content:s}],u),e=[];for(const s of l){const a=$e(s,r),m=r(a.userMessage);if(m.length>o.maxContext){console.warn(`[ttft] target ${s}: ${m.length} tok exceeds maxContext ${o.maxContext} β€” skipped`);continue}const d=[],b=[];let S=0;o.resetKVTracking(),await o.generatePipelined(m,i,()=>{});for(let k=0;k{y===0&&(f=performance.now()),y++});const w=performance.now(),T=(w-x)/1e3,$=f?f-x:T*1e3,P=y>1&&f?(y-1)/((w-f)/1e3):y/T,C=o.getLastPrefill();C&&(S=C.chunks),d.push($),b.push(P),console.log(`[ttft] prompt=${m.length} tok (target ${s}) run${k+1}: ttft ${$.toFixed(0)}ms Β· ${y} gen tok Β· decode ${P.toFixed(2)} tok/s`+(C?` Β· prefill chunks ${C.chunks} (reused ${C.reused})`:""))}e.push({targetTokens:s,promptTokens:m.length,ttftMsRuns:d,ttftMs:g(d),decodeTokPerS:g(b),chunks:S})}console.log(""),console.log(" prompt tok | TTFT ms | ms/prompt-token | vs shortest | chunks"),console.log(" -----------+----------+------------------+--------------+-------");const c=e[0];for(const s of e)console.log(` ${String(s.promptTokens).padStart(10)} | ${s.ttftMs.toFixed(0).padStart(8)} | ${(s.ttftMs/s.promptTokens).toFixed(3).padStart(16)} | ${(s.ttftMs/c.ttftMs).toFixed(2).padStart(12)}x | ${String(s.chunks).padStart(6)}`);return{mode:"ttft-sweep",engine:"zero-tvm",runsPerPoint:p,genTokensPerPoint:i,points:e}}finally{t.setBusy(!1),t.onIdle()}},window.benchBatched=async(l=4,i=500,p="ffnDown")=>t.isBusy()?(console.warn("[batched-bench] decode in flight β€” skipping"),null):o.benchBatchedFfnDown(l,i,p),window.specSim=async(l=160,i=3,p=3)=>{if(t.isBusy())return console.warn("[specSim] decode in flight β€” skipping"),null;t.setBusy(!0);try{const g=[{name:"prose",messages:[{role:"system",content:"You are a helpful assistant."},{role:"user",content:"Write a four-sentence explanation of how photosynthesis works."}]},{name:"code",messages:[{role:"system",content:"You are a helpful coding assistant. Reply with only code, no prose."},{role:"user",content:"Write a TypeScript function that computes the nth Fibonacci number using memoization. Include a short test block that prints fib(10), fib(20), and fib(30)."}]},{name:"summary",messages:[{role:"system",content:"You are a helpful assistant."},{role:"user",content:"List the top five considerations when designing a REST API, with a one-sentence explanation for each."}]}],r=[];for(const e of g){const c=B(o.spec,e.messages,u),s=[];await o.generatePipelined(c,l,d=>{s.push(d)});const a=[...c,...s],m=ye(e.name,a,c.length,i,p);r.push({...m,generatedTokens:s.length}),console.log(`[specSim] ${e.name}: ${s.length} gen tok, hit=${(m.hitRate*100).toFixed(0)}%, Ξ±=${(m.acceptanceRate*100).toFixed(0)}%, accepted/step=${m.meanAcceptedPerStep.toFixed(2)}/${p}, theoretical speedup=${m.theoreticalSpeedup.toFixed(2)}Γ—`)}return console.table(r.map(e=>({prompt:e.name,gen:e.generatedTokens,"hit %":+(e.hitRate*100).toFixed(1),"accept Ξ±":+(e.acceptanceRate*100).toFixed(1),"accepted/step":+e.meanAcceptedPerStep.toFixed(2),speedup:+e.theoreticalSpeedup.toFixed(2)}))),r}finally{t.setBusy(!1),t.onIdle()}},window.benchPrefill=async(l=800,i=3)=>{var p;if(t.isBusy())return console.warn("[benchPrefill] decode in flight β€” skipping"),null;t.setBusy(!0);try{const g="Photosynthesis is the process by which green plants convert light energy into chemical energy, storing it in the bonds of glucose molecules that fuel growth, respiration, and reproduction. ";let r=4,e=[];const c=d=>B(o.spec,[{role:"system",content:"You are a helpful assistant."},{role:"user",content:g.repeat(d)+` +Summarize the passage above in one sentence.`}],u);for(e=c(r);e.length{});const S=performance.now()-b,k=e.length/(S/1e3),x=d===0?"warmup":`run${d}/${i}`;console.log(`[benchPrefill] ${x}: ${S.toFixed(0)} ms to first token = ${k.toFixed(1)} prefill tok/s`),d>0&&s.push({ttftMs:S,tokPerS:k})}const a=s.map(d=>d.tokPerS).sort((d,b)=>d-b),m=a[Math.floor(a.length/2)];return console.log(`[benchPrefill] median: ${m.toFixed(1)} prefill tok/s (${((p=o.getLastPrefill())==null?void 0:p.chunks)??0} chunks/run)`),{promptTokens:e.length,runs:s,median:m,lastPrefill:o.getLastPrefill()}}finally{t.setBusy(!1),t.onIdle()}},window.benchTurns=async(l=120)=>{if(t.isBusy())return console.warn("[benchTurns] decode in flight β€” skipping"),null;t.setBusy(!0);try{const i="The city library reopened after a two-year renovation that added a glass atrium, a children's wing, quiet study pods, and a rooftop reading garden overlooking the river. ",p=[`Here is some background material: ${i.repeat(9)} +What changed in the renovation? Answer in detail.`,`More context: ${i.repeat(9)} +How do the study pods differ from the reading garden?`,"Now, in one short sentence: what overlooks the river?"];o.resetKVTracking();const g=[{role:"system",content:"You are a helpful, concise assistant."}],r=[];for(let e=0;e{s.length===0&&(m=performance.now()-a),s.push(b)}),g.push({role:"assistant",content:u.decode(s)});const d=o.getLastPrefill();console.log(`[benchTurns] turn ${e+1}: prompt ${c.length} tok, reused ${(d==null?void 0:d.reused)??0}, chunks ${(d==null?void 0:d.chunks)??0}, TTFT ${m.toFixed(0)} ms, generated ${s.length}`),r.push({turn:e+1,promptTokens:c.length,ttftMs:m,...d})}return r}finally{t.setBusy(!1),t.onIdle()}},window.checkReuse=async()=>{var l;if(t.isBusy())return console.warn("[checkReuse] decode in flight β€” skipping"),null;t.setBusy(!0);try{o.resetKVTracking();const i=[{role:"system",content:"You are a helpful, concise assistant."},{role:"user",content:"Name three primary colors."}],p=B(o.spec,i,u),g=[];await o.generatePipelined(p,48,a=>g.push(a)),i.push({role:"assistant",content:u.decode(g)}),i.push({role:"user",content:"Which of those is the warmest?"});const r=B(o.spec,i,u),e=await o.debugCompareReuse(r),c=(((l=o.getLastPrefill())==null?void 0:l.chunks)??0)>0,s=e.maxAbsDiff===0?" (bit-exact βœ“)":c?" (nonzero: prefix built by CHUNKED prefill vs per-token replay β€” kernel-variant numerics; rerun with ?chunk=0 for the bit-exact assertion)":" (MISMATCH β€” reuse rules broken!)";return console.log(`[checkReuse] prompt ${e.promptLen} tok, reused prefix ${e.startPos} β€” logits max|Ξ”| = ${e.maxAbsDiff} mean|Ξ”| = ${e.meanAbsDiff}`+s),{...e,chunkedPrefix:c}}finally{t.setBusy(!1),t.onIdle()}},console.log("[bench] harness ready β€” call `await bench(128, 3)` or `await bench(0, 0, true)` for per-kernel profile"),console.log("[bench] prefill: `await benchPrefill(800, 3)` Β· multi-turn TTFT: `await benchTurns()` Β· reuse assertion: `await checkReuse()`"),console.log("[bench] batched primitive: `await benchBatched(4, 500)` β€” ffnDown weight-reuse falsifiability test"),console.log("[bench] PLD acceptance sim: `await specSim(160, 3, 3)` β€” measures spec-decode upside without GPU changes")}const F=t=>document.getElementById(t);function ne(t){const o=F("stats");o&&(o.textContent=t)}function Le(t,o){const u=F("loading-error");if(u){if(u.textContent=t,o){const l=document.createElement("button");l.type="button",l.id="retry-download-btn",l.textContent="Retry download",l.style.cssText="display:block;margin-top:0.6rem;padding:0.45rem 1.1rem;background:var(--accent);color:#000;font-weight:600;font-size:0.8rem;border:none;border-radius:6px;cursor:pointer",l.addEventListener("click",()=>{u.classList.remove("visible"),u.replaceChildren(),o()},{once:!0}),u.appendChild(l)}u.classList.add("visible")}}function Ee(t){const{spec:o}=t,u=t.search??location.search,l=t.poolSlots??0;return pe({spec:o,...l?{expertPool:l}:{},optionalFeatures:["subgroups","timestamp-query","chromium-experimental-subgroup-matrix"],probeSubgroups:!0,onDeviceLost:t.onDeviceLost,buildEngine:({device:i,weights:p,sgSizeOk:g,spec:r})=>{const e=he(u,{hasSubgroupsFeature:i.features.has("subgroups"),sgSizeOk:g}),c=!r.qkNorm&&r.weightFormat!=="mlx-safetensors",s=e.int8KV&&!r.mla;e.int8KV&&!s&&O(`?kv8=1 ignored for ${r.id} β€” int8 KV does not cover MLA, which caches a latent rather than per-head K/V`);const a=Math.round(r.maxContext*r.kvBytesPerToken/(1024*1024)/(s?2:1));O(`Allocating ${s?"int8 ":""}KV cache β€” ${r.maxPages} pages Γ— ${r.pageSize} = ${r.maxContext} tokens Β· ~${a} MiB`);const m=s?ce(i,r):de(i,r);O("Building decode engine…");const d=new URLSearchParams(u),b=(k,x)=>{const y=d.get(k);if(y===null)return x;const f=Number(y);return Number.isFinite(f)?f:x},S=b("temp",0);return S>0&&O(`Sampling: temperature ${S}, top-p ${b("topp",1)}, min-p ${b("minp",0)}`),ue(i,p,m,{variants:e,fused:c,int8KV:s,spec:r,...l?{expertPool:l}:{},prefixReuse:d.get("reuse")!=="0",chunkedPrefill:d.get("chunk")!=="0",pooledChunkedPrefill:d.get("chunk")==="1",sampling:S>0?{temperature:S,topP:b("topp",1),minP:b("minp",0),seed:b("seed",0)}:void 0})},warmup:async(i,p,g)=>{const r=B(o,[{role:"user",content:"Hi."}],p),e=o.moe?r.slice(0,1):r,c=performance.now();g==null||g(`Warming up pipeline β€” ${e.length} token(s)${o.moe?" (single-pass: MoE)":""}…`),await i.generatePipelined(e,1,()=>{}),g==null||g(`Warmup done in ${((performance.now()-c)/1e3).toFixed(1)}s`)}})}function Ie(t){var J;const{spec:o,tokenizer:u,engine:l}=t,i=we(o),p=t.log??O,g=t.onPhase??(()=>{});fe(i.name,me(o));const r=F("composer"),e=F("inp"),c=F("btn"),s=F("stop-btn"),a=F("new-chat-btn"),m=F("suggest-grid"),d=F("ctx-hint"),b=F("welcome");if(!r||!e||!c||!s){console.error("[zero-tvm] chat UI elements missing");return}const S="You are a helpful, concise assistant. Use Markdown (numbered lists, **bold**, and fenced ```code``` blocks with a language tag) when it clarifies the answer.\n\nYou are running inside zero-tvm (zerotvm.com): an LLM inference engine written by hand in WGSL and TypeScript, running entirely in this browser tab on the user's own GPU through WebGPU β€” no server, no WebLLM, no TVM. Model weights download once from Hugging Face and cache on this device; prompts, generated tokens and the KV cache never leave the machine. The whole forward pass is 10 hand-written WGSL kernel roles, readable end-to-end. If asked what zero-tvm is, answer from this paragraph.",k=[{role:"system",content:S}];let x=!1,y=!1;const f=`zt-chat-${o.id}`;function w(){try{const n=k.filter(h=>h.role!=="system").slice(-40);localStorage.setItem(f,JSON.stringify(n))}catch{}}function T(){let n=[];try{n=JSON.parse(localStorage.getItem(f)??"[]")}catch{return}!Array.isArray(n)||n.length===0||(n.forEach((h,v)=>{var L;if(h.role==="user"&&typeof h.content=="string")k.push({role:"user",content:h.content}),ee(h.content);else if(h.role==="assistant"&&typeof h.content=="string"){const M={role:"assistant",content:h.content,ids:Array.isArray(h.ids)?h.ids:void 0};k.push(M),te().finish({fullText:h.content,tokens:((L=M.ids)==null?void 0:L.length)??0,tokPerS:0,...v===n.length-1?{onRegenerate:()=>{j()}}:{}})}}),C(B(o,k,u).length))}function $(){!e||!c||(c.disabled=x||e.value.trim().length===0)}function P(n){!e||!c||!s||(x=n,c.hidden=n,s.hidden=!n,s.disabled=!n,e.disabled=n,a&&(a.disabled=n),e.placeholder=n?"Generating…":`Ask ${i.name} anything…`)}function C(n){if(d)if(!n)d.textContent="Zero TVM Β· 10 WGSL kernel roles Β· β€œ/” for commands";else if(n>=l.maxContext)d.textContent=`Context full β€” ${l.maxContext} / ${l.maxContext} tokens Β· start a new chat`;else{const h=Math.round(n/l.maxContext*100);d.textContent=`Context ${n} / ${l.maxContext} tokens (${h}%)`}}function A(){if(x)return;k.length=0,k.push({role:"system",content:S});try{localStorage.removeItem(f)}catch{}const n=F("messages");n&&n.replaceChildren(),b==null||b.classList.remove("hidden"),ne(""),_("Ready","ready"),C(),matchMedia("(pointer: coarse)").matches||e==null||e.focus()}e.disabled=!1,e.placeholder=`Ask ${i.name} anything…`,a&&(a.disabled=!1),Y(e),$(),C(),ge(),T();const R=!matchMedia("(pointer: coarse)").matches;R&&e.focus(),(J=t.lock)==null||J.onChange(n=>{x||(e.disabled=n,c.disabled=n||e.value.trim().length===0,e.placeholder=n?"Serving a room guest…":`Ask ${i.name} anything…`)}),e.addEventListener("input",()=>{Y(e),$()}),e.addEventListener("keydown",n=>{n.key==="Enter"&&!n.shiftKey&&(n.preventDefault(),c.disabled||r.requestSubmit())}),r.addEventListener("submit",n=>{n.preventDefault(),c.disabled||se()}),s.addEventListener("click",()=>{y=!0,s.disabled=!0}),a==null||a.addEventListener("click",A),m==null||m.addEventListener("click",n=>{const h=n.target.closest(".suggest");if(!h)return;const v=h.dataset.prompt;v&&(e.value=v,Y(e),$(),r.requestSubmit())});async function D(n,h,v,L){const M=l.maxContext-h.length;p(`Prompt: ${h.length} tokens Β· reply budget ${M} tokens`),C(h.length);const E=v.length>0;E||n.showThinking(),g(E?"generating":"thinking");const K=performance.now();let I=0,V=!0;const H=v.slice();let W=E?u.decode(H):"";try{await l.generatePipelined(h,M,N=>{var X;(X=t.onToken)==null||X.call(t),V&&(E||n.body.replaceChildren(n.cursor),V=!1,g("generating")),I++,H.push(N),W=u.decode(H),n.render(W);const le=(performance.now()-K)/1e3;ne(`${I} tok Β· ${(I/le).toFixed(1)} tok/s`)},()=>y)}catch(N){W+=` + +_[Error: ${N}]_`,p(`Error: ${N}`)}const re=(performance.now()-K)/1e3,ae=I/Math.max(re,.001),Z=h.length+I,Q=I>=M,ie=y&&!Q;return n.finish({fullText:W,tokens:H.length,tokPerS:ae,onRegenerate:()=>{j()},notice:Q?{text:`Cut off β€” the ${l.maxContext}-token context window is full. Start a new chat to keep going.`}:ie?{text:"Stopped β€” this reply is incomplete.",onContinue:L}:void 0}),g("idle"),C(Z),Z>=l.maxContext&&(_("Context full","error"),p(`Context window full (${l.maxContext} tokens) β€” start a new chat to continue.`)),{text:W,ids:H}}async function z(){var M,E,K,I;P(!0),y=!1,await((M=t.lock)==null?void 0:M.acquire()),(E=F("messages"))==null||E.querySelectorAll(".truncation-btn").forEach(V=>V.remove());const n=te(),h=B(o,k,u);if(h.length>=l.maxContext){const V=`Context full β€” the conversation (${h.length} tokens) exceeds the ${l.maxContext}-token window. Start a new chat.`;n.finish({fullText:`_${V}_`,tokens:0,tokPerS:0}),_("Context full","error"),C(h.length),p(V),(K=t.lock)==null||K.release(),P(!1),y=!1,$();return}const v={role:"assistant",content:""};k.push(v);const L=await D(n,h,[],()=>{G(v,n)});v.content=L.text,v.ids=L.ids,w(),(I=t.lock)==null||I.release(),P(!1),y=!1,$(),R&&(e==null||e.focus())}async function G(n,h){var M,E;if(x||!n.ids||k[k.length-1]!==n)return;P(!0),y=!1,await((M=t.lock)==null?void 0:M.acquire());const v=B(o,k.slice(0,-1),u).concat(n.ids),L=await D(h,v,n.ids,()=>{G(n,h)});n.content=L.text,n.ids=L.ids,w(),(E=t.lock)==null||E.release(),P(!1),y=!1,$(),R&&(e==null||e.focus())}const U={new:()=>A(),canvas:()=>{ke()||oe("No runnable code block yet β€” ask for html, svg, or js.")},...t.commands};function oe(n){var v;const h=document.createElement("div");h.className="sys-note",h.textContent=n,(v=F("messages"))==null||v.appendChild(h)}async function se(){if(x||!e)return;const n=e.value.trim();if(!n)return;const h=n.startsWith("/")?n.slice(1).split(/\s/)[0].toLowerCase():"";if(h&&U[h]){e.value="",Y(e),$(),U[h]();return}e.value="",Y(e),ee(n),k.push({role:"user",content:n}),w(),await z()}async function j(){var h;if(x||k.length===0||k[k.length-1].role!=="assistant")return;k.pop(),w();const n=(h=F("messages"))==null?void 0:h.querySelectorAll(".msg.ai");n&&n.length>0&&n[n.length-1].remove(),await z()}ve({engine:l,tokenizer:u,isBusy:()=>x,setBusy:P,onIdle:$})}export{Ee as b,Le as s,Ie as w}; diff --git a/assets/chat-ui-CQAxV1po6S.js b/assets/chat-ui-CQAxV1po6S.js new file mode 100644 index 0000000000000000000000000000000000000000..601f5c96e75d8a0ebae41c18326ea3a7b8226482 --- /dev/null +++ b/assets/chat-ui-CQAxV1po6S.js @@ -0,0 +1,23 @@ +import{_}from"./preload-helper-ckwbz45pO3.js";const x='',H='',q='';function M(t,s){t.innerHTML=`${x}Copy`,t.addEventListener("click",async()=>{try{await navigator.clipboard.writeText(s()),t.classList.add("copied"),t.innerHTML=`${H}Copied`,setTimeout(()=>{t.classList.remove("copied"),t.innerHTML=`${x}Copy`},1500)}catch{}})}function u(t,s){const n=/(```)|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*\n]+\*)|\[([^\]]+)\]\(([^)\s]+)\)/g;let e=0,c;for(;(c=n.exec(t))!==null;)if(!c[1]){if(c.index>e&&s.appendChild(document.createTextNode(t.slice(e,c.index))),c[2]){const o=document.createElement("code");o.textContent=c[2].slice(1,-1),s.appendChild(o)}else if(c[3]){const o=document.createElement("strong");o.textContent=c[3].slice(2,-2),s.appendChild(o)}else if(c[4]){const o=document.createElement("em");o.textContent=c[4].slice(1,-1),s.appendChild(o)}else if(c[5]&&c[6])if(/^https?:\/\//i.test(c[6])){const o=document.createElement("a");o.href=c[6],o.textContent=c[5],o.target="_blank",o.rel="noopener noreferrer",s.appendChild(o)}else s.appendChild(document.createTextNode(c[0]));e=c.index+c[0].length}eimport("./feats-dlBfBxqpI8.js"),[]).then(a=>a.recordFeat("canvas")).catch(()=>{}),(r=document.getElementById("code-canvas"))==null||r.remove();const n=document.createElement("div");n.id="code-canvas",n.innerHTML=` + `;const e=document.activeElement,c=()=>{var a;n.remove(),window.removeEventListener("keydown",o),(a=e==null?void 0:e.focus)==null||a.call(e)},o=a=>{a.key==="Escape"&&c()};n.addEventListener("click",a=>{const i=a.target;(i===n||i.closest(".canvas-close"))&&c()}),window.addEventListener("keydown",o);const p=t.replace(/<\/script/gi,"<\\/script"),l=s==="html"?t:s==="svg"?`${t}`:`

+
+
+
diff --git a/entrance/backring.svg b/entrance/backring.svg
new file mode 100644
index 0000000000000000000000000000000000000000..eeb9ec37c805be7e259312e1bf054c1a09ab4d5d
--- /dev/null
+++ b/entrance/backring.svg
@@ -0,0 +1,18 @@
+
+  
+    
+    
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+    
+  
+
diff --git a/entrance/banner.svg b/entrance/banner.svg
new file mode 100644
index 0000000000000000000000000000000000000000..03cd16626ace4d49da77348a8d3eee52f6bbeac0
--- /dev/null
+++ b/entrance/banner.svg
@@ -0,0 +1,13 @@
+
+  
+    
+    
+    
+    
+  
+  
+    
+    
+    
+  
+
diff --git a/entrance/bg.svg b/entrance/bg.svg
new file mode 100644
index 0000000000000000000000000000000000000000..cd236088b8880480d30a37bb097077222042f0b1
--- /dev/null
+++ b/entrance/bg.svg
@@ -0,0 +1,27 @@
+
+  
+  
+    
+      
+      
+      
+    
+    
+      
+      
+      
+    
+    
+      
+      
+    
+    
+  
+  
+  
+  
+  
+  
+  
+
diff --git a/entrance/border.svg b/entrance/border.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ed80db72b07f83c6a7aa7c8364fe6123192bffd3
--- /dev/null
+++ b/entrance/border.svg
@@ -0,0 +1,6 @@
+
+  
+  
+  
+  
+
diff --git a/entrance/column.svg b/entrance/column.svg
new file mode 100644
index 0000000000000000000000000000000000000000..cbd79b1b40eb0638130271f493d840a1b218965c
--- /dev/null
+++ b/entrance/column.svg
@@ -0,0 +1,20 @@
+
+  
+    
+      
+    
+  
+  
+  
+  
+    
+    
+    
+  
+  
+    
+    
+    
+    
+  
+
diff --git a/entrance/corner.svg b/entrance/corner.svg
new file mode 100644
index 0000000000000000000000000000000000000000..10aab02188a6e39c95c889dba3ccf8102ff08530
--- /dev/null
+++ b/entrance/corner.svg
@@ -0,0 +1,11 @@
+
+  
+    
+    
+  
+  
+    
+    
+    
+  
+
diff --git a/entrance/divider.svg b/entrance/divider.svg
new file mode 100644
index 0000000000000000000000000000000000000000..84f0319de232a46d6c05107dbf8a70e88cfeb7c8
--- /dev/null
+++ b/entrance/divider.svg
@@ -0,0 +1,13 @@
+
+  
+    
+    
+    
+    
+  
+  
+    
+    
+    
+  
+
diff --git a/entrance/pframe.svg b/entrance/pframe.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6f71e520429950a9e97b6f7db16c422a47d5ac27
--- /dev/null
+++ b/entrance/pframe.svg
@@ -0,0 +1,7 @@
+
+  
+    
+    
+  
+  
+
diff --git a/entrance/ring.svg b/entrance/ring.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7428cf6bac71733d8b07e468c5f95688c7b8e76e
--- /dev/null
+++ b/entrance/ring.svg
@@ -0,0 +1,22 @@
+
+  
+  
+    
+    
+    
+    
+      
+      
+      
+      
+      
+      
+      
+    
+    
+      
+      
+    
+  
+
diff --git a/entrance/spires.svg b/entrance/spires.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f44ae5d5778249dd8ffa6e1e8ec1bb4378d7c170
--- /dev/null
+++ b/entrance/spires.svg
@@ -0,0 +1,31 @@
+
+  
+  
+    
+      
+    
+  
+  
+    
+    
+    
+    
+    
+    
+  
+  
+    
+    
+    
+    
+    
+    
+  
+  
+    
+    
+    
+    
+  
+
diff --git a/favicon.svg b/favicon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..fa26b61edeeea45a5e67642d07497d4cdc963793
--- /dev/null
+++ b/favicon.svg
@@ -0,0 +1,7 @@
+
+  
+  
+  
+  
+
diff --git a/fonts.css b/fonts.css
new file mode 100644
index 0000000000000000000000000000000000000000..cdd05e8271a37d62926d9f6dfbdd3fc18d9d4136
--- /dev/null
+++ b/fonts.css
@@ -0,0 +1,131 @@
+/* Self-hosted. The site's claim is that nothing leaves your machine;
+   a webfont request to Google would leak every visitor's IP and make
+   that claim false on the landing page itself. 292 KB, 7 files. */
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f2-fraunce.woff2') format('woff2');
+  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f3-fraunce.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f1-fraunce.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: url('/fonts/f2-fraunce.woff2') format('woff2');
+  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: url('/fonts/f3-fraunce.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Fraunces';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: url('/fonts/f1-fraunce.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f5-plinean.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f4-plinean.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('/fonts/f5-plinean.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('/fonts/f4-plinean.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: url('/fonts/f5-plinean.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Spline Sans';
+  font-style: normal;
+  font-weight: 600;
+  font-display: swap;
+  src: url('/fonts/f4-plinean.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Spline Sans Mono';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f7-plineanmono.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Spline Sans Mono';
+  font-style: normal;
+  font-weight: 400;
+  font-display: swap;
+  src: url('/fonts/f6-plineanmono.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Spline Sans Mono';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('/fonts/f7-plineanmono.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+  font-family: 'Spline Sans Mono';
+  font-style: normal;
+  font-weight: 500;
+  font-display: swap;
+  src: url('/fonts/f6-plineanmono.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/fonts/f1-fraunce.woff2 b/fonts/f1-fraunce.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..810c55342bbd1cb7fe332a3c5962b453ba6f3c24
--- /dev/null
+++ b/fonts/f1-fraunce.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:48282a415ec22e31beaf0a0666e6fae0c8cbddcd0b1f6e729f27c3ade8a64e43
+size 67388
diff --git a/fonts/f2-fraunce.woff2 b/fonts/f2-fraunce.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..c25ebf378552d44f3e53365203312480e91f1bc4
--- /dev/null
+++ b/fonts/f2-fraunce.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7e3c04662669cd2b8ee672e4e5d63146d21f6aa36654b211718af5fe0bb3bd51
+size 19748
diff --git a/fonts/f3-fraunce.woff2 b/fonts/f3-fraunce.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..1d55c1520197d186e41fed67d43ddab34dec8fec
--- /dev/null
+++ b/fonts/f3-fraunce.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f120089b9440f3e35a980a2137347b1851ad46ee4b8ad5c63b6c07e053fa40e2
+size 59540
diff --git a/fonts/f4-plinean.woff2 b/fonts/f4-plinean.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..4b9234a43e9b4511727713c2098f061cabb1c3d9
--- /dev/null
+++ b/fonts/f4-plinean.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:14ed7b4db18fb8fd77f252da580df74f457f81a788cff0b71f357e882279156c
+size 57844
diff --git a/fonts/f5-plinean.woff2 b/fonts/f5-plinean.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..dfe3af79e3535a21ffa11c1b249a21ff88938852
--- /dev/null
+++ b/fonts/f5-plinean.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d13b339c84af67cc3b402cc08f2f53e08f29b65b9ca5ac7b729b5b4243a02638
+size 21048
diff --git a/fonts/f6-plineanmono.woff2 b/fonts/f6-plineanmono.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..e499b3165318c17a1cac589116255726f61dba78
--- /dev/null
+++ b/fonts/f6-plineanmono.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b193a4f0fef3fc4c6a3fa756bb5dab6c10b489acd5e0718736847755077e9ec
+size 36528
diff --git a/fonts/f7-plineanmono.woff2 b/fonts/f7-plineanmono.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..7f5359141442488ce894cfead44f1bbb3c5d9377
--- /dev/null
+++ b/fonts/f7-plineanmono.woff2
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bde42c142265c31a450781d82cc72e1859bd42cba8e438fb96f77f1d69ebc8d6
+size 20748
diff --git a/index.html b/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..fae70c38ca99a12383890a43a7ffa0a3ced440aa
--- /dev/null
+++ b/index.html
@@ -0,0 +1,151 @@
+
+
+
+  
+  
+  zero-tvm β€” LLM inference in the browser, on hand-written WGSL
+  
+  
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+  
+  
+  
+  
+  
+  
+  
+
+  
+  
+  
+  
+  
+  
+
+
+
+
+
+
+ +
+
+ + + + + + + + diff --git a/landing.css b/landing.css new file mode 100644 index 0000000000000000000000000000000000000000..6a6815bf5e5905ccb8cec1424c313335213531ff --- /dev/null +++ b/landing.css @@ -0,0 +1,1442 @@ +/* LANDING β€” Foundry chassis. docs/SITE_REDESIGN.md. + No figure is written here. Anything numeric on this page is rendered from + model-registry.ts at runtime or it is absent, so adding a model updates the + page by itself and a stale number cannot be committed. */ + +*, *::before, *::after { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--body); + font-size: var(--step-0); + line-height: 1.65; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hi); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--rad-s); } + +.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 24px; } + +/* ── eyebrow ───────────────────────────────────────────────────────── */ +.eyebrow { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--mono); font-size: var(--step--1); + letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); +} +.pulse { + width: 7px; height: 7px; border-radius: 50%; background: var(--accent); + box-shadow: 0 0 0 0 var(--accent-dim); animation: pulse 2.4s ease-out infinite; +} +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 #f0a86066; } + 70% { box-shadow: 0 0 0 9px #f0a86000; } + 100% { box-shadow: 0 0 0 0 #f0a86000; } +} +@media (prefers-reduced-motion: reduce) { .pulse { animation: none; } } + +/* ── nav ───────────────────────────────────────────────────────────── */ +.nav { + position: sticky; top: 0; z-index: 40; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); +} +.nav-in { display: flex; align-items: center; gap: 28px; height: 60px; } +.nav-logo { + font-family: var(--mono); font-weight: 500; color: var(--text); + letter-spacing: -0.02em; margin-right: auto; +} +.nav-logo span { color: var(--accent); } +.nav a:not(.nav-logo):not(.nav-cta) { color: var(--muted); font-size: var(--step--1); } +.nav a:not(.nav-logo):not(.nav-cta):hover { color: var(--text); } +.nav-cta { + font-size: var(--step--1); padding: 7px 15px; border-radius: var(--rad-s); + border: 1px solid var(--accent); color: var(--accent); +} +.nav-cta:hover { background: var(--accent); color: var(--bg); } +.nav-links { display: flex; gap: 22px; align-items: center; } +/* On phones the nav used to collapse to ONLY "Open chat" β€” the single action + that costs gigabytes β€” while Docs and everything explanatory vanished. Docs + stays; the heavyweight links are the ones that can go. */ +@media (max-width: 720px) { .nav-links a:not(.nav-cta):not([href="docs.html"]) { display: none; } } + +/* ── hero ──────────────────────────────────────────────────────────── */ +.hero { padding: 84px 0 64px; } +.hero h1 { + font-family: var(--display); + font-size: var(--step-3); + font-weight: 400; + line-height: 1.08; + letter-spacing: -0.02em; + margin: 20px 0 0; + text-wrap: balance; +} +.hero h1 em { font-style: italic; color: var(--accent); } +.hero p { + color: var(--muted); max-width: 62ch; margin: 20px 0 0; font-size: var(--step-1); + line-height: 1.6; +} +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 28px; } +.btn { + font-family: var(--body); font-size: var(--step-0); + padding: 11px 20px; border-radius: var(--rad-s); border: 1px solid var(--border-hi); + color: var(--text); background: transparent; cursor: pointer; +} +.btn:hover { border-color: var(--accent); color: var(--accent); } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #1a1206; font-weight: 500; } +.btn-primary:hover { background: var(--accent-hi); border-color: var(--accent-hi); color: #1a1206; } + +/* ── kernel window ─────────────────────────────────────────────────── */ +.kw { + margin-top: 44px; border: 1px solid var(--border); border-radius: var(--rad); + background: var(--surface); overflow: hidden; +} +.kw-bar { + display: flex; align-items: center; gap: 10px; + padding: 0 14px; height: 40px; border-bottom: 1px solid var(--border); + background: var(--surface-2); +} +.kw-dots { display: flex; gap: 6px; margin-right: 6px; } +.kw-dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--border-hi); } +.kw-tabs { display: flex; gap: 2px; overflow-x: auto; scrollbar-width: none; } +.kw-tabs::-webkit-scrollbar { display: none; } +.kw-tab { + font-family: var(--mono); font-size: var(--step--1); color: var(--dim); + background: none; border: 0; padding: 6px 11px; border-radius: var(--rad-s); + cursor: pointer; white-space: nowrap; +} +.kw-tab:hover { color: var(--muted); } +.kw-tab[aria-selected="true"] { color: var(--accent); background: var(--accent-dim); } +.kw-body { overflow-x: auto; } +.kw-body pre { + margin: 0; padding: 20px 22px; + font-family: var(--mono); font-size: var(--step--1); line-height: 1.7; + color: var(--muted); +} +.kw-body pre[hidden] { display: none; } +.kw-foot { + display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 10px 16px; border-top: 1px solid var(--border); + font-family: var(--mono); font-size: var(--step--1); color: var(--dim); + flex-wrap: wrap; +} + +/* WGSL syntax β€” one hue family, so the code reads as material rather than + as a rainbow. Comments recede; the accent is spent on identifiers. */ +.t-key { color: #c58bff; } +.t-fn { color: var(--accent-hi); } +.t-num { color: var(--ok); } +.t-str { color: #9fd3ff; } +.t-com { color: var(--dim); font-style: italic; } +.t-attr { color: #7e8aa0; } +.t-type { color: #8fd0ff; } + +/* ── capability band ───────────────────────────────────────────────── */ +.band { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); + border: 1px solid var(--border); border-radius: var(--rad); overflow: hidden; } +@media (max-width: 840px) { .band { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 480px) { .band { grid-template-columns: 1fr; } } +.band a { background: var(--surface); padding: 22px; display: block; color: inherit; } +.band a:hover { background: var(--surface-2); } +.band h3 { + font-family: var(--mono); font-size: var(--step--1); text-transform: uppercase; + letter-spacing: 0.08em; color: var(--accent); margin: 0 0 8px; font-weight: 500; +} +.band p { margin: 0; color: var(--muted); font-size: var(--step--1); line-height: 1.6; } + +/* ── sections ──────────────────────────────────────────────────────── */ +section { padding: 72px 0; } +.sec-head { margin-bottom: 32px; } +.sec-head h2 { + font-family: var(--display); font-weight: 400; font-size: var(--step-2); + margin: 12px 0 0; letter-spacing: -0.01em; +} +.sec-head p { color: var(--muted); margin: 12px 0 0; max-width: 66ch; } + +/* ── model browser ─────────────────────────────────────────────────── */ +/* The container IS the keyboard interface β€” index.html tells the reader the + arrow keys move between models, and this is the element that must be + focused for that to work. Removing its ring left no way to tell. */ +#model-browser:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +.mb-stage { display: flex; align-items: center; gap: 8px; } +.mb-arrow { + flex: 0 0 auto; width: 42px; height: 42px; border-radius: 50%; + background: var(--surface); border: 1px solid var(--border); color: var(--muted); + font-size: 22px; line-height: 1; cursor: pointer; font-family: var(--body); +} +.mb-arrow:hover { border-color: var(--accent); color: var(--accent); } +.mb-slide { + flex: 1 1 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 32px; + align-items: center; min-height: 380px; + border: 1px solid var(--border); border-radius: var(--rad); + background: linear-gradient(180deg, var(--surface-2), var(--surface)); + padding: 28px 32px; +} +@media (max-width: 780px) { + .mb-slide { grid-template-columns: 1fr; text-align: center; min-height: 0; } + .mb-stats { justify-items: center; } + .mb-variants, .mb-info { justify-content: center; align-items: center; } +} +.mb-art { position: relative; padding-bottom: 66px; } +.mb-mascot { display: block; width: 100%; height: 320px; position: relative; z-index: 1; } +@media (max-width: 780px) { .mb-mascot { height: 220px; } } +/* The pedestal: a class-coloured light pool under the figure β€” the character + stands ON something, which is most of what makes a select screen read as a + stage rather than a card. */ +.mb-pedestal { + position: absolute; left: 50%; bottom: 46px; transform: translateX(-50%); + width: 78%; height: 64px; border-radius: 50%; + background: radial-gradient(ellipse at center, + color-mix(in srgb, var(--cs-accent, var(--accent)) 34%, transparent) 0%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 10%, transparent) 55%, + transparent 72%); + filter: blur(2px); +} +/* Nameplate under the stage, class-coloured keyline. */ +.mb-plate { + position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); + text-align: center; padding: 8px 22px 10px; min-width: 60%; + border: 1px solid color-mix(in srgb, var(--cs-accent, var(--accent)) 45%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 7%, var(--surface)); +} +.mb-panel { + border: 1px solid var(--border); border-radius: 12px; + background: linear-gradient(180deg, var(--surface-2), transparent); + padding: 16px 18px 14px; +} +.mb-row-label { + font-family: var(--mono); font-size: var(--step--1); color: var(--dim); + text-transform: uppercase; letter-spacing: 0.09em; margin: 10px 0 6px; +} +.mb-row-label:first-child { margin-top: 0; } + +.mb-info { display: flex; flex-direction: column; gap: 14px; } +.mb-name { font-family: var(--display); font-size: var(--step-2); line-height: 1.1; } +.mb-params { font-family: var(--mono); font-size: var(--step--1); color: var(--dim); margin-top: 2px; } + +.mb-variants { display: flex; gap: 6px; flex-wrap: wrap; } +/* Memory builds β€” the character screen's loadout row. Same pill grammar as + the quantisation picker: a build is a choice about THIS character, and the + selected one re-poses the figure (leaner, streamed experts drifting out). */ +.mb-modes { display: flex; gap: 6px; flex-wrap: wrap; } +.mb-variant { + font-family: var(--mono); font-size: var(--step--1); cursor: pointer; + padding: 5px 12px; border-radius: 99px; + border: 1px solid var(--border-hi); background: transparent; color: var(--muted); +} +.mb-variant:hover { color: var(--text); border-color: var(--accent); } +.mb-variant[aria-selected="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); } + +.mb-stats { display: grid; grid-template-columns: repeat(3, auto); gap: 22px; margin: 14px 0 4px; justify-content: start; } +@media (max-width: 780px) { .mb-stats { justify-content: center; } } +/* At 390px the three-column stats row set a ~204px min-content floor which, + plus slide padding and the two 42px arrows, needed ~368px of a 342px + viewport β€” the body scrolled sideways. Flex-wrap has no min-content floor: + each stat drops to the next line instead of pushing the page. min-width: 0 + on the grid/flex items is what lets the slide actually shrink. */ +@media (max-width: 480px) { + .mb-stats { display: flex; flex-wrap: wrap; gap: 10px 20px; } + .mb-slide { min-width: 0; padding: 20px 18px; } + .mb-art, .mb-info { min-width: 0; } + .mb-arrow { width: 34px; height: 34px; font-size: 18px; } +} +.mb-stats dt { + font-family: var(--mono); font-size: var(--step--1); color: var(--dim); + text-transform: uppercase; letter-spacing: 0.07em; +} +.mb-stats dd { margin: 4px 0 0; font-family: var(--mono); font-size: var(--step-0); color: var(--text); } +.mb-hw { color: var(--dim); font-size: var(--step--1); } +.mb-cached { color: var(--ok); font-size: var(--step--1); margin: 14px 0 0; } +.mb-ram { color: var(--warn); font-size: var(--step--1); margin: 14px 0 0; } +.mb-cta { + align-self: stretch; text-align: center; font-size: var(--step-0); + padding: 0.85rem 1.2rem; + background: var(--cs-accent, var(--accent)); + border-color: var(--cs-accent, var(--accent)); +} +.mb-cta:hover { filter: brightness(1.12); } +@media (max-width: 780px) { .mb-cta { align-self: center; } } + +/* The roster rail β€” party lineup instead of dots. Each slot carries the + character's own snapshotted face (--thumb, painted async); cached models + were snapshotted lit, which is the visual for "unlocked". */ +.mb-dots { display: flex; gap: 10px; justify-content: center; margin-top: 22px; flex-wrap: wrap; } +.mb-dot { + width: 64px; height: 76px; padding: 0 0 4px; cursor: pointer; + border: 1px solid var(--border-hi); border-radius: 10px; + background: + var(--thumb, radial-gradient(circle at 50% 40%, var(--border-hi) 0%, transparent 60%)) + center 2px / 58px 58px no-repeat, + var(--surface); + display: flex; align-items: flex-end; justify-content: center; + transition: border-color 0.15s, transform 0.15s; +} +.mb-dot:hover { border-color: var(--muted); transform: translateY(-2px); } +.mb-dot[aria-selected="true"] { + border-color: var(--cs-accent, var(--accent)); + box-shadow: 0 0 0 1px var(--cs-accent, var(--accent)), + 0 6px 18px color-mix(in srgb, var(--cs-accent, var(--accent)) 25%, transparent); +} +.mb-dot-name { + font-family: var(--mono); font-size: 9px; color: var(--dim); + max-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.mb-dot[aria-selected="true"] .mb-dot-name { color: var(--text); } +@media (max-width: 480px) { .mb-dot { width: 52px; height: 64px; } .mb-dot-name { font-size: 8px; } } +.note { color: var(--muted); font-size: var(--step--1); margin-top: 16px; } + +/* ── kernel wall ───────────────────────────────────────────────────── */ +/* Five columns so the ten tiles fill exactly two rows β€” auto-fill left a + half-empty third row that read as a rendering fault. */ +.wall { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; + background: var(--border); border: 1px solid var(--border); border-radius: var(--rad); + overflow: hidden; } +@media (max-width: 900px) { .wall { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 560px) { .wall { grid-template-columns: repeat(2, 1fr); } } +.wall a { + background: var(--surface); padding: 16px 14px; color: var(--muted); + font-family: var(--mono); font-size: var(--step--1); display: block; +} +.wall a:hover { background: var(--surface-2); color: var(--accent); } +.wall b { display: block; color: var(--text); font-weight: 500; margin-bottom: 4px; } +.wall a:hover b { color: var(--accent); } +.wall span { color: var(--dim); } + +/* ── proof strip ───────────────────────────────────────────────────── */ +.proof { + border: 1px solid var(--border); border-left: 2px solid var(--accent); + border-radius: var(--rad); background: var(--surface); padding: 26px 28px; +} +.proof h2 { font-family: var(--display); font-weight: 400; font-size: var(--step-1); margin: 0; } +.proof p { color: var(--muted); margin: 10px 0 0; max-width: 70ch; } + +/* ── footer ────────────────────────────────────────────────────────── */ +footer { border-top: 1px solid var(--border); padding: 56px 0 72px; } +.foot-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 40px; } +@media (max-width: 780px) { .foot-grid { grid-template-columns: 1fr; gap: 28px; } } +footer h4 { + font-family: var(--mono); font-size: var(--step--1); text-transform: uppercase; + letter-spacing: 0.08em; color: var(--dim); margin: 0 0 12px; font-weight: 500; +} +footer ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } +footer li a { color: var(--muted); font-size: var(--step--1); } +footer li a:hover { color: var(--accent); } +.foot-about { color: var(--muted); font-size: var(--step--1); max-width: 44ch; } +.foot-bottom { + margin-top: 44px; padding-top: 22px; border-top: 1px solid var(--border); + display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; + font-family: var(--mono); font-size: var(--step--1); color: var(--dim); +} + + +/* ══ THE GAME ENTRANCE ═════════════════════════════════════════════════════ + #models is the site: a full-viewport character select. The chrome is the + circuit-rune asset set in /entrance (bg painting, pedestal ring, corner + brackets, divider) β€” monochrome SVGs applied through CSS masks so every + ornament tints to the class accent of the model on stage. Data unchanged: + every value is the registry row. */ +#models { padding: 0; } +#models .cs-root { + position: relative; + min-height: 100dvh; + display: grid; + grid-template-columns: minmax(270px, 350px) 1fr minmax(250px, 320px); + grid-template-rows: auto 1fr auto auto; + grid-template-areas: + 'plate plate roster' + 'sheet stage roster' + 'enter enter roster' + 'note note note'; + gap: 10px 24px; + padding: calc(var(--nav-h, 64px) + 14px) 30px 24px; + background: + radial-gradient(ellipse at 50% 92%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 13%, transparent) 0%, + transparent 52%), + url('/entrance/bg.svg') center / cover no-repeat, + var(--bg); + overflow: hidden; +} + +/* Atmosphere: two fog bands drifting opposite ways, and a dust field. All + accent-neutral; the accent lives in the radial wash above. */ +.cs-fog { position: absolute; inset: 0; pointer-events: none; z-index: 0; } +.cs-fog i { + position: absolute; width: 90vw; height: 34vh; border-radius: 50%; + background: radial-gradient(ellipse at center, #18223a55 0%, transparent 65%); + filter: blur(30px); +} +.cs-fog i:nth-child(1) { left: -20vw; bottom: 4vh; animation: fogA 46s ease-in-out infinite alternate; } +.cs-fog i:nth-child(2) { right: -25vw; bottom: 12vh; animation: fogB 58s ease-in-out infinite alternate; } +@keyframes fogA { from { transform: translateX(0); } to { transform: translateX(9vw); } } +@keyframes fogB { from { transform: translateX(0); } to { transform: translateX(-11vw); } } +.cs-dust { + position: absolute; inset: -10% 0 0 0; pointer-events: none; z-index: 0; opacity: 0.5; + background-image: + radial-gradient(1.2px 1.2px at 12% 30%, #cfe0ff88 50%, transparent 51%), + radial-gradient(1px 1px at 34% 68%, #cfe0ff66 50%, transparent 51%), + radial-gradient(1.4px 1.4px at 58% 22%, #cfe0ff77 50%, transparent 51%), + radial-gradient(1px 1px at 71% 55%, #cfe0ff55 50%, transparent 51%), + radial-gradient(1.2px 1.2px at 86% 34%, #cfe0ff77 50%, transparent 51%), + radial-gradient(1px 1px at 45% 84%, #cfe0ff44 50%, transparent 51%); + background-size: 900px 700px; + animation: dust 90s linear infinite; +} +@keyframes dust { from { background-position: 0 0; } to { background-position: -900px 350px; } } + +/* Nameplate: display face over an ornamental divider. */ +#models .mb-plate { + grid-area: plate; position: relative; z-index: 1; justify-self: center; + text-align: center; margin-top: 4px; padding: 0 26px 14px; + border: 0; background: none; +} +#models .mb-name { + font-family: var(--display); font-size: clamp(1.9rem, 3.6vw, 2.9rem); + line-height: 1.05; text-shadow: 0 2px 24px #000c; +} +#models .mb-params { + font-family: var(--mono); font-size: var(--step--1); letter-spacing: 0.14em; + text-transform: uppercase; margin-top: 6px; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 75%, var(--text)); +} +#models .mb-plate::after { + content: ''; display: block; height: 16px; margin: 10px auto 0; width: min(360px, 80%); + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 80%, #fff); + -webkit-mask: url('/entrance/divider.svg') center / contain no-repeat; + mask: url('/entrance/divider.svg') center / contain no-repeat; +} + +/* The stage. */ +#models .mb-stage { grid-area: stage; display: flex; align-items: stretch; gap: 4px; min-height: 0; position: relative; z-index: 1; } +#models .mb-art { flex: 1; padding: 0; display: flex; align-items: center; justify-content: center; position: relative; } +#models .mb-mascot { height: min(54dvh, 500px); max-width: 100%; position: relative; z-index: 2; } +#models .mb-pedestal { + position: absolute; left: 50%; bottom: 2%; transform: translateX(-50%); + width: min(560px, 86%); height: 150px; border-radius: 0; filter: none; + background: radial-gradient(ellipse at center, + color-mix(in srgb, var(--cs-accent, var(--accent)) 30%, transparent) 0%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 8%, transparent) 52%, + transparent 70%); +} +#models .mb-pedestal::after { + content: ''; position: absolute; inset: 0; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + -webkit-mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + animation: ringPulse 5s ease-in-out infinite; +} +@keyframes ringPulse { 0%, 100% { opacity: 0.85; } 50% { opacity: 0.5; } } +#models .mb-arrow { + align-self: center; width: 52px; height: 76px; border-radius: 12px; + background: transparent; border: 1px solid transparent; font-size: 34px; + color: var(--dim); z-index: 2; +} +#models .mb-arrow:hover { color: var(--cs-accent, var(--accent)); border-color: var(--border); background: #0b101cbb; } + +/* The sheet: glass plate with rune corners. */ +#models .mb-info { grid-area: sheet; align-self: center; z-index: 1; } +#models .mb-panel { + position: relative; border: 1px solid color-mix(in srgb, var(--cs-accent, var(--accent)) 22%, var(--border)); + border-radius: 4px; padding: 20px 20px 16px; + background: linear-gradient(180deg, #0c1220ee, #090e18e6); + box-shadow: inset 0 1px 0 #ffffff0a, 0 18px 50px #0009; + backdrop-filter: blur(4px); +} +#models .mb-panel::before, #models .mb-panel::after { + content: ''; position: absolute; width: 40px; height: 40px; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + -webkit-mask: url('/entrance/corner.svg') 0 0 / contain no-repeat; + mask: url('/entrance/corner.svg') 0 0 / contain no-repeat; +} +#models .mb-panel::before { top: -5px; left: -5px; } +#models .mb-panel::after { bottom: -5px; right: -5px; transform: rotate(180deg); } +#models .mb-row-label { + font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.16em; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, var(--dim)); + text-transform: uppercase; margin: 14px 0 7px; +} +#models .mb-row-label:first-child { margin-top: 0; } +#models .mb-stats { display: block; margin: 12px 0 2px; } +#models .mb-stats > div { + display: flex; justify-content: space-between; align-items: baseline; + padding: 6px 2px; border-bottom: 1px solid #ffffff0d; +} +#models .mb-stats dt { + font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.12em; color: var(--dim); +} +#models .mb-stats dd { + margin: 0; font-family: var(--mono); font-size: var(--step-0); + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 45%, var(--text)); +} +#models .mb-variant { + border-radius: 3px; border-color: #ffffff1c; background: #ffffff07; +} +#models .mb-variant[aria-selected="true"] { + border-color: var(--cs-accent, var(--accent)); + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 18%, transparent); + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, #fff); +} +#models .mb-cached { color: var(--ok); font-size: var(--step--1); margin: 10px 0 0; } +#models .mb-ram { font-size: var(--step--1); margin: 8px 0 0; } + +/* Roster rail. */ +#models .cs-roster-head { + grid-area: roster; align-self: start; z-index: 1; + font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + padding: 2px 0 0 16px; height: 20px; +} +#models .mb-roster { + grid-area: roster; z-index: 1; margin: 26px 0 0; padding: 0 4px 4px 14px; + display: flex; flex-direction: column; gap: 8px; justify-content: flex-start; + overflow-y: auto; border-left: 1px solid #ffffff12; + scrollbar-width: thin; scrollbar-color: var(--border-hi) transparent; +} +#models .mb-dot { + width: 100%; height: auto; min-height: 64px; padding: 7px 10px; + display: flex; flex-direction: row; align-items: center; gap: 11px; + text-align: left; border-radius: 4px; cursor: pointer; + border: 1px solid #ffffff14; + background: linear-gradient(180deg, #ffffff08, transparent 60%), #0a0f1acc; + transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s; + position: relative; +} +#models .mb-dot:hover { border-color: var(--muted); transform: translateX(-2px); } +#models .mb-dot[aria-selected="true"] { + border-color: var(--cs-accent, var(--accent)); + box-shadow: inset 0 0 0 1px var(--cs-accent, var(--accent)), + 0 0 22px color-mix(in srgb, var(--cs-accent, var(--accent)) 30%, transparent); +} +#models .mb-dot[data-cached]::after { + content: 'READY'; position: absolute; top: 5px; right: 7px; + font-family: var(--mono); font-size: 0.5rem; letter-spacing: 0.12em; + color: var(--ok); border: 1px solid color-mix(in srgb, var(--ok) 55%, transparent); + border-radius: 3px; padding: 1px 4px; +} +.mb-dot-face { + flex: 0 0 46px; height: 46px; border-radius: 4px; + background: var(--thumb, radial-gradient(circle at 50% 40%, var(--border-hi) 0%, transparent 60%)) + center / cover no-repeat, #0d1322; + border: 1px solid #ffffff10; +} +.mb-dot-text { display: flex; flex-direction: column; min-width: 0; gap: 1px; } +.mb-dot-text b { font-size: 0.8rem; font-weight: 600; color: var(--text); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mb-dot-text i { font-style: normal; font-family: var(--mono); font-size: 0.6rem; + color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ENTER. */ +#models .cs-enter { grid-area: enter; display: flex; justify-content: center; padding: 4px 0 2px; z-index: 1; } +#models .cs-enter .mb-cta { + min-width: 340px; text-align: center; letter-spacing: 0.14em; text-transform: uppercase; + font-weight: 700; font-size: var(--step-0); padding: 0.95rem 1.4rem; + border-radius: 4px; position: relative; overflow: hidden; + color: #0b0d10; + background: linear-gradient(180deg, + color-mix(in srgb, var(--cs-accent, var(--accent)) 80%, #fff) 0%, + var(--cs-accent, var(--accent)) 55%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, #000) 100%); + border: 1px solid color-mix(in srgb, var(--cs-accent, var(--accent)) 60%, #fff); + box-shadow: 0 10px 34px color-mix(in srgb, var(--cs-accent, var(--accent)) 40%, transparent), + inset 0 1px 0 #ffffff66; +} +#models .cs-enter .mb-cta:hover { filter: brightness(1.1); } +#models .cs-enter .mb-cta::after { + content: ''; position: absolute; top: 0; bottom: 0; width: 40%; + left: -60%; transform: skewX(-20deg); + background: linear-gradient(90deg, transparent, #ffffff55, transparent); + transition: left 0.5s ease; +} +#models .cs-enter .mb-cta:hover::after { left: 130%; } +#models .cs-note { grid-area: note; text-align: center; z-index: 1; } + +/* The logo opening. */ +.cs-splash { + position: absolute; inset: 0; z-index: 30; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; + background: radial-gradient(ellipse at 50% 42%, #0d1424 0%, var(--bg) 70%); + animation: csSplashOut 2.6s ease forwards; +} +.cs-emblem { + width: 84px; height: 84px; color: var(--accent); + animation: csEmblem 1.4s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.cs-logo { + font-family: var(--display); font-size: clamp(3rem, 9vw, 6.5rem); color: var(--text); + animation: csLogoIn 1.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.cs-logo b { color: var(--accent); } +.cs-sub { + font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.22em; + text-transform: uppercase; color: var(--dim); + animation: csSub 1s ease 0.7s both; +} +@keyframes csEmblem { + 0% { opacity: 0; transform: rotate(-90deg) scale(0.6); } + 100% { opacity: 1; transform: rotate(0deg) scale(1); } +} +@keyframes csLogoIn { + 0% { opacity: 0; transform: scale(1.22); letter-spacing: 0.16em; filter: blur(12px); } + 55% { opacity: 1; filter: blur(0); } + 100% { opacity: 1; transform: scale(1); letter-spacing: 0.01em; } +} +@keyframes csSub { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes csSplashOut { 0%, 78% { opacity: 1; } 100% { opacity: 0; visibility: hidden; } } + +@media (prefers-reduced-motion: reduce) { + .cs-fog i, .cs-dust, #models .mb-pedestal::after { animation: none; } +} + +/* Phones: stage first, roster as a strip, sheet, enter. */ +@media (max-width: 900px) { + #models .cs-root { + grid-template-columns: 1fr; + grid-template-areas: 'plate' 'stage' 'roster' 'sheet' 'enter' 'note'; + padding: calc(var(--nav-h, 64px) + 8px) 14px 18px; + gap: 8px; + } + #models .mb-mascot { height: min(36dvh, 300px); } + #models .cs-roster-head { display: none; } + #models .mb-roster { + flex-direction: row; overflow-x: auto; overflow-y: hidden; margin-top: 0; + border-left: 0; border-top: 1px solid #ffffff12; padding: 10px 2px 4px; + } + #models .mb-dot { width: auto; min-width: 168px; flex: 0 0 auto; } + #models .cs-enter .mb-cta { min-width: 0; width: 100%; } +} + + +/* ══ ROUND TWO: depth, transitions, identity ═══════════════════════════════ */ + +/* The floor reflection β€” Chromium's box-reflect, which is the browser class + the engine requires anyway. Elsewhere it simply does not draw. */ +#models .mb-mascot { + -webkit-box-reflect: below -6px linear-gradient(transparent 62%, rgba(255,255,255,0.10)); +} + +/* Selection transition. go() re-arms .cs-in, so switching characters flashes + the pedestal and walks the plate and sheet back in β€” selection FEELS like + selection. Names are transform/opacity only; nothing relayouts. */ +#models .mb-art.cs-in .mb-pedestal::after { animation: ringFlash 0.7s ease-out, ringPulse 5s ease-in-out 0.7s infinite; } +@keyframes ringFlash { + 0% { opacity: 1; transform: scale(0.92); filter: brightness(2.2); } + 100% { opacity: 0.85; transform: scale(1); filter: brightness(1); } +} +#models .mb-art.cs-in .mb-mascot { animation: charIn 0.5s cubic-bezier(0.2, 1.4, 0.4, 1); } +@keyframes charIn { + 0% { opacity: 0; transform: translateY(14px) scale(0.965); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} +#models .mb-plate.cs-in .mb-name { animation: plateIn 0.45s ease both; } +#models .mb-plate.cs-in .mb-params { animation: plateIn 0.45s ease 0.08s both; } +@keyframes plateIn { + 0% { opacity: 0; transform: translateY(-8px); letter-spacing: 0.08em; } + 100% { opacity: 1; transform: translateY(0); } +} +#models .mb-panel.cs-in > * { animation: rowIn 0.4s ease both; } +#models .mb-panel.cs-in > *:nth-child(2) { animation-delay: 0.05s; } +#models .mb-panel.cs-in > *:nth-child(3) { animation-delay: 0.10s; } +#models .mb-panel.cs-in > *:nth-child(4) { animation-delay: 0.15s; } +#models .mb-panel.cs-in > *:nth-child(5) { animation-delay: 0.20s; } +#models .mb-panel.cs-in > *:nth-child(n+6) { animation-delay: 0.25s; } +@keyframes rowIn { + 0% { opacity: 0; transform: translateX(-10px); } + 100% { opacity: 1; transform: translateX(0); } +} + +/* Class sigil beside the class line. */ +#models .mb-params { display: inline-flex; align-items: center; gap: 8px; justify-content: center; } +.mb-sigil { display: inline-flex; width: 16px; height: 16px; } +.mb-sigil svg { width: 100%; height: 100%; } + +/* Realm corners: tiny mono statements, both literally true. */ +.cs-corner { + position: absolute; bottom: 10px; z-index: 1; + font-family: var(--mono); font-size: 0.58rem; letter-spacing: 0.14em; + text-transform: uppercase; color: color-mix(in srgb, var(--dim) 70%, transparent); + pointer-events: none; +} +.cs-corner-l { left: 16px; } +.cs-corner-r { right: 16px; } + +/* READY glint: a light pass over the tag every few seconds β€” unlocked should + glimmer. Masked to the tag only. */ +#models .mb-dot[data-cached]::after { overflow: hidden; } +#models .mb-dot[data-cached] { isolation: isolate; } +#models .mb-dot[data-cached]::before { + content: ''; position: absolute; top: 3px; right: 5px; width: 44px; height: 14px; + background: linear-gradient(110deg, transparent 30%, #ffffff2e 50%, transparent 70%) no-repeat; + background-size: 200% 100%; + animation: readyGlint 4.5s ease-in-out infinite; + pointer-events: none; z-index: 1; +} +@keyframes readyGlint { + 0%, 72% { background-position: 180% 0; } + 92%, 100% { background-position: -80% 0; } +} + +/* Enter: charge on hover, visible keyboard focus. */ +#models .cs-enter .mb-cta { transition: transform 0.18s ease, filter 0.18s ease, box-shadow 0.18s ease; } +#models .cs-enter .mb-cta:hover { + transform: translateY(-1px) scale(1.015); + box-shadow: 0 14px 44px color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, transparent), + inset 0 1px 0 #ffffff77; +} +#models .cs-enter .mb-cta:focus-visible { + outline: 2px solid #fff; outline-offset: 3px; +} + +/* Parallax layers move on transform β€” give them will-change and keep the + fog's own drift composed with the pointer offset via nesting (the + children animate, the container translates). */ +.cs-fog, .cs-dust, #models .mb-art { will-change: transform; transition: transform 0.3s ease-out; } + +@media (prefers-reduced-motion: reduce) { + #models .mb-art.cs-in .mb-mascot, + #models .mb-plate.cs-in .mb-name, #models .mb-plate.cs-in .mb-params, + #models .mb-panel.cs-in > *, #models .mb-dot[data-cached]::before { animation: none; } +} + + +/* ══ ROUNDS 3-6: the aura, the juice, the frame ════════════════════════════ */ + +/* The aura: motes of the class light rising off the pedestal, their pace set + by the model's measured rate (--cs-tempo β€” the same number the mascot's + idle clock reads). A fast little model boils; a 35B smoulders. */ +.cs-aura { position: absolute; left: 50%; bottom: 8%; width: min(480px, 70%); height: 60%; + transform: translateX(-50%); pointer-events: none; z-index: 1; } +.cs-aura i { + position: absolute; bottom: 0; width: 4px; height: 4px; border-radius: 50%; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + opacity: 0; + animation: mote calc(5.2s / var(--cs-tempo, 1)) ease-in infinite; +} +.cs-aura i:nth-child(1) { left: 6%; animation-delay: 0.0s; } +.cs-aura i:nth-child(2) { left: 16%; animation-delay: 1.1s; width: 3px; height: 3px; } +.cs-aura i:nth-child(3) { left: 27%; animation-delay: 2.3s; } +.cs-aura i:nth-child(4) { left: 38%; animation-delay: 0.6s; width: 5px; height: 5px; } +.cs-aura i:nth-child(5) { left: 50%; animation-delay: 1.7s; width: 3px; height: 3px; } +.cs-aura i:nth-child(6) { left: 61%; animation-delay: 2.9s; } +.cs-aura i:nth-child(7) { left: 70%; animation-delay: 0.3s; } +.cs-aura i:nth-child(8) { left: 80%; animation-delay: 1.9s; width: 3px; height: 3px; } +.cs-aura i:nth-child(9) { left: 89%; animation-delay: 2.6s; } +.cs-aura i:nth-child(10) { left: 95%; animation-delay: 1.3s; width: 5px; height: 5px; } +@keyframes mote { + 0% { opacity: 0; transform: translateY(0) scale(1); } + 12% { opacity: 0.9; } + 100% { opacity: 0; transform: translateY(-46vh) scale(0.4); } +} + +/* ARMED: a cached character's circle runs a rotating highlight β€” the same + OPFS truth as the READY tag, drawn as ceremony. Conic light clipped by the + very ring mask. */ +#models .mb-art[data-armed] .mb-pedestal::before { + content: ''; position: absolute; inset: 0; + background: conic-gradient(from 0deg, + transparent 0deg, transparent 290deg, + color-mix(in srgb, var(--cs-accent, var(--accent)) 90%, #fff) 330deg, + transparent 360deg); + -webkit-mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + animation: armedSpin 6s linear infinite; +} +@keyframes armedSpin { to { transform: rotate(360deg); } } + +/* Lore under the nameplate: computed from the spec, styled as inscription. */ +.cs-lore { + margin-top: 8px; font-size: 0.8rem; font-style: italic; + color: color-mix(in srgb, var(--text) 62%, transparent); + letter-spacing: 0.02em; +} + +/* Juice: chips stamp when chosen, roster cards press, ENTER presses. */ +#models .mb-variant[aria-selected="true"] { animation: stamp 0.22s ease; } +@keyframes stamp { 0% { transform: scale(1.08); } 100% { transform: scale(1); } } +#models .mb-dot:active { transform: translateX(-2px) scale(0.98); } +#models .cs-enter .mb-cta:active { transform: translateY(1px) scale(0.995); box-shadow: 0 6px 20px color-mix(in srgb, var(--cs-accent, var(--accent)) 35%, transparent); } + +/* The cinematic frame: a breath of dark at the top and bottom edges, so the + scene reads composed rather than cropped. */ +#models .cs-root::before, #models .cs-root::after { + content: ''; position: absolute; left: 0; right: 0; height: 90px; + pointer-events: none; z-index: 2; +} +#models .cs-root::before { top: 0; background: linear-gradient(180deg, #05070dcc, transparent); } +#models .cs-root::after { bottom: 0; background: linear-gradient(0deg, #05070d99, transparent); } + +/* Screen-reader announcer. */ +.cs-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } + +@media (prefers-reduced-motion: reduce) { + .cs-aura i, #models .mb-art[data-armed] .mb-pedestal::before, + #models .mb-variant[aria-selected="true"] { animation: none; } + .cs-aura i { opacity: 0.35; transform: translateY(-8vh); } +} + + +/* ══ ROUND 7: THE ENVIRONMENT ══════════════════════════════════════════════ + The realm gains architecture. Silhouette layers between backdrop and + stage, each on its own parallax rate: obelisk skyline far, rune columns + near, the character floating between them under an aerial ring. */ +.cs-spires { + position: absolute; left: -3%; right: -3%; bottom: 0; height: 42vh; + background: url('/entrance/spires.svg') bottom center / cover no-repeat; + opacity: 0.85; pointer-events: none; z-index: 0; +} +.cs-col { + position: absolute; bottom: -2vh; width: min(240px, 16vw); height: 92vh; + background: url('/entrance/column.svg') bottom / contain no-repeat; + pointer-events: none; z-index: 1; opacity: 0.9; +} +.cs-col-l { left: max(6px, 2vw); } +.cs-col-r { right: max(6px, 2vw); transform: scaleX(-1); } + +/* The aerial ring: a great slow wheel behind the character, in the class + light at whisper opacity. The armed floor ring is ceremony; this is + architecture. */ +.cs-backring { + position: absolute; left: 50%; top: 46%; width: min(64dvh, 620px); aspect-ratio: 1; + translate: -50% -50%; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, #fff); + -webkit-mask: url('/entrance/backring.svg') center / contain no-repeat; + mask: url('/entrance/backring.svg') center / contain no-repeat; + opacity: 0.14; z-index: 0; + animation: backringSpin 80s linear infinite; +} +@keyframes backringSpin { to { rotate: 360deg; } } + +/* Nameplate banner wings behind the name. */ +#models .mb-plate { padding-top: 10px; } +.cs-banner { + position: absolute; left: 50%; top: 16px; transform: translateX(-50%); + width: min(640px, 92vw); height: 80px; pointer-events: none; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 65%, #fff); + -webkit-mask: url('/entrance/banner.svg') center / contain no-repeat; + mask: url('/entrance/banner.svg') center / contain no-repeat; + opacity: 0.5; z-index: -1; +} + +/* Portrait frames on the roster faces. */ +.mb-dot-face { position: relative; } +.mb-dot-face::after { + content: ''; position: absolute; inset: -3px; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, #9fb4d8); + -webkit-mask: url('/entrance/pframe.svg') center / contain no-repeat; + mask: url('/entrance/pframe.svg') center / contain no-repeat; + opacity: 0.35; transition: opacity 0.15s; +} +#models .mb-dot[aria-selected="true"] .mb-dot-face::after, +#models .mb-dot:hover .mb-dot-face::after { opacity: 0.9; } + +/* The cinematic edges upgraded: the dark breath now carries the rune border + strip along its inner line. */ +#models .cs-root::before { + background: + linear-gradient(180deg, #05070dcc, transparent), + linear-gradient(180deg, transparent 76px, transparent 76px); +} +#models .cs-root > .cs-edge-t, #models .cs-root > .cs-edge-b { display: none; } +#models .cs-root::after { + background: linear-gradient(0deg, #05070d99, transparent); +} +.cs-borderline-t, .cs-borderline-b { + position: absolute; left: 8%; right: 8%; height: 12px; z-index: 2; pointer-events: none; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 40%, #6c7f9f); + -webkit-mask: url('/entrance/border.svg') center / auto 100% repeat-x; + mask: url('/entrance/border.svg') center / auto 100% repeat-x; + opacity: 0.4; +} +.cs-borderline-t { top: calc(var(--nav-h, 64px) + 2px); } +.cs-borderline-b { bottom: 8px; } + +/* The splash's ring burst: the aerial ring blooming open behind the emblem. */ +.cs-splash { position: absolute; } +.cs-splash-ring { + position: absolute; width: 420px; aspect-ratio: 1; + background: var(--accent); + -webkit-mask: url('/entrance/backring.svg') center / contain no-repeat; + mask: url('/entrance/backring.svg') center / contain no-repeat; + animation: splashRing 2.2s cubic-bezier(0.16, 1, 0.3, 1) both; + opacity: 0.22; +} +@keyframes splashRing { + 0% { transform: scale(0.4) rotate(-40deg); opacity: 0; } + 40% { opacity: 0.3; } + 100% { transform: scale(1.05) rotate(8deg); opacity: 0.18; } +} + +/* The realm's cursor: a small trace-diamond. Entrance only. */ +#models .cs-root, #models .cs-root a, #models .cs-root button { + cursor: url('data:image/svg+xml;utf8,') 11 11, auto; +} + +@media (prefers-reduced-motion: reduce) { + .cs-backring, .cs-splash-ring { animation: none; } +} +@media (max-width: 900px) { + .cs-col { display: none; } + .cs-spires { height: 26vh; opacity: 0.6; } + .cs-backring { width: min(78vw, 380px); top: 40%; } + .cs-banner { height: 56px; opacity: 0.4; } + .cs-borderline-t, .cs-borderline-b { display: none; } +} + + +/* ══ ROUND 8: THE ALIGNMENT PASS ═══════════════════════════════════════════ + One rhythm: an 8px gutter system, a hard max-width so ultrawide screens + frame the scene instead of stretching it, the sheet and roster hung from + the same top line, and the enter slab locked to the stage's centre line. */ +#models .cs-root { + --cs-gutter: 24px; + max-width: 1720px; margin: 0 auto; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr) minmax(260px, 310px); + gap: 8px var(--cs-gutter); + padding: calc(var(--nav-h, 64px) + 12px) max(28px, 4vw) 22px; +} +/* Sheet and roster share a top line: both start where the stage starts. */ +#models .mb-info { align-self: start; padding-top: 34px; } +#models .cs-roster-head { padding-top: 8px; height: 26px; } +#models .mb-roster { margin-top: 34px; } +/* The plate is the one centred element; give it a fixed rhythm below. */ +#models .mb-plate { margin: 2px 0 0; padding-bottom: 6px; } +/* Enter hangs centred under the stage column only, not the whole row β€” + optically aligned with the character above it. */ +#models .cs-enter { grid-column: 2; justify-self: center; } +#models .cs-corner-l { left: max(28px, 4vw); } +#models .cs-corner-r { right: max(28px, 4vw); } +/* Border strips share the content inset. */ +.cs-borderline-t, .cs-borderline-b { left: max(28px, 4vw); right: max(28px, 4vw); } +/* Stat rows: value column right-aligned on a shared edge, tabular digits. */ +#models .mb-stats dd { font-variant-numeric: tabular-nums; text-align: right; } +#models .mb-stats dt { padding-right: 12px; } +/* Chips wrap on the same 8px rhythm. */ +#models .mb-variants, #models .mb-modes { gap: 8px; } +@media (max-width: 900px) { + #models .cs-enter { grid-column: 1; justify-self: stretch; } + #models .mb-info { padding-top: 0; } + #models .mb-roster { margin-top: 0; } +} + +/* ══ ROUND 15: THE PARTY READS AS CLASSES ══ */ +#models .mb-dot { padding-right: 26px; } +.mb-dot-sigil { + position: absolute; right: 7px; bottom: 6px; width: 13px; height: 13px; opacity: 0.75; +} +.mb-dot-sigil svg { width: 100%; height: 100%; } + +/* ══ ROUND 17: THE SPLASH YIELDS ══ */ +.cs-splash { cursor: pointer; } + + +/* ══ ROUND 9: ATTENTION ══ */ +#models .mb-art { transition: rotate 0.4s ease, transform 0.3s ease-out; perspective: 900px; } + +/* ══ ROUND 11: THE SELECTION WIPE ══ A sheet of the class light crossing the + stage on every selection β€” the cut between characters reads as a cut. */ +.cs-wipe { + position: absolute; inset: 0; z-index: 3; pointer-events: none; opacity: 0; + background: linear-gradient(100deg, transparent 30%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 26%, transparent) 50%, + transparent 70%); + transform: translateX(-100%); +} +.cs-wipe.cs-go { animation: csWipe 0.55s ease-out; } +@keyframes csWipe { + 0% { opacity: 1; transform: translateX(-100%); } + 100% { opacity: 1; transform: translateX(100%); } +} + +/* ══ ROUND 16: THE ENGAGE ══ Clicking ENTER flashes the realm in the class + light and falls to black before the chat page takes over β€” an exit that + answers the entrance. Reduced motion (and modified clicks) skip it. */ +.cs-engage { + position: absolute; inset: 0; z-index: 40; pointer-events: none; opacity: 0; + background: + radial-gradient(ellipse at 50% 60%, + color-mix(in srgb, var(--cs-accent, var(--accent)) 45%, transparent) 0%, transparent 60%), + #05070d; +} +.cs-engage.cs-go { animation: csEngage 0.34s ease-in forwards; } +@keyframes csEngage { + 0% { opacity: 0; } + 45% { opacity: 0.65; } + 100% { opacity: 1; } +} +@media (prefers-reduced-motion: reduce) { .cs-wipe.cs-go, .cs-engage.cs-go { animation: none; } } + + +/* ══ ROUND 10: STAT ICONOGRAPHY + HONEST BARS ══ Icons name the stat; the + bars are within-character fractions only (context: shipped ceiling over + the checkpoint's own max; memory: the build's residency) β€” never a + cross-model ranking. */ +.mb-stat-ico { display: inline-flex; width: 12px; height: 12px; margin-right: 7px; + vertical-align: -1px; color: color-mix(in srgb, var(--cs-accent, var(--accent)) 60%, var(--dim)); } +.mb-stat-ico svg { width: 100%; height: 100%; } +.mb-bar { display: block; height: 3px; margin-top: 5px; border-radius: 2px; + background: #ffffff12; overflow: hidden; } +.mb-bar i { display: block; height: 100%; border-radius: 2px; + background: linear-gradient(90deg, + color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, transparent), + var(--cs-accent, var(--accent))); + transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1); } +#models .mb-stats dd { min-width: 128px; } + +/* ══ ROUND 13: THE LOADOUT GAUGE ══ Filled diamonds for what the build + keeps, hollow for what it streams β€” ordinal, not a measurement. */ +.mb-gauge { font-size: 0.6rem; letter-spacing: 0.08em; margin-right: 7px; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, var(--dim)); } + + +/* ══ ROUND 12: IDLE LIFE ══ Left alone, the realm breathes: the stage sways + a few pixels on a long period and the aura thickens slightly. Any input + snaps the camera back to the hand. */ +#models .cs-root.cs-idle .mb-art { animation: idleSway 14s ease-in-out infinite; } +@keyframes idleSway { + 0%, 100% { transform: translate(0, 0); } + 25% { transform: translate(6px, -3px); } + 75% { transform: translate(-6px, 2px); } +} +#models .cs-root.cs-idle .cs-aura i { animation-duration: calc(3.8s / var(--cs-tempo, 1)); } + +/* ══ ROUND 14: REALM WEATHER ══ A falling trace crosses the high sky every + ~16 s β€” thin, fast, gone. And the fog drinks a hint of the class light, so + the weather itself changes when the character does. */ +.cs-comet { + position: absolute; top: 12%; left: -8%; width: 130px; height: 2px; + background: linear-gradient(90deg, transparent, #cfe0ffbb 60%, #fff); + border-radius: 2px; opacity: 0; pointer-events: none; z-index: 0; + transform: rotate(9deg); + animation: comet 16s linear infinite; + animation-delay: 5s; +} +@keyframes comet { + 0%, 93% { opacity: 0; left: -8%; top: 12%; } + 94% { opacity: 0.9; } + 100% { opacity: 0; left: 108%; top: 26%; } +} +.cs-fog i { + background: radial-gradient(ellipse at center, + color-mix(in srgb, var(--cs-accent, var(--accent)) 12%, #18223a) 0%, transparent 65%); + opacity: 0.36; +} +@media (prefers-reduced-motion: reduce) { + .cs-comet, #models .cs-root.cs-idle .mb-art { animation: none; } +} + + +/* ══ CHAT MODE ══ ENTER pressed: the roster and the sheet step aside, the + conversation takes the right column, and the character stays on stage β€” + summoning heartbeat while the weights land, faster heartbeat while it + thinks, one bounce per real token while it speaks. Requires chat-ui.css + (linked by index.html) for the message bubbles and the composer; below is + only the panel chrome and the stagecraft. */ + +#models .cs-root.cs-chatting { + /* The nav is sticky IN FLOW (60px + border), so a flat 100dvh hangs the + composer 61px below the fold. In chat mode the composer is the primary + control β€” the chamber must end above it. The tightened padding-top gives + the reclaimed room to the conversation. */ + height: calc(100dvh - 61px); + min-height: 0; + padding-top: 22px; + grid-template-columns: 1fr minmax(420px, 600px); + grid-template-rows: auto 1fr auto; + grid-template-areas: + 'plate chat' + 'stage chat' + 'note chat'; +} +/* #models-prefixed: the base rules carry the #models id, and a bare class + pair loses that specificity fight β€” the roster stayed visible and its + orphaned grid-area spawned implicit tracks that crushed the real columns + (caught by the first browser pass). */ +#models .cs-chatting .mb-info, #models .cs-chatting .mb-roster, +#models .cs-chatting .cs-roster-head, #models .cs-chatting .cs-enter, +#models .cs-chatting .mb-arrow, #models .cs-chatting .cs-corner-r { display: none; } + +/* The engage flash no longer hands off to another page β€” it lifts to reveal + the chamber. Starts opaque (the .cs-go fade ran under it), holds long + enough to cover the swap, then clears. */ +#models .cs-root.cs-chatting .cs-engage.cs-go { animation: csEngageReveal 0.9s ease forwards; } +@keyframes csEngageReveal { + 0%, 30% { opacity: 1; } + 100% { opacity: 0; visibility: hidden; } +} + +/* The panel: the sheet's glass and rune corners, grown into an instrument. */ +#models .cs-chat { + /* min-width 0: grid items default to min-content width, and the nowrap + composer-hint line was wider than a phone β€” the panel overflowed the + viewport instead of shrinking into its column. */ + grid-area: chat; z-index: 2; min-height: 0; min-width: 0; position: relative; + /* It is a
, and the page's generic section rule pads 72px into + the panel β€” zero it; the head/composer carry their own spacing. */ + padding: 0; + display: flex; flex-direction: column; + border: 1px solid color-mix(in srgb, var(--cs-accent, var(--accent)) 22%, var(--border)); + border-radius: 4px; + background: linear-gradient(180deg, #0c1220ee, #090e18e6); + box-shadow: inset 0 1px 0 #ffffff0a, 0 18px 50px #0009; + backdrop-filter: blur(4px); + animation: csChatIn 0.5s cubic-bezier(0.2, 1, 0.3, 1) 0.1s both; +} +@keyframes csChatIn { + from { opacity: 0; transform: translateX(26px); } + to { opacity: 1; transform: translateX(0); } +} +#models .cs-chat::before, #models .cs-chat::after { + content: ''; position: absolute; width: 40px; height: 40px; z-index: 3; pointer-events: none; + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + -webkit-mask: url('/entrance/corner.svg') 0 0 / contain no-repeat; + mask: url('/entrance/corner.svg') 0 0 / contain no-repeat; +} +#models .cs-chat::before { top: -5px; left: -5px; } +#models .cs-chat::after { bottom: -5px; right: -5px; transform: rotate(180deg); } + +/* Panel header: sigil, name, build, live badge, tools. */ +.cs-chat-head { + display: flex; align-items: center; gap: 10px; + padding: 12px 14px; border-bottom: 1px solid #ffffff0f; +} +.cs-chat-sigil { flex: none; width: 22px; height: 22px; + color: var(--cs-accent, var(--accent)); } +.cs-chat-sigil svg { width: 100%; height: 100%; } +.cs-chat-id { display: flex; flex-direction: column; gap: 1px; min-width: 0; margin-right: auto; } +.cs-chat-id b { font-family: var(--display); font-weight: 600; font-size: 1.02rem; + line-height: 1.15; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cs-chat-id i { font-style: normal; font-family: var(--mono); font-size: 0.58rem; + letter-spacing: 0.12em; text-transform: uppercase; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, var(--text)); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cs-chat-tool { + flex: none; font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--dim); text-decoration: none; + padding: 5px 9px; border: 1px solid #ffffff1c; border-radius: 3px; background: #ffffff07; + cursor: pointer; transition: color 0.15s, border-color 0.15s; +} +.cs-chat-tool:hover { color: var(--text); border-color: var(--muted); } +.cs-chat-tool:disabled { opacity: 0.4; cursor: not-allowed; } + +/* Live badge β€” written by loading-ui.ts (Summoning β†’ % β†’ Ready). */ +.cs-chat .badge { + flex: none; display: inline-flex; align-items: center; gap: 6px; + font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; + padding: 4px 9px; border: 1px solid #ffffff1c; border-radius: 999px; color: var(--dim); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.cs-chat .badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } +.cs-chat .badge.loading { color: #fbbf24; border-color: #fbbf2440; } +.cs-chat .badge.loading .dot { background: #fbbf24; animation: csBadgePulse 1.4s infinite; } +.cs-chat .badge.ready { color: var(--cs-accent, var(--accent)); + border-color: color-mix(in srgb, var(--cs-accent, var(--accent)) 40%, transparent); } +.cs-chat .badge.ready .dot { background: var(--cs-accent, var(--accent)); } +.cs-chat .badge.error { color: var(--err); border-color: color-mix(in srgb, var(--err) 45%, transparent); } +.cs-chat .badge.error .dot { background: var(--err); } +@keyframes csBadgePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } + +/* The rite card: download + compile progress, then it folds away. */ +.cs-boot { padding: 16px 16px 14px; border-bottom: 1px solid #ffffff0f; } +.cs-boot.cs-done { display: none; } +.cs-boot-title { font-family: var(--display); font-size: 1.05rem; margin-bottom: 2px; } +.cs-boot-status { font-size: 0.78rem; color: var(--text-dim, #a9b3c6); min-height: 1.2em; margin-bottom: 10px; } +.cs-boot-track { height: 4px; border-radius: 2px; background: #ffffff12; overflow: hidden; } +.cs-boot-track i { + display: block; height: 100%; width: 0; border-radius: 2px; + background: linear-gradient(90deg, + color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, transparent), + var(--cs-accent, var(--accent))); + transition: width 0.25s ease; +} +.cs-boot-detail { margin-top: 7px; font-family: var(--mono); font-size: 0.64rem; + color: var(--dim); font-variant-numeric: tabular-nums; } +.cs-boot-note { margin-top: 7px; font-size: 0.72rem; color: var(--warn, #d9a441); } +.cs-boot-log { margin-top: 9px; } +.cs-boot-log summary { + font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.14em; text-transform: uppercase; + color: var(--dim); cursor: pointer; user-select: none; +} +.cs-boot-log summary:hover { color: var(--text-dim, #a9b3c6); } +.cs-boot-log pre { + margin-top: 6px; max-height: 130px; overflow-y: auto; + padding: 8px 10px; border: 1px solid #ffffff0f; border-radius: 3px; background: #070b13; + font-family: var(--mono); font-size: 0.62rem; line-height: 1.5; color: var(--text-dim, #a9b3c6); + white-space: pre-wrap; +} +.cs-boot-error { display: none; margin-top: 10px; padding: 9px 11px; + border: 1px solid color-mix(in srgb, var(--err) 40%, transparent); border-radius: 3px; + background: color-mix(in srgb, var(--err) 9%, transparent); + font-size: 0.76rem; line-height: 1.5; color: color-mix(in srgb, var(--err) 60%, #fff); } +.cs-boot-error.visible { display: block; } + +/* Conversation column: bubbles ride the panel glass, not their own ground. */ +.cs-chat .chat-main { flex: 1; min-height: 0; } +.cs-chat .welcome.cs-wait, .cs-chat .welcome.hidden { display: none; } +.cs-chat .welcome { padding: 1.4rem 0.2rem 1rem; } +.cs-welcome-title { font-family: var(--display); font-size: 1.3rem; margin-bottom: 6px; } +.cs-welcome-lore { font-size: 0.8rem; font-style: italic; line-height: 1.55; + color: color-mix(in srgb, var(--text) 62%, transparent); margin-bottom: 14px; } +.cs-sug-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.cs-chat .suggest { + text-align: left; font-size: 0.76rem; line-height: 1.4; color: var(--text-dim, #a9b3c6); + padding: 10px 12px; border: 1px solid #ffffff14; border-radius: 3px; background: #ffffff06; + cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.cs-chat .suggest:hover { + color: var(--text); background: color-mix(in srgb, var(--cs-accent, var(--accent)) 8%, transparent); + border-color: color-mix(in srgb, var(--cs-accent, var(--accent)) 45%, transparent); +} + +/* Composer, re-cut to the entrance's edge language (chat-ui.css carries the + mechanics; only the chrome changes here). */ +.cs-chat .composer-wrap { background: transparent; border-top: 1px solid #ffffff0f; padding: 11px 13px 10px; } +.cs-chat .composer { background: #0a101ccc; border-radius: 3px; + border-color: color-mix(in srgb, var(--cs-accent, var(--accent)) 26%, var(--border)); } +.cs-chat .composer:focus-within { background: #0c1322; border-color: var(--cs-accent, var(--accent)); } +.cs-chat .composer-btn { border-radius: 3px; } +.cs-chat .composer-hint { + display: flex; justify-content: space-between; gap: 4px 10px; flex-wrap: wrap; text-align: left; + font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.06em; color: var(--dim); + max-width: none; +} +.cs-chat-stats { font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* ── Stagecraft: what the realm does while the model works ──────────────── */ + +/* Summoning: the circle beats slowly and the ring runs the armed sweep β€” the + character is being drawn through. */ +#models .cs-summoning .mb-pedestal::after { animation: ringPulse 1.5s ease-in-out infinite; } +#models .cs-summoning .mb-pedestal::before { + content: ''; position: absolute; inset: 0; + background: conic-gradient(from 0deg, + transparent 0deg, transparent 290deg, + color-mix(in srgb, var(--cs-accent, var(--accent)) 90%, #fff) 330deg, + transparent 360deg); + -webkit-mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + mask: url('/entrance/ring.svg') center bottom / 100% auto no-repeat; + animation: armedSpin 2.6s linear infinite; +} +/* Thinking: prefill β€” the ring tightens to a fast shallow breath. */ +#models .cs-thinking .mb-pedestal::after { animation: ringPulse 0.9s ease-in-out infinite; } +#models .cs-thinking .mb-pedestal { filter: brightness(1.18); } +/* Generating: the floor burns steady and bright; the bounce on stage is the + token stream itself (mascot.pulse per emitted token). */ +#models .cs-generating .mb-pedestal { filter: brightness(1.3); } +#models .cs-generating .mb-pedestal::after { animation: none; opacity: 1; } +#models .mb-pedestal { transition: filter 0.4s ease; } + +/* Phones: the character folds to a small familiar above the conversation. */ +@media (max-width: 900px) { + #models .cs-root.cs-chatting { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: 'plate' 'stage' 'chat'; + } + #models .cs-chatting .mb-mascot { height: 110px; -webkit-box-reflect: none; } + #models .cs-chatting .mb-pedestal { height: 60px; } + #models .cs-chatting .cs-lore { display: none; } + #models .cs-chatting .mb-plate { padding-bottom: 4px; } + #models .cs-chatting .mb-name { font-size: 1.3rem; } + .cs-sug-grid { grid-template-columns: 1fr; } + .cs-chat-tool#new-chat-btn { display: none; } +} +@media (prefers-reduced-motion: reduce) { + #models .cs-chat, #models .cs-root.cs-chatting .cs-engage.cs-go { animation: none; } + #models .cs-summoning .mb-pedestal::before { animation: none; } +} + +/* ══ DOWNLOADED = READY ══ The READY tag gains a fill so it reads at roster + distance, and a variant chip that is already on this device carries a dot β€” + both from the same OPFS probe as everything else. */ +#models .mb-dot[data-cached]::after { + background: color-mix(in srgb, var(--ok) 16%, transparent); +} +#models .mb-variant[data-cached]::after { + content: '●'; margin-left: 7px; font-size: 0.5rem; vertical-align: 2px; + color: var(--ok); +} + +/* ══ GAME ROUND: abilities, saves, listening, the token stream ═════════════ */ + +/* Abilities: the spec's mechanics as passives β€” rune bullet, name, clause. */ +#models .mb-abilities { list-style: none; margin: 12px 0 0; padding: 0; + display: flex; flex-direction: column; gap: 6px; } +#models .mb-abilities li { display: flex; align-items: baseline; gap: 8px; + font-size: 0.74rem; line-height: 1.4; } +#models .mb-abilities li::before { content: 'β—†'; flex: none; font-size: 0.5rem; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 80%, #fff); } +#models .mb-abilities b { flex: none; font-weight: 600; letter-spacing: 0.02em; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 55%, var(--text)); } +#models .mb-abilities span { color: var(--dim); } + +/* Save slot: a stored conversation marks its character β€” roster glyph + + sheet line. Presence only, never a count. */ +#models .mb-dot[data-save] .mb-dot-text b::after { + content: ' β—ˆ'; font-size: 0.7em; vertical-align: 1px; + color: #cfe0ffaa; +} +#models .mb-save { font-size: var(--step--1); margin: 10px 0 0; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 60%, var(--text)); } + +/* LISTENING: keys are landing β€” the character turns toward the panel. The + ears perk through the mascot's hover state; this is the body language. */ +#models .cs-chatting.cs-listening .mb-art { rotate: y 9deg; } +#models .mb-art { transition: transform 0.3s ease-out, rotate 0.45s ease; } + +/* THE TOKEN STREAM: while the model speaks, glyphs fly from the character's + mouth into the conversation. Visible ONLY under cs-generating β€” the stream + exists exactly when tokens do. */ +.cs-stream { display: none; } +#models .cs-chatting.cs-generating .cs-stream { display: block; + position: absolute; left: 26%; right: 0; top: 0; bottom: 0; + pointer-events: none; z-index: 2; } +.cs-stream i { + position: absolute; left: 0; top: 44%; + font-style: normal; font-size: 11px; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + text-shadow: 0 0 6px color-mix(in srgb, var(--cs-accent, var(--accent)) 60%, transparent); + opacity: 0; + animation: tokFly 1.15s linear infinite; +} +.cs-stream i:nth-child(2) { animation-delay: 0.19s; font-size: 8px; top: 45.5%; } +.cs-stream i:nth-child(3) { animation-delay: 0.38s; top: 43%; } +.cs-stream i:nth-child(4) { animation-delay: 0.57s; font-size: 8px; top: 46%; } +.cs-stream i:nth-child(5) { animation-delay: 0.76s; top: 44.5%; } +.cs-stream i:nth-child(6) { animation-delay: 0.95s; font-size: 9px; top: 43.5%; } +@keyframes tokFly { + 0% { transform: translateX(0) translateY(0); opacity: 0; } + 12% { opacity: 0.9; } + 55% { transform: translateX(22vw) translateY(-1.2vh); opacity: 0.85; } + 92% { opacity: 0.5; } + 100% { transform: translateX(38vw) translateY(-0.6vh); opacity: 0; } +} + +/* SUMMON FAILURE: the circle goes cold. The rite card carries the flavour + line; the diagnostic stays in the error panel. */ +#models .cs-boot-failed .mb-pedestal { filter: grayscale(0.7) brightness(0.55); } +#models .cs-boot-failed .mb-pedestal::after { animation: none; opacity: 0.3; } +#models .cs-boot-failed .mb-pedestal::before { display: none; } + +@media (max-width: 900px) { + /* The stream's geometry assumes stage-left/panel-right; stacked phones + have neither, and the abilities pad the sheet. */ + .cs-stream { display: none !important; } +} +@media (prefers-reduced-motion: reduce) { + .cs-stream { display: none !important; } + #models .cs-chatting.cs-listening .mb-art { rotate: none; } +} + +/* ══ EXTENDED ROUND: sleep, the constellation, the deeds rail ══════════════ */ + +/* ASLEEP (50 s idle): the ring cools and slows with the character β€” the + sleepy face itself is the mascot's mood, this is the room's light. */ +#models .cs-asleep .mb-pedestal { filter: brightness(0.7) saturate(0.8); } +#models .cs-asleep .mb-pedestal::after { animation-duration: 10s; } +#models .cs-asleep .cs-fog i { opacity: 0.2; } + +/* THE CONSTELLATION: the lane's sigil, huge and faint, behind the stage. + Masked accent β€” it recolours with the character and drifts on parallax. */ +.cs-sigilbg { + position: absolute; z-index: 0; pointer-events: none; + left: 50%; top: 50%; + width: min(74vh, 58vw); height: min(74vh, 58vw); + margin: calc(min(74vh, 58vw) / -2) 0 0 calc(min(74vh, 58vw) / -2); + background: color-mix(in srgb, var(--cs-accent, var(--accent)) 80%, #fff); + opacity: 0.05; + -webkit-mask-size: contain; mask-size: contain; + -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; + -webkit-mask-position: center; mask-position: center; +} +#models .cs-chatting .cs-sigilbg { left: 27%; } + +/* THE DEEDS RAIL: things that happened on this device. Unearned = a dim rune + with the trigger in its tooltip (a quest list, not decoration); earned = + lit, named. No counts anywhere. */ +#models .cs-deeds { + grid-area: note; z-index: 1; justify-self: center; + display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; + padding: 2px 0; +} +.cs-deed { + font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.12em; + text-transform: uppercase; color: color-mix(in srgb, var(--dim) 55%, transparent); + cursor: default; +} +.cs-deed i { font-style: normal; display: none; margin-left: 5px; } +.cs-deed[data-on] { + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 75%, #fff); + text-shadow: 0 0 8px color-mix(in srgb, var(--cs-accent, var(--accent)) 45%, transparent); +} +.cs-deed[data-on] i { display: inline; } +@media (max-width: 900px) { #models .cs-deeds { display: none; } } + +/* ══ THE ROOM ══ Hosting from inside the game: the strip in the panel, and + one small mascot per connected guest flanking the character. */ +.cs-room { padding: 12px 14px; border-bottom: 1px solid #ffffff0f; } +.cs-room-consent p { font-size: 0.76rem; line-height: 1.55; color: var(--text-dim, #a9b3c6); + margin: 0 0 10px; } +.cs-room-linkrow { display: flex; gap: 6px; align-items: center; } +.cs-room-linkrow input { + flex: 1; min-width: 0; padding: 6px 9px; font-family: var(--mono); font-size: 0.66rem; + color: var(--text); background: #0a101ccc; border-radius: 3px; + border: 1px solid color-mix(in srgb, var(--cs-accent, var(--accent)) 30%, var(--border)); +} +.cs-room-members { margin-top: 8px; font-family: var(--mono); font-size: 0.62rem; + letter-spacing: 0.08em; color: var(--dim); } +.cs-room-log { list-style: none; margin: 8px 0 0; padding: 0; display: flex; + flex-direction: column; gap: 4px; } +.cs-room-log li { display: flex; gap: 8px; align-items: baseline; font-size: 0.7rem; + color: var(--text-dim, #a9b3c6); overflow: hidden; white-space: nowrap; } +.cs-room-log b { font-family: var(--mono); font-size: 0.6rem; font-weight: 400; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 70%, var(--text)); flex: none; } +.cs-room-log span { overflow: hidden; text-overflow: ellipsis; } +.cs-room-log i { font-style: normal; font-family: var(--mono); font-size: 0.6rem; + color: var(--dim); margin-left: auto; flex: none; } +.cs-tool-live { border-color: var(--cs-accent, var(--accent)) !important; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 80%, #fff) !important; } + +/* Guests on stage: small familiars on the ring's edge. */ +.cs-guest-mascot { position: absolute; width: 90px; height: 90px; z-index: 1; + pointer-events: none; animation: guestIn 0.5s ease both; } +.cs-guest-0 { left: 8%; bottom: 10%; } +.cs-guest-1 { right: 8%; bottom: 10%; } +.cs-guest-2 { left: 2%; bottom: 34%; } +.cs-guest-3 { right: 2%; bottom: 34%; } +.cs-guest-4 { left: 16%; bottom: 0; } +.cs-guest-5 { right: 16%; bottom: 0; } +@keyframes guestIn { from { opacity: 0; transform: translateY(10px) scale(0.8); } + to { opacity: 1; transform: none; } } +@media (max-width: 900px) { .cs-guest-mascot { display: none; } } + +/* The room path under ENTER: quiet sibling, same class light. */ +#models .cs-enter { flex-direction: column; align-items: center; gap: 8px; } +#models .mb-cta-room { + font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.1em; + text-transform: uppercase; text-decoration: none; cursor: pointer; + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 65%, var(--dim)); + padding: 4px 10px; border: 1px solid transparent; border-radius: 3px; + transition: color 0.15s, border-color 0.15s; +} +#models .mb-cta-room:hover { + color: color-mix(in srgb, var(--cs-accent, var(--accent)) 85%, #fff); + border-color: color-mix(in srgb, var(--cs-accent, var(--accent)) 40%, transparent); +} + +/* ══ LENS ROUND 2026-08-17 FIXES ═══════════════════════════════════════════ */ + +/* The cinematic bottom fade must not wash the chat panel's composer β€” the + panel IS the bottom of the screen in chat mode. */ +#models .cs-root.cs-chatting::after { display: none; } + +@media (prefers-reduced-motion: reduce) { + /* The mood states re-armed the ring pulse that the base reduce block had + disabled; and guests should simply appear. */ + #models .cs-summoning .mb-pedestal::after, + #models .cs-thinking .mb-pedestal::after { animation: none; } + .cs-guest-mascot { animation: none; } +} diff --git a/og.png b/og.png new file mode 100644 index 0000000000000000000000000000000000000000..347ed275ca719e0dd2fa17d2984bceef4e71884e --- /dev/null +++ b/og.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6184b54547a35ce2a9547b86758bf65559983da776d51700bf2ae9b3fdea6366 +size 110832 diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..9a0bfd3fadb66b54bd4d577e54b3a9b6cd66341c --- /dev/null +++ b/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / + +Sitemap: https://zerotvm.com/sitemap.xml +Host: https://zerotvm.com diff --git a/share.html b/share.html new file mode 100644 index 0000000000000000000000000000000000000000..bedf5638b0e64b5a372965b78e09170515e8394b --- /dev/null +++ b/share.html @@ -0,0 +1,181 @@ + + + + + + Share a model β€” Zero-TVM + + + + + + + + + + + + + + + + + + + + + diff --git a/sitemap.xml b/sitemap.xml new file mode 100644 index 0000000000000000000000000000000000000000..d01a4095646126ef7cbd8664aeb0b1749978c1ad --- /dev/null +++ b/sitemap.xml @@ -0,0 +1,32 @@ + + + + + https://zerotvm.com/ + weekly + 1.0 + + + https://zerotvm.com/zero-tvm.html + weekly + 0.9 + + + https://zerotvm.com/docs.html + monthly + 0.8 + + + https://zerotvm.com/validate.html + monthly + 0.6 + + diff --git a/style.css b/style.css new file mode 100644 index 0000000000000000000000000000000000000000..d5f4448f4e091e4f2896ed54d50b7a27cc9c4815 --- /dev/null +++ b/style.css @@ -0,0 +1,141 @@ +/* Shared styles for Zero-TVM chat, WebLLM bench, and related demo pages. */ + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #0a0a0a; + color: #e0e0e0; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +header { + padding: 1rem 2rem; + background: #111; + border-bottom: 1px solid #222; + display: flex; + align-items: center; + gap: 1rem; +} + +header h1 { + font-size: 1.2rem; + color: #fff; + font-weight: 600; + letter-spacing: -0.01em; +} + +.back-link { + color: #6c63ff; + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + margin-right: 0.5rem; + transition: opacity 0.15s; +} + +.back-link:hover { opacity: 0.75; } + +.badge { + background: #1a472a; + color: #4caf50; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; +} +.badge.loading { background: #3a3a00; color: #ffd54f; } +.badge.error { background: #4a1a1a; color: #ff7070; } + +.stats, #stats { + margin-left: auto; + font-size: 0.8rem; + color: #666; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.log, #log { + padding: 0.5rem 2rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.65rem; + color: #666; + background: #0d0d0d; + max-height: 140px; + overflow-y: auto; + border-bottom: 1px solid #1a1a1a; + white-space: pre-wrap; +} + +.chat, #chat { + flex: 1; + overflow-y: auto; + padding: 1rem 2rem; +} + +.msg { + margin-bottom: 1rem; + max-width: 80%; + padding: 0.75rem 1rem; + border-radius: 12px; + line-height: 1.6; + white-space: pre-wrap; + font-size: 0.95rem; +} +.msg.user { background: #1a3a5c; margin-left: auto; border-bottom-right-radius: 4px; } +.msg.ai { background: #1a1a1a; border-bottom-left-radius: 4px; } + +.input-row, #bar { + padding: 1rem 2rem; + background: #111; + border-top: 1px solid #222; + display: flex; + gap: 0.75rem; +} + +.input-row input, #bar input { + flex: 1; + background: #1a1a1a; + border: 1px solid #333; + color: #fff; + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 1rem; + outline: none; +} +.input-row input:focus, #bar input:focus { border-color: #1a73e8; } +.input-row input:disabled, #bar input:disabled { opacity: 0.5; } + +.input-row button, #bar button { + background: #1a73e8; + color: #fff; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; +} +.input-row button:hover:not(:disabled), +#bar button:hover:not(:disabled) { background: #1557b0; } +.input-row button:disabled, #bar button:disabled { background: #333; cursor: not-allowed; } + +footer, .note { + text-align: center; + padding: 0.6rem; + font-size: 0.7rem; + color: #555; + background: #0d0d0d; + border-top: 1px solid #1a1a1a; +} diff --git a/tokens.css b/tokens.css new file mode 100644 index 0000000000000000000000000000000000000000..bd0ce3be7b1d73151584ae14ddd72a116719822a --- /dev/null +++ b/tokens.css @@ -0,0 +1,47 @@ +/* FAMILY TOKENS β€” the chassis nine sibling sites share, with the one accent + none of them claimed: molten amber. docs/SITE_REDESIGN.md is the spec. + + The alias layer at the bottom (--surface --border --text --muted --accent) + is the shared API webgpu-dna and neuropulse already declare, so a future + sites-shared/tokens.css can drive all of them from one place. */ + +:root { + /* ground */ + --bg: #05070d; + --surface: #0b0f1a; + --surface-2: #111726; + --border: #1c2434; + --border-hi: #2a3446; + + /* ink β€” a 3-step ramp, off-white rather than pure */ + --text: #edf0f6; + --muted: #a9b3c6; + --dim: #5e6a7e; + + /* accent */ + --accent: #f0a860; + --accent-hi: #ffc98f; + --accent-dim: #f0a86022; + + /* semantic β€” deliberately separate from the accent, so "good" never reads + as "emphasis" and a losing number never borrows the brand colour */ + --ok: #34e3c0; + --warn: #ffc98f; + --err: #ff7a6b; + + /* type */ + --display: 'Fraunces', 'Iowan Old Style', Georgia, serif; + --body: 'Spline Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --mono: 'Spline Sans Mono', ui-monospace, 'SF Mono', 'Fira Code', monospace; + + /* scale */ + --step--1: clamp(0.78rem, 0.76rem + 0.1vw, 0.84rem); + --step-0: clamp(0.98rem, 0.95rem + 0.15vw, 1.06rem); + --step-1: clamp(1.2rem, 1.1rem + 0.5vw, 1.45rem); + --step-2: clamp(1.6rem, 1.35rem + 1.2vw, 2.2rem); + --step-3: clamp(2.1rem, 1.6rem + 2.4vw, 3.6rem); + + --rad: 10px; + --rad-s: 6px; + --maxw: 1120px; +} diff --git a/validate.html b/validate.html new file mode 100644 index 0000000000000000000000000000000000000000..840009d057f69b230ac5fcdfe737e57d1773e03e --- /dev/null +++ b/validate.html @@ -0,0 +1,169 @@ + + + + + + Validate β€” Zero-TVM + + + + + + + + + + + + + + + + + + + +
+
+ ← zerotvm.com +

Validate

+ Waiting + Idle +
+
+
+
Zero-TVM Validation Harness
+
+ Runs the full Zero-TVM forward pass on a battery of diverse prompts and + shows the top-10 next tokens + a short greedy continuation for each. + Pair this with the kernel suites in the repo (npm run test:kernels) + to check that the live chat path generalizes beyond a single + memorized prompt. +
+ +
~2 GB Phi-3 weights download on first run, cached after
+
+
+
Initializing...
+
+
+
+
+
+
+
+
+
+ + + diff --git a/weights-cache-sw.js b/weights-cache-sw.js new file mode 100644 index 0000000000000000000000000000000000000000..b4ead6e97613d90104b9f761452858112e89f3ae --- /dev/null +++ b/weights-cache-sw.js @@ -0,0 +1,104 @@ +/* eslint-env serviceworker */ +/** + * weights-cache-sw.js β€” Phi-3 weight cache, shared by any page that loads those weights. + * + * Both zero-tvm's hand-written loader and WebLLM fetch the same files from the + * HF mirror at + * `huggingface.co/mlc-ai/Phi-3-mini-4k-instruct-q4f16_1-MLC/resolve/main/*`. + * Without this SW each cache their own copy, so visiting both pages downloads + * 1.8 GB twice. This SW intercepts those URLs and serves from a shared OPFS + * directory (`zero-tvm-weights/`, the same dir zero-tvm's own loader uses for + * its fast path) β€” populating it on first download from either side. + * + * Result: whichever page the visitor opens first pays the network cost once. + * Subsequent visits to either page hit OPFS instantly. + */ + +// KEEP IN SYNC with WEIGHTS_OPFS_DIR in src/zero-tvm/weight-loader.ts β€” this +// SW is plain JS and cannot import the exported constant, so the two +// definitions are paired by hand. +const SHARED_DIR = "zero-tvm-weights"; +const HF_PHI3_HOST = "huggingface.co"; +const HF_PHI3_PATH_RE = /^\/mlc-ai\/Phi-3-mini-4k-instruct-q4f16_1-MLC\/resolve\/[^/]+\//; + +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim())); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + let url; + try { url = new URL(req.url); } catch { return; } + if (url.host !== HF_PHI3_HOST || !HF_PHI3_PATH_RE.test(url.pathname)) return; + + event.respondWith(handle(req, url)); +}); + +async function handle(req, url) { + // Flatten the path-suffix into an OPFS-safe filename, matching the + // `opfsKey()` convention in zero-tvm's weight-loader.ts byte-for-byte + // (so files written by the SW are reused by zero-tvm's direct loader, + // and vice versa). + const dataPath = url.pathname.replace(HF_PHI3_PATH_RE, ""); + const key = dataPath.replace(/[^A-Za-z0-9._-]/g, "_"); + + // 1. Try OPFS hit first. + const cached = await opfsRead(key).catch(() => null); + if (cached) { + return new Response(cached, { + status: 200, + headers: { + "content-type": guessType(key), + "content-length": String(cached.byteLength), + "x-zero-tvm-cache": "opfs-hit", + }, + }); + } + + // 2. Network fall-through. Stream into OPFS so subsequent visits hit cache. + const netResp = await fetch(req); + // Only cache complete 200 bodies: a 206 (the weight-loader resumes dead + // transfers with Range requests) buffered under the full key would poison + // the shared cache with a truncated shard. Pass everything else through. + if (netResp.status !== 200 || !netResp.body) return netResp; + + const buf = await netResp.clone().arrayBuffer(); + // Best-effort write β€” failures (quota, no OPFS) are fine; just no caching. + opfsWrite(key, buf).catch(() => {}); + + return new Response(buf, { + status: netResp.status, + headers: { + "content-type": netResp.headers.get("content-type") || guessType(key), + "content-length": String(buf.byteLength), + "x-zero-tvm-cache": "network", + }, + }); +} + +async function getDir() { + if (!self.navigator?.storage?.getDirectory) throw new Error("no OPFS in SW"); + const root = await self.navigator.storage.getDirectory(); + return root.getDirectoryHandle(SHARED_DIR, { create: true }); +} + +async function opfsRead(key) { + const dir = await getDir(); + const fh = await dir.getFileHandle(key); + const file = await fh.getFile(); + return await file.arrayBuffer(); +} + +async function opfsWrite(key, buf) { + const dir = await getDir(); + const fh = await dir.getFileHandle(key, { create: true }); + const writer = await fh.createWritable(); + await writer.write(buf); + await writer.close(); +} + +function guessType(key) { + if (key.endsWith(".json")) return "application/json"; + if (key.endsWith(".wasm")) return "application/wasm"; + return "application/octet-stream"; +} diff --git a/zero-tvm.html b/zero-tvm.html new file mode 100644 index 0000000000000000000000000000000000000000..efe0ebb14c1c1d84b7544f0f9dd89cb38ef35692 --- /dev/null +++ b/zero-tvm.html @@ -0,0 +1,737 @@ + + + + + + Zero-TVM Chat β€” LLMs up to a 35B MoE on hand-written WebGPU kernels + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
Phi-3-mini on raw WebGPU
+
Runs in your browser on hand-written WGSL kernels. Weights download once and cache locally; prompts and generation stay on this machine.
+
+
+
~2 GB
weights
+
~70 t/s
M2 Max
+
context
+
+ + +
+
    +
  • Cached after first load β€” next visit starts in seconds
  • +
  • Chrome 113+ / Edge 113+ with shader-f16 required
  • +
  • Progress and detailed log shown while downloading
  • +
+
+
+ Not now + +
+
+ + + +
+
+
Preparing Zero-TVM
+
Starting…
+
+
0%
+
+ Details +

+      
+
+
+
+ + +
+
+ + + +
+ +
hand-written WGSL Β· paged KV Β· WebGPU
+ +
+
+ +
+ + +
+ + Waiting +
+ +
+
+
+ +
Chat with Phi-3-mini
+
+ q4f16_1 Β· 3.8 B + 4 K context + Runs on your GPU Β· WebGPU + On-device only +
+
Running on your own GPU through hand-written WGSL kernels. Prompts, tokens and the KV cache stay in this tab.
+
+ + + + + + +
+
+
+
+ +
+ +
+
+ + + +
+
+ Enter to send Β· Shift+Enter for new line Β· + Zero TVM Β· 10 WGSL kernel roles +
+
+
+ +