nanochat/nanochat/ui.html
Manmohan Sharma e159c1cf9e
improve mobile responsiveness: proper scaling for phone/tablet
- Tablet (700px): shrink illustrations, hide steam, wider message bubbles
- Mobile (480px): smaller hero text, compact input bar, stacked footer,
  tighter spacing, scaled-down toran, properly sized illustrations
- Small phones (360px): further reduced hero/illustration sizes
- Safe area insets for notched phones

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:46:57 -04:00

921 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>समोसा चाट — samosaChaat</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg">
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;600;700;800&family=Caveat:wght@400;600;700&family=Great+Vibes&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: light;
--gold: #e8a838;
--brown: #8b4d0a;
--cream: #fff8e7;
--green-chutney: #2d8a4e;
--red-chutney: #c0392b;
--warm-grey: #d4c4a0;
--light-cream: #fffdf5;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: 'Caveat', cursive, sans-serif;
background-color: #ffffff;
color: #111827;
min-height: 100dvh;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
/* ===== NAVBAR ===== */
.landing-nav {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.1rem 2.2rem;
position: relative;
z-index: 10;
flex-shrink: 0;
}
.nav-left {
display: flex;
align-items: center;
gap: 0.6rem;
}
.nav-left .home-icon { cursor: pointer; transition: transform 0.2s; }
.nav-left .home-icon:hover { transform: scale(1.08); }
.nav-brand {
font-family: 'Caveat', cursive;
font-size: 1.35rem;
font-weight: 600;
color: #333;
text-decoration: none;
position: relative;
}
.nav-brand::after {
content: '';
position: absolute;
bottom: -2px; left: 0;
width: 100%; height: 1.5px;
background: #555;
border-radius: 1px;
transform: rotate(-0.5deg);
}
.nav-right {
display: flex;
align-items: center;
gap: 1.2rem;
font-family: 'Caveat', cursive;
font-size: 1.05rem;
color: #555;
padding-top: 0.3rem;
}
.nav-right a { color: #555; text-decoration: none; transition: color 0.2s; }
.nav-right a:hover { color: var(--brown); }
.inference-badge {
font-family: 'Caveat', cursive;
font-size: 0.9rem;
padding: 2px 10px;
border-radius: 12px;
border: 1px solid var(--warm-grey);
color: var(--brown);
background: var(--light-cream);
display: none;
}
.inference-badge.visible { display: inline; }
.inference-badge.local {
border-color: var(--green-chutney);
color: var(--green-chutney);
background: #eafaf0;
}
/* --- Toran (center navbar) --- */
.toran-wrap {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
transform-origin: top center;
animation: pendulum 3s ease-in-out infinite alternate;
z-index: 5;
}
/* ===== LANDING ELEMENTS (fade/slide out on chat) ===== */
.hero {
text-align: center;
padding: 1.5rem 1rem 0;
flex-shrink: 0;
transition: opacity 0.5s ease, transform 0.5s ease;
}
.hero-hindi {
font-family: 'Baloo 2', cursive;
font-weight: 800;
font-size: clamp(3rem, 7vw, 5.5rem);
color: #1a1a1a;
line-height: 1.1;
transform: rotate(-1deg);
margin-bottom: -0.35em;
position: relative;
z-index: 2;
}
.hero-english {
font-family: 'Great Vibes', cursive;
font-weight: 400;
font-size: clamp(2rem, 5vw, 3.8rem);
color: rgba(30,30,30,0.55);
line-height: 1;
transform: rotate(0.5deg);
position: relative;
z-index: 1;
margin-top: -0.1em;
}
/* --- Illustrations (absolute positioned, slide off-screen) --- */
.illust-left {
position: fixed;
bottom: 5%;
left: 5%;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease;
z-index: 5;
}
.illust-left .samosa-svg {
animation: float 2.5s ease-in-out infinite;
}
.explore-tag {
font-family: 'Caveat', cursive;
font-size: 1.15rem;
color: #5a3e1b;
background: #f5edd6;
padding: 3px 18px;
border: 1px solid #d4c4a0;
transform: rotate(-3deg);
box-shadow: 1px 2px 5px rgba(0,0,0,0.08);
margin-top: 6px;
display: inline-block;
border-radius: 2px;
}
.illust-right {
position: fixed;
bottom: 5%;
right: 5%;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease;
z-index: 5;
}
.illust-right .kettle-wrap {
animation: wobble 3s ease-in-out infinite;
position: relative;
}
.kettle-steam {
position: absolute;
top: -40px;
right: -10px;
pointer-events: none;
}
.steam-wisp { transform-box: fill-box; transform-origin: bottom center; }
.steam-w1 { animation: steamFloat 2.8s ease-in-out infinite; }
.steam-w2 { animation: steamFloat 2.8s ease-in-out infinite 0.7s; }
.steam-w3 { animation: steamFloat 2.8s ease-in-out infinite 1.4s; }
.chai-label {
font-family: 'Caveat', cursive;
font-size: 1.15rem;
color: #5a3e1b;
background: #f5edd6;
padding: 3px 18px;
border: 1px solid #d4c4a0;
transform: rotate(2deg);
box-shadow: 1px 2px 5px rgba(0,0,0,0.08);
margin-top: 6px;
display: inline-block;
border-radius: 2px;
}
/* Slide-out states */
.illust-left.slide-out { transform: translateX(-120vw); opacity: 0; }
.illust-right.slide-out { transform: translateX(120vw); opacity: 0; }
.hero.fade-out { opacity: 0; transform: translateY(-30px); pointer-events: none; }
.doodle.fade-out { opacity: 0 !important; }
.landing-footer.fade-out { opacity: 0; pointer-events: none; }
/* --- Ambient doodles --- */
.doodle {
position: fixed;
pointer-events: none;
opacity: 0.35;
z-index: 0;
transition: opacity 0.5s ease;
}
.doodle-1 { top: 18%; right: 6%; transform: rotate(25deg); }
.doodle-2 { bottom: 28%; left: 3%; transform: rotate(-15deg); }
.doodle-3 { top: 45%; right: 3%; transform: rotate(140deg); }
.doodle-4 { bottom: 18%; left: 42%; transform: rotate(10deg); }
.doodle-5 { top: 32%; left: 7%; transform: rotate(-40deg); }
/* --- Footer --- */
.landing-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 2.2rem;
font-family: 'Caveat', cursive;
font-size: 0.95rem;
color: #999;
flex-shrink: 0;
transition: opacity 0.5s ease;
margin-top: auto;
}
.landing-footer a { color: #999; text-decoration: none; transition: color 0.2s; }
.landing-footer a:hover { color: #666; }
/* ===== ANIMATIONS ===== */
@keyframes pendulum {
0% { transform: translateX(-50%) rotate(-4deg); }
100% { transform: translateX(-50%) rotate(4deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes wobble {
0%, 100% { transform: rotate(-1.5deg); }
50% { transform: rotate(1.5deg); }
}
@keyframes steamFloat {
0% { opacity: 0; transform: translateY(4px) scaleX(0.8); }
35% { opacity: 0.5; }
70% { opacity: 0.35; }
100% { opacity: 0; transform: translateY(-18px) scaleX(1.4); }
}
/* ===== CHAT AREA ===== */
.chat-container {
flex: 1;
overflow-y: auto;
background-color: #ffffff;
display: none;
}
.chat-container.active { display: block; }
.chat-wrapper {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem 1.5rem 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* --- Download banner --- */
.download-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1.5rem;
background: var(--light-cream);
border-bottom: 1px solid #f0e8d8;
font-family: 'Caveat', cursive;
font-size: 1rem;
color: var(--brown);
}
.progress-track {
flex: 1; height: 6px; background: #efe6d0; border-radius: 3px; overflow: hidden; max-width: 220px;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--gold), #d4940a);
border-radius: 3px; transition: width 0.3s ease;
}
.download-cancel {
background: none; border: none; font-family: 'Caveat', cursive;
font-size: 0.95rem; color: var(--red-chutney); cursor: pointer; padding: 2px 6px;
}
.download-cancel:hover { text-decoration: underline; }
.download-done { color: var(--green-chutney); font-weight: 600; }
/* --- Messages --- */
.message {
display: flex;
justify-content: flex-start;
margin-bottom: 0.5rem;
color: #0d0d0d;
}
.message.assistant { justify-content: flex-start; }
.message.user { justify-content: flex-end; }
.message-content {
white-space: pre-wrap;
line-height: 1.65;
max-width: 100%;
font-family: ui-sans-serif, -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 1rem;
}
.message.assistant .message-content {
background: transparent; border: none; cursor: pointer;
border-radius: 0.5rem; padding: 0.5rem; margin-left: -0.5rem;
transition: background-color 0.2s ease;
}
.message.assistant .message-content:hover { background-color: var(--light-cream); }
.message.user .message-content {
background-color: var(--cream); border: 1px solid #f0e0b8;
border-radius: 1.25rem; padding: 0.8rem 1rem; max-width: 65%;
cursor: pointer; transition: background-color 0.2s ease, border-color 0.2s ease;
}
.message.user .message-content:hover { background-color: #fff0d0; border-color: var(--gold); }
.message.console .message-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Courier New', monospace;
font-size: 0.875rem; background-color: var(--light-cream); border: 1px solid #f0e8d8;
padding: 0.75rem 1rem; color: #5a3e1b; max-width: 80%; border-radius: 0.75rem;
}
.error-message {
background-color: #fee2e2; border: 1px solid #fecaca; color: #b91c1c;
padding: 0.75rem 1rem; border-radius: 0.75rem; margin-top: 0.5rem;
}
/* --- Steam typing indicator --- */
.steam-typing-wrap { display: inline-flex; align-items: flex-end; height: 28px; padding: 0 4px; }
.steam-typing-wrap .st {
width: 3px; border-radius: 2px; background: var(--warm-grey);
margin: 0 3px; animation: steamType 1.6s ease-in-out infinite; transform-origin: bottom;
}
.steam-typing-wrap .st:nth-child(1) { height: 14px; animation-delay: 0s; }
.steam-typing-wrap .st:nth-child(2) { height: 18px; animation-delay: 0.25s; }
.steam-typing-wrap .st:nth-child(3) { height: 12px; animation-delay: 0.5s; }
.steam-typing-wrap .st:nth-child(4) { height: 16px; animation-delay: 0.75s; }
@keyframes steamType {
0%, 100% { opacity: 0.25; transform: scaleY(0.6); }
50% { opacity: 0.8; transform: scaleY(1); }
}
/* --- Input --- */
.input-container {
background-color: #ffffff;
padding: 1rem;
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
flex-shrink: 0;
z-index: 10;
}
/* In landing mode, push input to vertically center with illustrations */
.input-container.landing-mode {
margin-top: auto;
margin-bottom: 0;
}
.input-wrapper {
max-width: 48rem; margin: 0 auto; display: flex; gap: 0.75rem; align-items: flex-end;
}
.chat-input {
flex: 1; padding: 0.8rem 1rem;
border: 1px solid var(--warm-grey); border-radius: 0.75rem;
background-color: #ffffff; color: #111827;
font-family: ui-sans-serif, -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 1rem; line-height: 1.5; resize: none; outline: none;
min-height: 54px; max-height: 200px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.chat-input::placeholder { color: #b8a88a; }
.chat-input:focus { border-color: var(--gold); box-shadow: 0 0 0 3px rgba(232, 168, 56, 0.15); }
.send-button {
flex-shrink: 0; padding: 0; width: 54px; height: 54px;
border: 1px solid var(--gold); border-radius: 0.75rem;
background-color: var(--gold); color: #ffffff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: background-color 0.2s ease, border-color 0.2s ease;
}
.send-button:hover:not(:disabled) { background-color: #d4940a; border-color: #d4940a; }
.send-button:disabled {
cursor: not-allowed; border-color: #e0d5c0; background-color: #efe6d0; color: #c4b090;
}
.new-conversation-btn {
width: 32px; height: 32px; padding: 0;
border: 1px solid #e0d5c0; border-radius: 0.5rem;
background-color: #ffffff; color: #8b7355;
cursor: pointer; display: none; align-items: center; justify-content: center;
transition: all 0.2s ease;
}
.new-conversation-btn.visible { display: flex; }
.new-conversation-btn:hover { background-color: var(--cream); border-color: var(--warm-grey); color: var(--brown); }
/* ===== RESPONSIVE ===== */
/* --- Tablet --- */
@media (max-width: 700px) {
.landing-nav { padding: 0.8rem 1rem; }
.nav-right .social-text { display: none; }
.hero-hindi { font-size: 2.5rem; }
.hero-english { font-size: 1.6rem; }
.illust-left { left: 2%; }
.illust-right { right: 2%; }
.illust-left svg { width: 130px; height: auto; }
.illust-right svg.kettle-svg { width: 130px; height: auto; }
.kettle-steam { display: none; }
.doodle { display: none; }
.landing-footer { padding: 0.6rem 1rem; font-size: 0.85rem; }
.chat-wrapper { padding: 1.5rem 1rem 2rem; }
.message.user .message-content { max-width: 80%; }
}
/* --- Mobile --- */
@media (max-width: 480px) {
.landing-nav { padding: 0.6rem 0.8rem; }
.nav-brand { font-size: 1.15rem; }
.toran-wrap svg { width: 36px; height: 75px; }
.hero { padding: 1rem 0.5rem 0; }
.hero-hindi { font-size: 1.8rem; }
.hero-english { font-size: 1.2rem; }
.illust-left { left: 1%; bottom: 8%; }
.illust-right { right: 1%; bottom: 8%; }
.illust-left svg { width: 90px; }
.illust-right svg.kettle-svg { width: 90px; }
.explore-tag, .chai-label { font-size: 0.95rem; padding: 2px 12px; }
.input-container { padding: 0.6rem 0.5rem; padding-bottom: calc(0.6rem + env(safe-area-inset-bottom)); }
.input-wrapper { gap: 0.5rem; }
.chat-input { min-height: 46px; font-size: 0.95rem; padding: 0.6rem 0.8rem; }
.send-button { width: 46px; height: 46px; }
.chat-wrapper { padding: 1rem 0.75rem 1.5rem; }
.message.user .message-content { max-width: 85%; padding: 0.6rem 0.8rem; font-size: 0.95rem; }
.message.assistant .message-content { font-size: 0.95rem; }
.landing-footer {
flex-direction: column;
gap: 0.3rem;
text-align: center;
padding: 0.6rem 0.8rem;
font-size: 0.8rem;
}
.new-conversation-btn { width: 28px; height: 28px; }
.inference-badge { font-size: 0.8rem; padding: 1px 8px; }
}
/* --- Small phones --- */
@media (max-width: 360px) {
.hero-hindi { font-size: 1.5rem; }
.hero-english { font-size: 1rem; }
.illust-left svg { width: 70px; }
.illust-right svg.kettle-svg { width: 70px; }
.explore-tag, .chai-label { font-size: 0.85rem; }
}
</style>
</head>
<body>
<!-- Navbar (always visible) -->
<nav class="landing-nav">
<div class="nav-left">
<svg class="home-icon" onclick="resetToLanding()" viewBox="0 0 30 30" width="30" height="30" fill="none" stroke="#444" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
<path d="M4,16 L15.2,5 L26,16"/>
<path d="M7,14.5 L7,26 L23,26 L23,14.5"/>
<rect x="12" y="19" width="6" height="7" rx="0.5"/>
<rect x="8.5" y="16" width="3" height="2.8" rx="0.3"/>
<rect x="18.5" y="16" width="3" height="2.8" rx="0.3"/>
</svg>
<a class="nav-brand" href="#">samosaChaat</a>
</div>
<!-- Toran -->
<div class="toran-wrap">
<svg viewBox="0 0 50 105" width="48" height="100" fill="none">
<line x1="25" y1="0" x2="25" y2="88" stroke="#555" stroke-width="1"/>
<path d="M23,16 C18,22 13,34 14,46 C14,49 16,50 18,47 C20,43 22,28 23,20Z" fill="#2E8B57" stroke="#1a6b3a" stroke-width="0.4"/>
<path d="M23,16 L22,11" stroke="#5a7c4f" stroke-width="1.4" stroke-linecap="round"/>
<path d="M27,20 C32,26 37,38 36,50 C36,53 34,54 32,51 C30,47 28,32 27,24Z" fill="#34A85A" stroke="#228B44" stroke-width="0.4"/>
<path d="M27,20 L28,15" stroke="#5a7c4f" stroke-width="1.4" stroke-linecap="round"/>
<path d="M23,35 C17,42 13,52 15,61 C15,64 17,64 19,62 C21,58 22,45 23,39Z" fill="#2E8B57" stroke="#1a6b3a" stroke-width="0.4"/>
<path d="M23,35 L22,31" stroke="#5a7c4f" stroke-width="1.3" stroke-linecap="round"/>
<path d="M27,42 C31,47 34,54 33,61 C33,63 31,63 30,61 C29,58 28,49 27,45Z" fill="#34A85A" stroke="#228B44" stroke-width="0.4"/>
<path d="M27,42 L28,39" stroke="#5a7c4f" stroke-width="1.2" stroke-linecap="round"/>
<!-- Lemon -->
<ellipse cx="25" cy="82" rx="12" ry="10.5" fill="#F4D03F"/>
<ellipse cx="25" cy="82" rx="9" ry="8" fill="#F7DC6F" opacity="0.5"/>
<ellipse cx="23" cy="80" rx="5" ry="4" fill="#FAEA7A" opacity="0.35"/>
<path d="M25,71 Q26,68 25,66" stroke="#C5A828" stroke-width="1.5" stroke-linecap="round" fill="none"/>
<circle cx="25" cy="65.5" r="1.5" fill="#8B9A46"/>
</svg>
</div>
<div class="nav-right">
<span id="inferenceMode" class="inference-badge">Cloud</span>
<button id="newConvBtn" class="new-conversation-btn" onclick="newConversation()" title="New Conversation (Ctrl+Shift+N)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14"></path><path d="M5 12h14"></path>
</svg>
</button>
<a href="https://instagram.com/samosachaat.art" class="social-text" target="_blank" rel="noopener">@samosachaat</a>
</div>
</nav>
<!-- Download banner (hidden until WebGPU engine calls API) -->
<div id="downloadBanner" class="download-banner" style="display:none">
<span id="downloadText">Downloading model for faster local inference...</span>
<div class="progress-track"><div id="progressFill" class="progress-fill"></div></div>
<span id="downloadPercent"></span>
<button id="downloadCancel" class="download-cancel" onclick="window.samosaChaat._cancelDownload && window.samosaChaat._cancelDownload()">Cancel</button>
</div>
<!-- Hero (fades out on chat) -->
<section class="hero" id="heroSection">
<h1 class="hero-hindi">समोसा चाट</h1>
<h2 class="hero-english">samosaChaat</h2>
</section>
<!-- Chat messages (hidden initially, grows on chat) -->
<div class="chat-container" id="chatContainer">
<div class="chat-wrapper" id="chatWrapper"></div>
</div>
<!-- Samosa illustration (left, slides out) -->
<div class="illust-left" id="samosaIllust">
<svg class="samosa-svg" viewBox="0 0 220 195" width="200" height="175">
<defs>
<linearGradient id="samosaG" x1="30%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#edb44c"/><stop offset="35%" stop-color="#d4940a"/>
<stop offset="75%" stop-color="#b87a08"/><stop offset="100%" stop-color="#8b5e0a"/>
</linearGradient>
<linearGradient id="samosaHighlight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f5d080" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#d4940a" stop-opacity="0"/>
</linearGradient>
<filter id="samosaSh"><feDropShadow dx="1" dy="3" stdDeviation="3" flood-color="#00000015"/></filter>
</defs>
<ellipse cx="108" cy="178" rx="58" ry="7" fill="#00000008"/>
<path d="M108,18 C88,18 38,72 28,128 C24,150 35,168 55,170 L162,170 C182,168 192,150 188,128 C178,72 128,18 108,18Z" fill="url(#samosaG)" filter="url(#samosaSh)"/>
<path d="M108,18 C88,18 38,72 28,128 C24,150 35,168 55,170 L162,170 C182,168 192,150 188,128 C178,72 128,18 108,18Z" fill="url(#samosaHighlight)"/>
<path d="M108,28 Q102,65 92,105 Q87,128 82,148" stroke="#a06808" stroke-width="1.2" fill="none" opacity="0.35"/>
<path d="M108,28 Q114,65 124,105 Q129,128 134,148" stroke="#a06808" stroke-width="1.2" fill="none" opacity="0.35"/>
<path d="M90,50 Q95,47 100,50 Q105,53 110,50 Q115,47 120,50 Q125,53 128,50" stroke="#8b5e0a" stroke-width="1" fill="none" opacity="0.3"/>
<path d="M55,170 Q80,164 108,166 Q136,168 162,170" stroke="#8b5e0a" stroke-width="1.5" fill="none" opacity="0.4"/>
<path d="M88,50 Q94,68 96,90" stroke="#f5d080" stroke-width="2.5" fill="none" opacity="0.35" stroke-linecap="round"/>
<ellipse cx="185" cy="158" rx="14" ry="9" fill="#2d8a4e" opacity="0.65"/>
<ellipse cx="183" cy="156" rx="9" ry="5.5" fill="#3aa85e" opacity="0.45"/>
<circle cx="60" cy="175" r="2" fill="#c4940a" opacity="0.3"/>
<circle cx="165" cy="176" r="1.5" fill="#c4940a" opacity="0.25"/>
<circle cx="145" cy="180" r="1.8" fill="#b87a08" opacity="0.2"/>
</svg>
<span class="explore-tag">Samosa</span>
</div>
<!-- Chai Kettle (right, slides out) -->
<div class="illust-right" id="kettleIllust">
<div class="kettle-wrap">
<svg class="kettle-steam" viewBox="0 0 60 55" width="60" height="55">
<g class="steam-w1"><path d="M15,48 Q10,36 18,26 Q26,16 18,4" stroke="#aaa" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.4"/></g>
<g class="steam-w2"><path d="M30,48 Q36,36 28,26 Q20,16 28,4" stroke="#bbb" stroke-width="1.8" fill="none" stroke-linecap="round" opacity="0.3"/></g>
<g class="steam-w3"><path d="M45,48 Q40,36 46,28 Q52,18 44,6" stroke="#aaa" stroke-width="1.5" fill="none" stroke-linecap="round" opacity="0.3"/></g>
</svg>
<svg class="kettle-svg" viewBox="0 0 200 185" width="180" height="165">
<defs>
<linearGradient id="kettleG" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#d4a543"/><stop offset="45%" stop-color="#b8862a"/>
<stop offset="100%" stop-color="#8b6914"/>
</linearGradient>
<radialGradient id="kettleHL" cx="35%" cy="38%">
<stop offset="0%" stop-color="#e8c860" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#d4a543" stop-opacity="0"/>
</radialGradient>
<filter id="kettleSh"><feDropShadow dx="1" dy="3" stdDeviation="3" flood-color="#00000012"/></filter>
</defs>
<ellipse cx="95" cy="178" rx="55" ry="6" fill="#00000008"/>
<path d="M28,118 C28,155 55,175 98,175 C141,175 168,155 168,118 C168,92 152,78 142,73 L54,73 C44,78 28,92 28,118Z" fill="url(#kettleG)" filter="url(#kettleSh)"/>
<path d="M28,118 C28,155 55,175 98,175 C141,175 168,155 168,118 C168,92 152,78 142,73 L54,73 C44,78 28,92 28,118Z" fill="url(#kettleHL)"/>
<rect x="58" y="54" width="80" height="19" rx="3" fill="#a07828"/>
<path d="M54,57 Q98,42 142,57" fill="#a07828" stroke="#8b6914" stroke-width="0.8"/>
<circle cx="98" cy="44" r="5" fill="#8b6914" stroke="#705510" stroke-width="0.8"/>
<path d="M56,52 Q38,18 98,10 Q158,18 140,52" stroke="#8b6914" stroke-width="4.5" fill="none" stroke-linecap="round"/>
<path d="M56,52 Q38,18 98,10 Q158,18 140,52" stroke="#c4a040" stroke-width="1.5" fill="none" stroke-linecap="round" opacity="0.3"/>
<path d="M163,98 Q178,90 182,76 Q184,70 180,66" stroke="#a07828" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M163,98 Q178,90 182,76 Q184,70 180,66" stroke="#c4a040" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.25"/>
<ellipse cx="98" cy="112" rx="66" ry="2.5" fill="#c4a040" opacity="0.25"/>
<ellipse cx="98" cy="128" rx="64" ry="2" fill="#c4a040" opacity="0.18"/>
<path d="M58,103 Q63,98 68,103 Q73,108 78,103" stroke="#705510" stroke-width="0.7" fill="none" opacity="0.25"/>
<path d="M118,103 Q123,98 128,103 Q133,108 138,103" stroke="#705510" stroke-width="0.7" fill="none" opacity="0.25"/>
<path d="M55,95 Q60,110 62,130" stroke="#e8c860" stroke-width="3" fill="none" opacity="0.2" stroke-linecap="round"/>
</svg>
</div>
<span class="chai-label">Chai</span>
</div>
<!-- Ambient doodles -->
<div class="doodle doodle-1"><svg viewBox="0 0 28 14" width="32" height="16"><path d="M2,7 C2,3 8,1 14,2 C20,3 26,7 26,10 C26,12 24,13 22,12 C18,10 10,5 6,6 C4,6.5 2,9 2,7Z" fill="#34A85A" opacity="0.7"/></svg></div>
<div class="doodle doodle-2"><svg viewBox="0 0 18 18" width="20" height="20"><circle cx="9" cy="9" r="7" fill="#F4D03F" opacity="0.6"/><circle cx="9" cy="9" r="5" fill="#F7DC6F" opacity="0.35"/></svg></div>
<div class="doodle doodle-3"><svg viewBox="0 0 24 12" width="28" height="14"><path d="M2,6 C2,3 6,1 12,2 C18,3 22,6 22,9 C22,11 20,11 18,10 C14,8 8,4 4,5 C3,5.5 2,7.5 2,6Z" fill="#2E8B57" opacity="0.6"/></svg></div>
<div class="doodle doodle-4"><svg viewBox="0 0 16 16" width="16" height="16"><circle cx="8" cy="8" r="6" fill="#F4D03F" opacity="0.45"/></svg></div>
<div class="doodle doodle-5"><svg viewBox="0 0 22 11" width="24" height="12"><path d="M2,5 C2,2 6,1 11,2 C16,3 20,5 20,8 C20,10 18,10 16,9 C12,7 8,3 4,4.5 C3,5 2,7 2,5Z" fill="#34A85A" opacity="0.55"/></svg></div>
<!-- Input -->
<div class="input-container landing-mode" id="inputContainer">
<div class="input-wrapper">
<textarea id="chatInput" class="chat-input" placeholder="Ask samosaChaat anything..." rows="1" onkeydown="handleKeyDown(event)"></textarea>
<button id="sendButton" class="send-button" onclick="sendMessage()" disabled>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"></path><path d="M22 2l-7 20-4-9-9-4 20-7z"></path>
</svg>
</button>
</div>
</div>
<!-- Footer (always at very bottom) -->
<footer class="landing-footer" id="landingFooter">
<span>&copy; 2026 samosachaat.art</span>
<span style="font-size:0.85rem; color:#bbb;">Built on <a href="https://github.com/karpathy/nanochat" target="_blank" rel="noopener" style="color:#b8a88a;">nanochat</a> by Andrej Karpathy</span>
<a href="#">Terms and Policies</a>
</footer>
<script>
// ================================================================
// STATE
// ================================================================
const API_URL = '';
const chatContainer = document.getElementById('chatContainer');
const chatWrapper = document.getElementById('chatWrapper');
const chatInput = document.getElementById('chatInput');
const sendButton = document.getElementById('sendButton');
let messages = [];
let isGenerating = false;
let isChatMode = false;
let currentTemperature = 0.8;
let currentTopK = 50;
// ================================================================
// TRANSITION: Landing → Chat
// ================================================================
function enterChatMode() {
if (isChatMode) return;
isChatMode = true;
// Slide samosa left, kettle right
document.getElementById('samosaIllust').classList.add('slide-out');
document.getElementById('kettleIllust').classList.add('slide-out');
// Fade out hero, doodles, footer
document.getElementById('heroSection').classList.add('fade-out');
document.getElementById('landingFooter').classList.add('fade-out');
document.querySelectorAll('.doodle').forEach(d => d.classList.add('fade-out'));
// Show chat area and new conversation button
chatContainer.classList.add('active');
document.getElementById('newConvBtn').classList.add('visible');
document.getElementById('inputContainer').classList.remove('landing-mode');
// Show inference badge
document.getElementById('inferenceMode').classList.add('visible');
}
function resetToLanding() {
if (!isChatMode) return;
isChatMode = false;
// Slide illustrations back
document.getElementById('samosaIllust').classList.remove('slide-out');
document.getElementById('kettleIllust').classList.remove('slide-out');
// Fade in hero, doodles, footer
document.getElementById('heroSection').classList.remove('fade-out');
document.getElementById('landingFooter').classList.remove('fade-out');
document.querySelectorAll('.doodle').forEach(d => d.classList.remove('fade-out'));
// Hide chat area
chatContainer.classList.remove('active');
document.getElementById('newConvBtn').classList.remove('visible');
document.getElementById('inferenceMode').classList.remove('visible');
document.getElementById('inputContainer').classList.add('landing-mode');
// Reset chat
messages = [];
chatWrapper.innerHTML = '';
chatInput.value = '';
chatInput.style.height = 'auto';
sendButton.disabled = false;
isGenerating = false;
chatInput.focus();
}
// ================================================================
// CHAT FUNCTIONALITY
// ================================================================
chatInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
sendButton.disabled = !this.value.trim() || isGenerating;
});
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
document.addEventListener('keydown', function(event) {
if (event.ctrlKey && event.shiftKey && event.key === 'N') {
event.preventDefault();
if (!isGenerating) newConversation();
}
});
function newConversation() {
messages = [];
chatWrapper.innerHTML = '';
chatInput.value = '';
chatInput.style.height = 'auto';
sendButton.disabled = false;
isGenerating = false;
chatInput.focus();
}
function addMessage(role, content, messageIndex = null) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${role}`;
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = content;
if (role === 'user' && messageIndex !== null) {
contentDiv.setAttribute('data-message-index', messageIndex);
contentDiv.setAttribute('title', 'Click to edit and restart from here');
contentDiv.addEventListener('click', function() { if (!isGenerating) editMessage(messageIndex); });
}
if (role === 'assistant' && messageIndex !== null) {
contentDiv.setAttribute('data-message-index', messageIndex);
contentDiv.setAttribute('title', 'Click to regenerate this response');
contentDiv.addEventListener('click', function() { if (!isGenerating) regenerateMessage(messageIndex); });
}
messageDiv.appendChild(contentDiv);
chatWrapper.appendChild(messageDiv);
chatContainer.scrollTop = chatContainer.scrollHeight;
return contentDiv;
}
function editMessage(messageIndex) {
if (messageIndex < 0 || messageIndex >= messages.length) return;
if (messages[messageIndex].role !== 'user') return;
chatInput.value = messages[messageIndex].content;
chatInput.style.height = 'auto';
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + 'px';
messages = messages.slice(0, messageIndex);
const allMessages = chatWrapper.querySelectorAll('.message');
for (let i = messageIndex; i < allMessages.length; i++) allMessages[i].remove();
sendButton.disabled = false;
chatInput.focus();
}
async function generateAssistantResponse() {
isGenerating = true;
sendButton.disabled = true;
const assistantContent = addMessage('assistant', '');
assistantContent.innerHTML = '<div class="steam-typing-wrap"><div class="st"></div><div class="st"></div><div class="st"></div><div class="st"></div></div>';
try {
if (window.samosaChaat._mode === 'local' && window.samosaChaat.generateLocal) {
let fullResponse = '';
assistantContent.textContent = '';
for await (const token of window.samosaChaat.generateLocal(messages)) {
fullResponse += token;
assistantContent.textContent = fullResponse;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
const idx = messages.length;
messages.push({ role: 'assistant', content: fullResponse });
assistantContent.setAttribute('data-message-index', idx);
assistantContent.setAttribute('title', 'Click to regenerate this response');
assistantContent.addEventListener('click', function() { if (!isGenerating) regenerateMessage(idx); });
} else {
const response = await fetch(`${API_URL}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, temperature: currentTemperature, top_k: currentTopK, max_tokens: 512 }),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = '';
assistantContent.textContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split('\n')) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.token) { fullResponse += data.token; assistantContent.textContent = fullResponse; chatContainer.scrollTop = chatContainer.scrollHeight; }
} catch (e) {}
}
}
}
const idx = messages.length;
messages.push({ role: 'assistant', content: fullResponse });
assistantContent.setAttribute('data-message-index', idx);
assistantContent.setAttribute('title', 'Click to regenerate this response');
assistantContent.addEventListener('click', function() { if (!isGenerating) regenerateMessage(idx); });
}
} catch (error) {
console.error('Error:', error);
assistantContent.innerHTML = `<div class="error-message">Error: ${error.message}</div>`;
} finally {
isGenerating = false;
sendButton.disabled = !chatInput.value.trim();
}
}
async function regenerateMessage(messageIndex) {
if (messageIndex < 0 || messageIndex >= messages.length) return;
if (messages[messageIndex].role !== 'assistant') return;
messages = messages.slice(0, messageIndex);
const allMessages = chatWrapper.querySelectorAll('.message');
for (let i = messageIndex; i < allMessages.length; i++) allMessages[i].remove();
await generateAssistantResponse();
}
function handleSlashCommand(command) {
const parts = command.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
const arg = parts[1];
if (cmd === '/temperature') {
if (arg === undefined) { addMessage('console', `Current temperature: ${currentTemperature}`); }
else { const t = parseFloat(arg); if (isNaN(t)||t<0||t>2) addMessage('console','Invalid temperature. Must be between 0.0 and 2.0'); else { currentTemperature=t; addMessage('console',`Temperature set to ${t}`); } }
return true;
} else if (cmd === '/topk') {
if (arg === undefined) { addMessage('console', `Current top-k: ${currentTopK}`); }
else { const k = parseInt(arg); if (isNaN(k)||k<1||k>200) addMessage('console','Invalid top-k. Must be between 1 and 200'); else { currentTopK=k; addMessage('console',`Top-k set to ${k}`); } }
return true;
} else if (cmd === '/clear') { newConversation(); return true; }
else if (cmd === '/help') {
addMessage('console', 'Available commands:\n/temperature [value] - Get/set temperature (0.0-2.0)\n/topk [value] - Get/set top-k (1-200)\n/clear - Clear conversation\n/help - Show this help');
return true;
}
return false;
}
async function sendMessage() {
const message = chatInput.value.trim();
if (!message || isGenerating) return;
// Enter chat mode on first message
if (!isChatMode) enterChatMode();
if (message.startsWith('/')) {
chatInput.value = ''; chatInput.style.height = 'auto';
handleSlashCommand(message);
return;
}
chatInput.value = ''; chatInput.style.height = 'auto';
const userMessageIndex = messages.length;
messages.push({ role: 'user', content: message });
addMessage('user', message, userMessageIndex);
await generateAssistantResponse();
}
sendButton.disabled = false;
// ================================================================
// WEBGPU INTEGRATION HOOKS
// ================================================================
window.samosaChaat = {
_mode: 'cloud',
setInferenceMode(mode) {
this._mode = mode;
const badge = document.getElementById('inferenceMode');
if (mode === 'local') { badge.textContent = 'Local GPU'; badge.classList.add('local'); }
else { badge.textContent = 'Cloud'; badge.classList.remove('local'); }
},
updateDownloadProgress(loaded, total) {
const banner = document.getElementById('downloadBanner');
const fill = document.getElementById('progressFill');
const pct = document.getElementById('downloadPercent');
const text = document.getElementById('downloadText');
banner.style.display = 'flex';
const percent = Math.round((loaded / total) * 100);
fill.style.width = percent + '%';
text.textContent = `Downloading model... ${(loaded/1048576).toFixed(0)}/${(total/1048576).toFixed(0)} MB`;
pct.textContent = `(${percent}%)`;
},
onModelReady() {
const banner = document.getElementById('downloadBanner');
document.getElementById('progressFill').style.width = '100%';
document.getElementById('downloadPercent').textContent = '';
document.getElementById('downloadText').innerHTML = '<span class="download-done">Model loaded on your GPU — responses are now local!</span>';
document.getElementById('downloadCancel').style.display = 'none';
this.setInferenceMode('local');
setTimeout(() => { banner.style.display = 'none'; }, 4000);
},
generateLocal: null,
_cancelDownload: null
};
// Health check
fetch(`${API_URL}/health`)
.then(r => r.json())
.then(data => console.log('Engine status:', data))
.catch(error => console.error('Engine not available:', error));
chatInput.focus();
</script>
</body>
</html>