fontend / index.html
wop's picture
Update index.html
925dea6 verified
Raw
History Blame Contribute Delete
72.3 kB
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<title>Cosmos T3 Chat — Shatter Edition</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #07080f;
--bg-main: #0b0d18;
--bg-assistant: rgba(255, 255, 255, 0.025);
--bg-input: rgba(17, 19, 33, 0.82);
--text-main: #eceef6;
--text-muted: #9da1b8;
--accent-color: #facc15;
--logo-yellow: #FACC15;
--user-accent-1: #8b5cf6;
--user-accent-2: #6d28d9;
--border-soft: rgba(255, 255, 255, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
::selection { background: rgba(250, 204, 21, 0.28); }
body {
background:
radial-gradient(1200px 800px at 82% -10%, rgba(139, 92, 246, 0.10), transparent 60%),
radial-gradient(1000px 700px at 8% 110%, rgba(250, 204, 21, 0.06), transparent 60%),
radial-gradient(900px 600px at 50% 45%, rgba(56, 78, 173, 0.08), transparent 65%),
linear-gradient(180deg, var(--bg-main), var(--bg-deep));
color: var(--text-main);
height: 100vh;
display: flex;
overflow: hidden;
}
/* ── Starfield ── */
.stars {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-repeat: repeat;
}
.stars-1 {
background-image:
radial-gradient(1px 1px at 25px 35px, rgba(255, 255, 255, 0.9), transparent),
radial-gradient(1px 1px at 120px 90px, rgba(255, 255, 255, 0.6), transparent),
radial-gradient(1.5px 1.5px at 200px 160px, rgba(250, 204, 21, 0.7), transparent),
radial-gradient(1px 1px at 80px 200px, rgba(255, 255, 255, 0.5), transparent),
radial-gradient(1px 1px at 170px 40px, rgba(186, 196, 255, 0.7), transparent);
background-size: 240px 240px;
opacity: 0.5;
animation: starDrift1 180s linear infinite, twinkle 7s ease-in-out infinite;
}
.stars-2 {
background-image:
radial-gradient(1px 1px at 60px 110px, rgba(255, 255, 255, 0.7), transparent),
radial-gradient(1.5px 1.5px at 300px 250px, rgba(255, 255, 255, 0.5), transparent),
radial-gradient(1px 1px at 200px 330px, rgba(186, 196, 255, 0.6), transparent),
radial-gradient(1px 1px at 340px 60px, rgba(250, 204, 21, 0.5), transparent);
background-size: 380px 380px;
opacity: 0.35;
animation: starDrift2 260s linear infinite, twinkle 9s ease-in-out infinite 2s;
}
@keyframes starDrift1 { from { background-position: 0 0; } to { background-position: -240px -480px; } }
@keyframes starDrift2 { from { background-position: 0 0; } to { background-position: -380px -760px; } }
@keyframes twinkle { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.5); } }
#main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
z-index: 1;
}
#chat-window {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
scroll-behavior: smooth;
padding-top: 60px; /* Space for floating header bar */
}
/* ── Header Bar ── */
.header-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 24px;
z-index: 100;
background: linear-gradient(to bottom, rgba(7, 8, 15, 0.85) 50%, transparent);
pointer-events: none; /* Let clicks pass through except on child buttons */
}
.header-btn {
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
border: 1px solid var(--border-soft);
border-radius: 10px;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
pointer-events: auto; /* Enable clicks on the button */
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.header-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(250, 204, 21, 0.35);
color: var(--text-main);
box-shadow: 0 0 18px -6px rgba(250, 204, 21, 0.35);
}
.header-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.header-btn svg {
width: 14px;
height: 14px;
}
/* ── Empty State ── */
.empty-state {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
}
.empty-state svg {
width: 84px;
height: auto;
margin-bottom: 20px;
opacity: 0.85;
}
.empty-state h1 {
font-size: 46px;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 12px;
background: linear-gradient(100deg, #fff 10%, var(--logo-yellow) 45%, #f59e0b 55%, #fff 90%);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: titleShimmer 6s linear infinite;
}
@keyframes titleShimmer { to { background-position: 200% center; } }
.empty-sub {
color: var(--text-muted);
font-size: 15px;
margin-bottom: 40px;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
max-width: 800px;
width: 100%;
}
.grid-card {
position: relative;
padding: 18px;
border: 1px solid var(--border-soft);
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.015));
text-align: left;
cursor: pointer;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
.grid-card:hover {
transform: translateY(-3px);
border-color: rgba(250, 204, 21, 0.35);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03));
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6), 0 0 24px -6px rgba(250, 204, 21, 0.18);
}
.card-icon { font-size: 20px; display: block; margin-bottom: 10px; }
.grid-card .title { font-weight: 600; margin-bottom: 6px; }
.grid-card .desc { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
/* ── Messages ── */
.message {
padding: 26px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
width: 100%;
animation: fadeIn 0.35s ease-out;
}
.message.assistant {
background: var(--bg-assistant);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.message.user { background: transparent; }
.message-content {
width: 100%;
display: flex;
gap: 16px;
max-width: 800px;
margin: 0 auto;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Message Actions (embedded in last assistant message) ── */
.message-actions {
display: flex;
gap: 10px;
margin-top: 12px;
animation: fadeIn 0.2s ease-out;
}
.msg-action-btn {
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.msg-action-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(250, 204, 21, 0.35);
color: var(--text-main);
}
.msg-action-btn svg {
width: 12px;
height: 12px;
stroke: currentColor;
}
/* ── Avatars ── */
.avatar {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: white;
}
.avatar.assistant {
background-color: transparent;
padding: 4px;
scale: 1.35;
transition: transform 2s cubic-bezier(0.4, 0, 0.2, 1);
}
.avatar.user {
background: linear-gradient(135deg, var(--user-accent-1), var(--user-accent-2));
box-shadow: 0 4px 14px -4px rgba(139, 92, 246, 0.5);
}
.user-icon { width: 20px; height: 20px; }
/* ── SVG Base / Layout ── */
.avatar.assistant svg {
width: 100%;
height: 100%;
}
.avatar.assistant svg,
.empty-state svg {
filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4));
transform-box: fill-box;
transform-origin: center;
will-change: transform;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* keep these color rules */
.avatar.assistant svg path,
.empty-state svg path {
stroke: var(--logo-yellow);
}
.avatar.assistant svg g path[fill="#000"],
.empty-state svg g path[fill="#000"] {
fill: var(--logo-yellow);
}
/* ── Click flash on the whole SVG (glow only — the layers do the motion) ── */
.avatar.assistant svg.clicked,
.empty-state svg.clicked {
animation: svgGlow 460ms ease-out;
}
@keyframes svgGlow {
0% { filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4)); }
25% { filter: drop-shadow(0 0 24px rgba(234, 179, 8, 1)); }
100% { filter: drop-shadow(0 0 8px rgba(234, 179, 8, 0.4)); }
}
/* ── Layer base transform setup (rotate around the SVG's true center) ── */
.svg-layer-outer-1,
.svg-layer-outer-2,
.svg-layer-inner {
transform-box: view-box;
transform-origin: center;
will-change: transform, rotate, scale;
}
/* ── SVG Layer Animations (idle) — spin via `rotate`, breathe via `scale`/opacity.
Keeping spin and pulse on SEPARATE properties lets every layer spin AND pulse
at once, and lets the click burst (a `transform`) ride on top of the spin. ── */
.svg-layer-outer-1 {
animation: spinCW 20s linear infinite, pulseOuter1 5s ease-in-out infinite;
}
.svg-layer-outer-2 {
animation: spinCCW 25s linear infinite, breatheOuter2 6s ease-in-out infinite 1s;
}
.svg-layer-inner {
animation: spinCW 15s linear infinite, breatheInner 4s ease-in-out infinite 0.5s;
}
@keyframes spinCW { from { rotate: 0deg; } to { rotate: 360deg; } }
@keyframes spinCCW { from { rotate: 0deg; } to { rotate: -360deg; } }
@keyframes pulseOuter1 { 0%, 100% { opacity: 1; scale: 1; } 50% { opacity: 0.65; scale: 1.04; } }
@keyframes breatheOuter2 { 0%, 100% { scale: 1; } 50% { scale: 1.06; } }
@keyframes breatheInner { 0%, 100% { scale: 1; } 50% { scale: 1.15; } }
/* ── SVG Layer Animations (typing / generating) ── */
.avatar.typing .svg-layer-outer-1 {
animation: spinCW 6s linear infinite, pulseOuter1 2s ease-in-out infinite;
}
.avatar.typing .svg-layer-outer-2 {
animation: spinCCW 8s linear infinite, breatheOuter2 2.5s ease-in-out infinite 0.3s;
}
.avatar.typing .svg-layer-inner {
animation: spinCW 4s linear infinite, breatheInner 1.5s ease-in-out infinite 0.2s;
}
/* ── Text Area ── */
.text-area {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.role-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
opacity: 0.9;
}
.message.assistant .role-label {
color: var(--logo-yellow);
opacity: 0.75;
}
.content-text {
max-width: 100%;
line-height: 1.65;
white-space: pre-wrap;
word-wrap: break-word;
}
/* ── Streaming Cursor ── */
.content-text.streaming::after {
content: '▋';
display: inline-block;
animation: blink 1s steps(2) infinite;
margin-left: 1px;
color: var(--logo-yellow);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ── Input Area ── */
.input-container {
padding: 20px 24px 16px;
background: linear-gradient(transparent, rgba(11, 13, 24, 0.92) 40%);
position: relative;
z-index: 2;
}
.input-wrapper {
width: 100%;
position: relative;
max-width: 800px;
margin: 0 auto;
}
textarea {
width: 100%;
background: var(--bg-input);
color: var(--text-main);
border: 1px solid var(--border-soft);
border-radius: 16px;
padding: 14px 54px 14px 18px;
resize: none;
outline: none;
font-size: 16px;
font-family: inherit;
box-shadow: 0 12px 32px -12px rgba(0, 0, 0, 0.6);
min-height: 52px;
max-height: 200px;
overflow-y: auto;
caret-color: var(--logo-yellow);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
transition: border-color 0.2s, box-shadow 0.2s;
}
textarea:focus {
border-color: rgba(250, 204, 21, 0.45);
box-shadow: 0 0 0 3px rgba(250, 204, 21, 0.12), 0 12px 32px -12px rgba(0, 0, 0, 0.6);
}
textarea::placeholder { color: #6f7390; }
.send-btn {
position: absolute;
right: 10px;
bottom: 10px;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 10px;
transition: background 0.2s, color 0.2s, transform 0.15s, box-shadow 0.2s;
}
.send-btn:not(:disabled):hover {
background: var(--logo-yellow);
color: #1a1505;
transform: scale(1.06);
box-shadow: 0 4px 16px -2px rgba(250, 204, 21, 0.5);
}
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.send-btn.stop-btn {
color: #f87171;
background: rgba(239, 68, 68, 0.12);
}
.send-btn.stop-btn:hover {
background: rgba(239, 68, 68, 0.25) !important;
color: #fca5a5 !important;
box-shadow: 0 4px 16px -2px rgba(239, 68, 68, 0.4) !important;
}
.footer-note {
text-align: center;
font-size: 11px;
letter-spacing: 0.06em;
color: #6b6f85;
margin-top: 12px;
}
/* ── Typing Indicator ── */
.typing-dots {
display: flex;
gap: 4px;
padding: 12px 0;
align-items: center;
}
.dot {
width: 8px;
height: 8px;
background: var(--logo-yellow);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1.0); }
}
/* ── Error Toast ── */
.error-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(220, 38, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
padding: 12px 24px;
border-radius: 10px;
font-size: 14px;
z-index: 1000;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: toastIn 0.3s ease-out, toastOut 0.3s ease-in 3.7s forwards;
}
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* ── Scrollbar ── */
#chat-window::-webkit-scrollbar { width: 8px; }
#chat-window::-webkit-scrollbar-track { background: transparent; }
#chat-window::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
#chat-window::-webkit-scrollbar-thumb:hover { background: rgba(250, 204, 21, 0.3); }
/* ── Responsive ── */
@media (max-width: 600px) {
.grid-container { grid-template-columns: 1fr; }
.message { padding: 16px 12px; }
.input-container { padding: 16px 12px; }
.empty-state h1 { font-size: 30px; }
.message-actions {
flex-wrap: wrap;
gap: 8px;
}
.msg-action-btn {
padding: 4px 8px;
font-size: 11px;
flex: 1 1 auto;
justify-content: center;
}
}
/* ── Loading Screen ── */
#loading-screen {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 26px;
background: radial-gradient(circle at 50% 42%, #13152a 0%, var(--bg-deep) 70%);
transition: opacity 0.6s ease, visibility 0.6s ease;
}
#loading-screen.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.loading-logo {
width: 130px;
height: 130px;
animation: loaderGlow 2.4s ease-in-out infinite;
}
.loading-logo svg {
width: 100%;
height: 100%;
}
.loading-logo svg path { stroke: var(--logo-yellow); }
.loading-logo svg g path[fill="#000"] { fill: var(--logo-yellow); }
@keyframes loaderGlow {
0%, 100% { filter: drop-shadow(0 0 14px rgba(234, 179, 8, 0.45)); }
50% { filter: drop-shadow(0 0 32px rgba(234, 179, 8, 0.85)); }
}
.loading-title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--text-main);
animation: loaderTitlePulse 2.4s ease-in-out infinite;
}
@keyframes loaderTitlePulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.9; }
}
.loading-bar {
position: relative;
width: 180px;
height: 3px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.loading-bar::after {
content: '';
position: absolute;
top: 0;
left: -40%;
width: 40%;
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--logo-yellow), transparent);
animation: loaderSlide 1.15s ease-in-out infinite;
}
@keyframes loaderSlide {
0% { left: -40%; }
100% { left: 100%; }
}
/* ── Glass Shatter Effect ── */
#glass-pane {
position: fixed;
inset: 0;
z-index: 1500;
background:
linear-gradient(115deg,
rgba(255, 255, 255, 0.10) 0%,
rgba(255, 255, 255, 0.02) 28%,
rgba(255, 255, 255, 0.09) 42%,
rgba(255, 255, 255, 0.02) 56%,
rgba(255, 255, 255, 0.06) 100%);
backdrop-filter: blur(1.5px) brightness(1.05);
-webkit-backdrop-filter: blur(1.5px) brightness(1.05);
opacity: 0;
transition: opacity 0.45s ease;
cursor: crosshair;
}
#glass-pane.on { opacity: 1; }
#glass-pane.broken {
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
cursor: default;
pointer-events: none;
}
.glass-hint {
position: absolute;
left: 50%;
bottom: 12%;
transform: translateX(-50%);
font-size: 13px;
letter-spacing: 0.25em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.65);
text-shadow: 0 0 12px rgba(255, 255, 255, 0.4);
animation: hintPulse 2.2s ease-in-out infinite;
pointer-events: none;
white-space: nowrap;
}
@keyframes hintPulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.9; }
}
.glass-shard {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1501;
will-change: transform, opacity;
}
.glass-flash {
position: fixed;
inset: 0;
z-index: 1502;
pointer-events: none;
}
#shatter-btn {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1400;
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-soft);
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
}
#shatter-btn:hover {
transform: scale(1.08);
border-color: rgba(250, 204, 21, 0.4);
box-shadow: 0 0 18px -4px rgba(250, 204, 21, 0.4);
}
</style>
</head>
<body>
<!-- ── Animated starfield background ── -->
<div class="stars stars-1"></div>
<div class="stars stars-2"></div>
<!-- ── Loading splash: spinning logo shown until the app is ready ── -->
<div id="loading-screen">
<div class="loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.217 108.55">
<defs>
<clipPath id="loaderClip"><path fill="none" d="M0 0h116.217v108.55H0z"/></clipPath>
</defs>
<g clip-path="url(#loaderClip)" fill="none" stroke-width="none" stroke-miterlimit="10"
font-family="none" font-weight="none" font-size="none"
text-anchor="none" style="mix-blend-mode:normal">
<g class="svg-layer-outer-1">
<path d="M40.9 92.92c-5.487-1.788-6.775-9.344-10.164-14.014-4.894-6.742-12.956-12.074-14.684-20.224-1.196-5.643 4.697-10.537 7.045-15.806 3.39-7.608 3.974-17.256 10.167-22.827 4.29-3.857 11.475-1.199 17.212-1.8 8.284-.868 16.931-5.187 24.851-2.608 5.487 1.787 6.776 9.343 10.165 14.013 4.89 6.74 12.948 12.07 14.673 20.217 1.194 5.643-4.7 10.536-7.048 15.805-3.39 7.609-3.974 17.257-10.167 22.827-4.3 3.867-34.124 6.997-42.05 4.416z" stroke="#000" stroke-width="8"/>
</g>
<g class="svg-layer-outer-2">
<path d="M77.404 104.55c-6.547 3.33-14.509-2.311-21.763-3.464-10.474-1.664-22.531.793-31.422-4.988-6.156-4.002-5.257-13.713-7.887-20.568-3.797-9.9-11.955-19.111-11.395-29.7.388-7.333 9.25-11.408 13.873-17.114C25.485 20.478 29.38 8.806 38.832 4 45.379.67 53.34 6.31 60.595 7.464c10.468 1.664 22.52-.79 31.405 4.99 6.156 4.005 5.255 13.715 7.884 20.571 3.797 9.9 11.956 19.11 11.395 29.7-.389 7.35-24.417 37.014-33.875 41.825z" stroke="#000" stroke-width="8"/>
</g>
<g class="svg-layer-inner">
<path d="M35.33 63.017c-1.51-2.966 1.047-6.573 1.57-9.86.753-4.746-.36-10.209 2.259-14.237 1.813-2.79 6.213-2.382 9.32-3.573 4.485-1.72 8.658-5.417 13.456-5.163 3.322.176 5.169 4.19 7.754 6.285 3.732 3.025 9.02 4.79 11.198 9.072 1.509 2.967-1.047 6.574-1.57 9.86-.753 4.744.359 10.204-2.26 14.23-1.815 2.789-6.214 2.38-9.32 3.572-4.486 1.72-8.66 5.417-13.457 5.163-3.33-.176-16.77-11.063-18.95-15.349" fill="#000"/>
</g>
</g>
</svg>
</div>
<div class="loading-title">Cosmos T3</div>
<div class="loading-bar"></div>
</div>
<main id="main">
<!-- Floating Header Bar containing the Clear Chat button -->
<div id="header-bar" class="header-bar" style="display: none;">
<button
id="clear-btn"
class="header-btn"
onclick="onClearClick()"
aria-label="Clear chat"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
Clear Chat
</button>
</div>
<div id="chat-window"></div>
<div class="input-container">
<div class="input-wrapper">
<textarea
id="user-input"
rows="1"
placeholder="Send a message..."
aria-label="Chat input"
></textarea>
<button
id="send-btn"
class="send-btn"
onclick="onSendClick()"
aria-label="Send message"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
<div class="footer-note">
Powered by Cosmos T3 BASE
</div>
</div>
</main>
<script>
// ════════════════════════════════════════════
// Backend configuration (Gradio Space API)
// ════════════════════════════════════════════
// The static frontend talks to the Gradio backend Space.
// Gradio exposes a stateless two-step REST per fn:
// 1) POST {BASE}/call/{FN} with {"data": [...]} -> { "event_id": "..." }
// 2) GET {BASE}/call/{FN}/{event_id} -> SSE stream
// The SSE emits:
// event: generating data: ["<full growing text>", null]
// event: heartbeat (keepalive, ignored)
// event: complete data: ["<final full text>", null]
// Note: each "generating" data carries the FULL snapshot, not a delta.
const BACKEND_BASE = 'https://wop-server-backend.hf.space/gradio_api';
const CHAT_FN = 'chat'; // single self-contained streaming fn: inputs [message, state]
// ────────────────────────────────────────────
// SVG Templates
// ────────────────────────────────────────────
const modelLogoSvg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.217 108.55">
<defs>
<clipPath id="a"><path fill="none" d="M0 0h116.217v108.55H0z"/></clipPath>
</defs>
<g clip-path="url(#a)" fill="none" stroke-width="none" stroke-miterlimit="10"
font-family="none" font-weight="none" font-size="none"
text-anchor="none" style="mix-blend-mode:normal">
<g class="svg-layer-outer-1">
<path d="M40.9 92.92c-5.487-1.788-6.775-9.344-10.164-14.014-4.894-6.742-12.956-12.074-14.684-20.224-1.196-5.643 4.697-10.537 7.045-15.806 3.39-7.608 3.974-17.256 10.167-22.827 4.29-3.857 11.475-1.199 17.212-1.8 8.284-.868 16.931-5.187 24.851-2.608 5.487 1.787 6.776 9.343 10.165 14.013 4.89 6.74 12.948 12.07 14.673 20.217 1.194 5.643-4.7 10.536-7.048 15.805-3.39 7.609-3.974 17.257-10.167 22.827-4.3 3.867-34.124 6.997-42.05 4.416z"
stroke="#000" stroke-width="8"/>
</g>
<g class="svg-layer-outer-2">
<path d="M77.404 104.55c-6.547 3.33-14.509-2.311-21.763-3.464-10.474-1.664-22.531.793-31.422-4.988-6.156-4.002-5.257-13.713-7.887-20.568-3.797-9.9-11.955-19.111-11.395-29.7.388-7.333 9.25-11.408 13.873-17.114C25.485 20.478 29.38 8.806 38.832 4 45.379.67 53.34 6.31 60.595 7.464c10.468 1.664 22.52-.79 31.405 4.99 6.156 4.005 5.255 13.715 7.884 20.571 3.797 9.9 11.956 19.11 11.395 29.7-.389 7.35-24.417 37.014-33.875 41.825z"
stroke="#000" stroke-width="8"/>
</g>
<g class="svg-layer-inner">
<path d="M35.33 63.017c-1.51-2.966 1.047-6.573 1.57-9.86.753-4.746-.36-10.209 2.259-14.237 1.813-2.79 6.213-2.382 9.32-3.573 4.485-1.72 8.658-5.417 13.456-5.163 3.322.176 5.169 4.19 7.754 6.285 3.732 3.025 9.02 4.79 11.198 9.072 1.509 2.967-1.047 6.574-1.57 9.86-.753 4.744.359 10.204-2.26 14.23-1.815 2.789-6.214 2.38-9.32 3.572-4.486 1.72-8.66 5.417-13.457 5.163-3.33-.176-16.77-11.063-18.95-15.349"
fill="#000"/>
</g>
</g>
</svg>`;
const userIconSvg = `
<svg class="user-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>`;
const sendIconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>`;
const stopIconSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>`;
// ────────────────────────────────────────────
// Session / State
// ────────────────────────────────────────────
const SESSION_KEY = 'lfm_session_id';
let sessionId = localStorage.getItem(SESSION_KEY);
if (!sessionId) {
sessionId = (crypto.randomUUID && crypto.randomUUID()) ||
(Date.now().toString(36) + Math.random().toString(36).slice(2));
localStorage.setItem(SESSION_KEY, sessionId);
}
let messages = [];
let isStreaming = false;
let abortController = null;
let activeAssistantMsg = null;
let activeAssistantContentEl = null;
let activeAvatarEl = null;
const chatWindowEl = document.getElementById('chat-window');
const userInputEl = document.getElementById('user-input');
const sendBtnEl = document.getElementById('send-btn');
// ────────────────────────────────────────────
// Utilities
// ────────────────────────────────────────────
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text ?? '';
return div.innerHTML;
}
function scrollToBottom() {
requestAnimationFrame(() => {
chatWindowEl.scrollTop = chatWindowEl.scrollHeight;
});
}
function showErrorToast(message) {
const existing = document.querySelector('.error-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'error-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
function setSendButton(mode) {
if (mode === 'stop') {
sendBtnEl.innerHTML = stopIconSvg;
sendBtnEl.classList.add('stop-btn');
sendBtnEl.disabled = false;
sendBtnEl.setAttribute('aria-label', 'Stop generating');
} else {
sendBtnEl.innerHTML = sendIconSvg;
sendBtnEl.classList.remove('stop-btn');
sendBtnEl.disabled = false;
sendBtnEl.setAttribute('aria-label', 'Send message');
}
}
function normalizeRole(role) {
return role === 'user' || role === 'assistant' || role === 'system' ? role : null;
}
// Filled when template card is clicked
function fillPrompt(text) {
userInputEl.value = text;
autoResizeTextarea();
}
// ────────────────────────────────────────────
// Header Controller (for Clear Chat)
// ────────────────────────────────────────────
function updateHeader() {
const headerBar = document.getElementById('header-bar');
const clearBtn = document.getElementById('clear-btn');
if (messages.length === 0) {
headerBar.style.display = 'none';
return;
}
headerBar.style.display = 'flex';
clearBtn.disabled = isStreaming;
}
// ────────────────────────────────────────────
// Rendering
// ────────────────────────────────────────────
function renderMessages() {
chatWindowEl.innerHTML = '';
if (messages.length === 0) {
chatWindowEl.innerHTML = `
<div class="empty-state">
${modelLogoSvg}
<h1>Cosmos T3</h1>
<div class="empty-sub">Ask anything. Explore everything.</div>
<div class="grid-container">
<div class="grid-card" onclick="fillPrompt('Explain quantum computing in simple terms')">
<span class="card-icon">💡</span>
<div class="title">Examples</div>
<div class="desc">"Explain quantum computing in simple terms"</div>
</div>
<div class="grid-card" onclick="fillPrompt('Write a short creative poem about the ocean')">
<span class="card-icon">✨</span>
<div class="title">Creative</div>
<div class="desc">"Write a short poem about the ocean"</div>
</div>
<div class="grid-card" onclick="fillPrompt('What is the meaning of life?')">
<span class="card-icon">🌌</span>
<div class="title">Philosophy</div>
<div class="desc">"What is the meaning of life?"</div>
</div>
</div>
</div>
`;
updateHeader();
return;
}
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = normalizeRole(msg.role);
if (!role) continue;
const isLastMessage = (i === messages.length - 1);
const isLastAssistantMessage = isLastMessage && role === 'assistant';
const msgDiv = document.createElement('div');
msgDiv.className = `message ${role}`;
const avatarContent = role === 'assistant' ? modelLogoSvg : userIconSvg;
const roleLabel = role === 'assistant' ? 'Cosmos T3' : (role === 'system' ? 'System' : 'You');
// Render action buttons directly inside the last assistant message
let actionsHtml = '';
if (isLastAssistantMessage && !isStreaming) {
const hasUserMsg = messages.some(m => m.role === 'user');
actionsHtml = `
<div class="message-actions">
${hasUserMsg ? `
<button
class="msg-action-btn"
onclick="onRegenerateClick()"
aria-label="Regenerate response"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Regenerate
</button>
` : ''}
<button
class="msg-action-btn"
onclick="onContinueClick()"
aria-label="Continue generating"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
Continue Generating
</button>
</div>
`;
}
msgDiv.innerHTML = `
<div class="message-content">
<div class="avatar ${role}">${avatarContent}</div>
<div class="text-area">
<div class="role-label">${roleLabel}</div>
<div class="content-text">${escapeHtml(msg.content)}</div>
${actionsHtml}
</div>
</div>
`;
chatWindowEl.appendChild(msgDiv);
}
scrollToBottom();
updateHeader();
}
function showTypingIndicator() {
removeTypingIndicator();
const indicator = document.createElement('div');
indicator.className = 'message assistant';
indicator.id = 'typing-indicator';
indicator.innerHTML = `
<div class="message-content">
<div class="avatar assistant typing">${modelLogoSvg}</div>
<div class="text-area">
<div class="role-label">Cosmos T3</div>
<div class="typing-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
</div>
`;
chatWindowEl.appendChild(indicator);
scrollToBottom();
}
function removeTypingIndicator() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
function createAssistantPlaceholder() {
const assistantMsg = { role: 'assistant', content: '' };
messages.push(assistantMsg);
renderMessages();
const contentEls = chatWindowEl.querySelectorAll('.content-text');
const lastContentEl = contentEls[contentEls.length - 1];
const lastMsgEl = lastContentEl ? lastContentEl.closest('.message') : null;
const avatarEl = lastMsgEl ? lastMsgEl.querySelector('.avatar.assistant') : null;
if (lastContentEl) lastContentEl.classList.add('streaming');
if (avatarEl) avatarEl.classList.add('typing');
activeAssistantMsg = assistantMsg;
activeAssistantContentEl = lastContentEl;
activeAvatarEl = avatarEl;
}
function clearAssistantStreamingUI() {
if (activeAssistantContentEl) {
activeAssistantContentEl.classList.remove('streaming');
}
if (activeAvatarEl) {
activeAvatarEl.classList.remove('typing');
}
activeAssistantContentEl = null;
activeAvatarEl = null;
}
// Set the assistant's full content (the backend streams full snapshots).
function setAssistantContent(fullText) {
if (activeAssistantMsg) {
activeAssistantMsg.content = fullText;
}
if (activeAssistantContentEl) {
activeAssistantContentEl.textContent = fullText;
scrollToBottom();
}
}
// ────────────────────────────────────────────
// SSE helpers (Gradio "event:/data:" blocks)
// ────────────────────────────────────────────
function parseSSEEvents(buffer) {
const events = buffer.split('\n\n');
const rest = events.pop();
return { events, rest };
}
// Parse one SSE block into { event, data } where data is the joined data: payload.
function parseEventBlock(block) {
const lines = block.split('\n');
let eventName = 'message';
const dataLines = [];
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
return { event: eventName, data: dataLines.length ? dataLines.join('\n') : null };
}
// Gradio "chat" fn outputs ["<full text>", null]. Pull the text out robustly.
function extractTextFromGradioData(parsed) {
if (parsed == null) return null;
if (typeof parsed === 'string') return parsed;
if (Array.isArray(parsed)) {
const first = parsed[0];
if (typeof first === 'string') return first;
// ChatMessage-style object fallback
if (first && typeof first === 'object') {
if (typeof first.content === 'string') return first.content;
if (Array.isArray(first.content)) {
return first.content
.map(c => (c && typeof c.text === 'string') ? c.text : '')
.join('');
}
}
return null;
}
if (typeof parsed === 'object') {
if (typeof parsed.content === 'string') return parsed.content;
}
return null;
}
async function readErrorBody(response) {
try {
const ct = response.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const j = await response.json();
return j.error || JSON.stringify(j);
}
return await response.text();
} catch {
return `HTTP ${response.status}`;
}
}
// ════════════════════════════════════════════
// Backend call: Gradio two-step streaming
// ════════════════════════════════════════════
// Step 1: enqueue -> event_id ; Step 2: open SSE and feed onToken(fullText).
async function streamGradioChat({ message, history, onFullText, signal }) {
// Step 1 — POST to enqueue. Returns { event_id }.
const joinRes = await fetch(`${BACKEND_BASE}/call/${CHAT_FN}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// chat fn signature: [message, state]. We pass null for state
// (the backend manages its own conversation context).
// NOTE: do NOT send session_hash here — with the stateless
// /call/{fn} REST it routes the event into a session queue that
// the GET /call/{fn}/{event_id} stream cannot drain (hangs/404).
data: [message, null]
}),
signal
});
if (!joinRes.ok) {
throw new Error(await readErrorBody(joinRes) || `Server error: HTTP ${joinRes.status}`);
}
const joinJson = await joinRes.json();
const eventId = joinJson.event_id || joinJson.eventId;
if (!eventId) {
throw new Error('Backend did not return an event_id.');
}
// Step 2 — open the SSE stream for this event.
const streamRes = await fetch(`${BACKEND_BASE}/call/${CHAT_FN}/${eventId}`, {
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
signal
});
if (!streamRes.ok || !streamRes.body) {
throw new Error(await readErrorBody(streamRes) || `Stream error: HTTP ${streamRes.status}`);
}
const reader = streamRes.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finished = false;
let lastText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSSEEvents(buffer);
buffer = parsed.rest;
for (const block of parsed.events) {
if (!block.trim()) continue;
const { event, data } = parseEventBlock(block);
// Keepalive — ignore.
if (event === 'heartbeat') continue;
// Backend-signalled error.
if (event === 'error') {
let msg = data || 'Unknown backend error';
try {
const j = JSON.parse(data);
msg = (typeof j === 'string') ? j : (j.error || JSON.stringify(j));
} catch { /* keep raw */ }
throw new Error(msg);
}
if (!data) continue;
let payload;
try {
payload = JSON.parse(data);
} catch {
// Non-JSON data line; skip.
continue;
}
const text = extractTextFromGradioData(payload);
if (event === 'generating') {
if (typeof text === 'string') {
lastText = text;
onFullText(text);
}
} else if (event === 'complete') {
if (typeof text === 'string') {
lastText = text;
onFullText(text);
}
finished = true;
}
}
}
return { finished, lastText };
}
// ────────────────────────────────────────────
// Main send / stop / regenerate / continue / clear logic
// ────────────────────────────────────────────
function onSendClick() {
if (isStreaming && abortController) {
abortController.abort();
return;
}
handleSend();
}
function onClearClick() {
if (isStreaming && abortController) {
abortController.abort();
}
messages = [];
renderMessages();
}
function onRegenerateClick() {
if (isStreaming || messages.length === 0) return;
// Find last assistant message and remove it if it exists
const lastMsg = messages[messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
messages.pop();
}
// Get the new last message, which must be from the user
const newLastMsg = messages[messages.length - 1];
if (!newLastMsg || newLastMsg.role !== 'user') {
renderMessages();
return;
}
const text = newLastMsg.content;
executeChatStream(text, true);
}
function onContinueClick() {
if (isStreaming || messages.length === 0) return;
// Verify the last message is from the assistant
const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.role !== 'assistant') {
return;
}
executeChatStream("Continue", false);
}
async function handleSend() {
const text = userInputEl.value.trim();
if (!text || isStreaming) return;
executeChatStream(text, false);
}
async function executeChatStream(text, isRegenerate = false) {
if (isStreaming) return;
isStreaming = true;
setSendButton('stop');
updateHeader();
if (!isRegenerate) {
const userMsg = { role: 'user', content: text };
messages.push(userMsg);
userInputEl.value = '';
userInputEl.style.height = 'auto';
}
renderMessages();
showTypingIndicator();
// History snapshot (excludes the just-added/current user turn).
const history = messages
.filter(m => ['user', 'assistant', 'system'].includes(m.role))
.slice(0, -1)
.map(m => ({ role: m.role, content: String(m.content ?? '') }));
abortController = new AbortController();
let placeholderCreated = false;
try {
const result = await streamGradioChat({
message: text,
history,
signal: abortController.signal,
onFullText: (fullText) => {
if (!placeholderCreated) {
removeTypingIndicator();
createAssistantPlaceholder();
placeholderCreated = true;
}
setAssistantContent(fullText);
}
});
removeTypingIndicator();
// No tokens ever arrived — make a placeholder so we can show a fallback.
if (!placeholderCreated) {
createAssistantPlaceholder();
placeholderCreated = true;
}
clearAssistantStreamingUI();
if (activeAssistantMsg) {
activeAssistantMsg.content = (activeAssistantMsg.content || '').trim();
if (!activeAssistantMsg.content) {
activeAssistantMsg.content = "I'm not sure how to respond to that.";
}
}
} catch (error) {
removeTypingIndicator();
if (error.name === 'AbortError') {
clearAssistantStreamingUI();
if (activeAssistantMsg) {
activeAssistantMsg.content = (activeAssistantMsg.content || '').trim();
if (!activeAssistantMsg.content) {
activeAssistantMsg.content = '(Generation stopped)';
} else {
activeAssistantMsg.content += ' ⏹';
}
}
} else {
console.error('Stream error:', error);
showErrorToast(error.message || 'Unknown error');
clearAssistantStreamingUI();
if (activeAssistantMsg && !activeAssistantMsg.content.trim()) {
activeAssistantMsg.content = '⚠️ Sorry, something went wrong: ' + (error.message || 'Unknown error');
} else if (!activeAssistantMsg) {
messages.push({
role: 'assistant',
content: '⚠️ Sorry, something went wrong: ' + (error.message || 'Unknown error')
});
}
}
} finally {
isStreaming = false;
abortController = null;
activeAssistantMsg = null;
clearAssistantStreamingUI();
setSendButton('send');
renderMessages(); // This now renders with isStreaming = false, so the buttons WILL show!
}
}
// ────────────────────────────────────────────
// Textarea Auto-Resize
// ────────────────────────────────────────────
function autoResizeTextarea() {
userInputEl.style.height = 'auto';
const newHeight = Math.min(userInputEl.scrollHeight, 200);
userInputEl.style.height = newHeight + 'px';
}
userInputEl.addEventListener('input', autoResizeTextarea);
// ────────────────────────────────────────────
// Enter to Send
// ────────────────────────────────────────────
userInputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSendClick();
}
});
// ────────────────────────────────────────────
// Init
// ────────────────────────────────────────────
renderMessages();
setSendButton('send');
// ── Loading screen: show the spinning logo briefly, then reveal the app ──
(function initLoadingScreen() {
const loader = document.getElementById('loading-screen');
if (!loader) return;
const MIN_VISIBLE_MS = 1400;
const start = performance.now();
function hide() {
const wait = Math.max(0, MIN_VISIBLE_MS - (performance.now() - start));
setTimeout(() => {
loader.classList.add('hidden');
loader.addEventListener('transitionend', () => loader.remove(), { once: true });
}, wait);
}
if (document.readyState === 'complete') hide();
else window.addEventListener('load', hide);
})();
const SVG_CLICK_SELECTOR = '.avatar.assistant svg, .empty-state svg';
function restartClassAnimation(el, className) {
el.classList.remove(className);
void el.getBoundingClientRect();
el.classList.add(className);
}
function animateSvg(svg) {
// Whole-logo glow flash (CSS class restart).
restartClassAnimation(svg, 'clicked');
// Staggered scale-punch rippling outer -> inner. We animate `transform`,
// which is independent of the `rotate`/`scale` properties driving the idle
// spin/breathe, so the layers keep spinning straight through the burst.
const layers = svg.querySelectorAll(
'.svg-layer-outer-1, .svg-layer-outer-2, .svg-layer-inner'
);
layers.forEach((layer, i) => {
layer.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(0.8)', offset: 0.35 },
{ transform: 'scale(1.14)', offset: 0.7 },
{ transform: 'scale(1)' }
],
{
duration: 420,
delay: i * 80,
easing: 'cubic-bezier(.2,.8,.2,1)',
fill: 'none'
}
);
});
}
document.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
const target = e.target instanceof Element ? e.target : null;
if (!target) return;
const svg = target.closest(SVG_CLICK_SELECTOR);
if (!svg) return;
animateSvg(svg);
});
</script>
<script>
// ════════════════════════════════════════════
// Glass Shatter Effect (Shatter Edition)
// ════════════════════════════════════════════
// The app loads behind a pane of glass. The first click cracks it from
// the impact point and blows the shards out of the screen. The 🔨 button
// in the corner replays the effect any time.
(function glassShatter() {
let pane = null;
let breaking = false;
function spawnPane(showHint) {
if (pane) return;
pane = document.createElement('div');
pane.id = 'glass-pane';
if (showHint) {
const hint = document.createElement('div');
hint.className = 'glass-hint';
hint.textContent = 'Click anywhere to shatter';
pane.appendChild(hint);
}
document.body.appendChild(pane);
requestAnimationFrame(() => requestAnimationFrame(() => pane.classList.add('on')));
pane.addEventListener('pointerdown', (e) => shatterAt(e.clientX, e.clientY));
}
// Synthesized crash: a filtered noise burst plus a few glassy pings.
function playShatterSound() {
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return;
const ctx = new Ctx();
const now = ctx.currentTime;
const len = Math.floor(ctx.sampleRate * 0.45);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.2);
}
const noise = ctx.createBufferSource();
noise.buffer = buf;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 2400;
const g = ctx.createGain();
g.gain.setValueAtTime(0.18, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
noise.connect(hp).connect(g).connect(ctx.destination);
noise.start(now);
for (let i = 0; i < 5; i++) {
const osc = ctx.createOscillator();
const og = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 1800 + Math.random() * 3200;
const t = now + 0.03 + Math.random() * 0.22;
og.gain.setValueAtTime(0.05, t);
og.gain.exponentialRampToValueAtTime(0.0008, t + 0.3);
osc.connect(og).connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.32);
}
setTimeout(() => ctx.close(), 1500);
} catch { /* sound is optional */ }
}
function shatterAt(cx, cy) {
if (!pane || breaking) return;
breaking = true;
const hint = pane.querySelector('.glass-hint');
if (hint) hint.remove();
const W = window.innerWidth, H = window.innerHeight;
const maxR = Math.max(
Math.hypot(cx, cy), Math.hypot(W - cx, cy),
Math.hypot(cx, H - cy), Math.hypot(W - cx, H - cy)
) * 1.12;
// Crack web: jittered rays out from the impact + rings across them.
// pts[i][j] = vertex where ray i meets ring j (j = 0 is the impact).
const nRays = 10 + Math.floor(Math.random() * 3);
const ringFracs = [0.14, 0.34, 0.62, 1.0];
const pts = [];
for (let i = 0; i < nRays; i++) {
const baseAngle = (i / nRays) * Math.PI * 2 + (Math.random() - 0.5) * 0.45;
const row = [[cx, cy]];
for (let j = 0; j < ringFracs.length; j++) {
const a = baseAngle + (Math.random() - 0.5) * 0.18;
const r = maxR * ringFracs[j] * (0.85 + Math.random() * 0.3);
row.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
pts.push(row);
}
playShatterSound();
// Impact flash.
const flash = document.createElement('div');
flash.className = 'glass-flash';
flash.style.background = `radial-gradient(circle at ${cx}px ${cy}px, rgba(255,255,255,0.85), rgba(255,255,255,0.25) 18%, transparent 45%)`;
document.body.appendChild(flash);
flash.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 320, easing: 'ease-out' }
).onfinish = () => flash.remove();
// Screen shake.
const main = document.getElementById('main');
if (main) {
main.animate(
[0, 1, 2, 3, 4, 5].map(k => ({
transform: (k === 0 || k === 5) ? 'translate(0,0)' :
`translate(${(Math.random() * 14 - 7).toFixed(1)}px, ${(Math.random() * 14 - 7).toFixed(1)}px)`
})),
{ duration: 340, easing: 'ease-out' }
);
}
// Draw the cracks (rays + inner rings) with a quick dash reveal.
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.style.cssText = 'position:fixed;inset:0;z-index:1502;pointer-events:none;';
const polylines = [];
for (let i = 0; i < nRays; i++) {
polylines.push(pts[i].map(p => p.join(',')).join(' '));
}
for (let j = 1; j < ringFracs.length; j++) {
const ring = [];
for (let i = 0; i <= nRays; i++) ring.push(pts[i % nRays][j].join(','));
polylines.push(ring.join(' '));
}
for (const ptsStr of polylines) {
const pl = document.createElementNS(SVG_NS, 'polyline');
pl.setAttribute('points', ptsStr);
pl.setAttribute('fill', 'none');
pl.setAttribute('stroke', 'rgba(255,255,255,0.85)');
pl.setAttribute('stroke-width', (0.8 + Math.random() * 1.4).toFixed(2));
svg.appendChild(pl);
}
document.body.appendChild(svg);
for (const pl of svg.children) {
const len = pl.getTotalLength();
pl.style.strokeDasharray = len;
pl.style.strokeDashoffset = len;
pl.animate(
[{ strokeDashoffset: len }, { strokeDashoffset: 0 }],
{ duration: 160 + Math.random() * 120, easing: 'ease-out', fill: 'forwards' }
);
}
// After the cracks land, release the shards.
setTimeout(() => {
pane.classList.add('broken');
svg.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 250, fill: 'forwards' }
).onfinish = () => svg.remove();
const shards = [];
for (let i = 0; i < nRays; i++) {
const next = (i + 1) % nRays;
for (let j = 0; j < ringFracs.length; j++) {
const poly = j === 0
? [pts[i][0], pts[i][1], pts[next][1]]
: [pts[i][j], pts[i][j + 1], pts[next][j + 1], pts[next][j]];
shards.push({ poly, ring: j });
}
}
let pending = shards.length;
for (const s of shards) {
const el = document.createElement('div');
el.className = 'glass-shard';
const sheenAngle = Math.floor(Math.random() * 360);
const a = 0.05 + Math.random() * 0.1;
el.style.background = `linear-gradient(${sheenAngle}deg, rgba(255,255,255,${(a + 0.08).toFixed(3)}), rgba(190,205,255,${a.toFixed(3)}) 45%, rgba(255,255,255,${(a + 0.05).toFixed(3)}))`;
el.style.clipPath = `polygon(${s.poly.map(p => `${p[0].toFixed(1)}px ${p[1].toFixed(1)}px`).join(',')})`;
document.body.appendChild(el);
const centX = s.poly.reduce((sum, p) => sum + p[0], 0) / s.poly.length;
const centY = s.poly.reduce((sum, p) => sum + p[1], 0) / s.poly.length;
const d = Math.hypot(centX - cx, centY - cy) || 1;
const dirX = (centX - cx) / d, dirY = (centY - cy) / d;
const fly = 90 + Math.random() * 240;
const fall = H * (0.5 + Math.random() * 0.7);
const rot = Math.random() * 140 - 70;
el.animate(
[
{ transform: 'translate(0,0) rotate(0deg)', opacity: 1 },
{ transform: `translate(${(dirX * fly * 0.6).toFixed(1)}px, ${(dirY * fly * 0.6).toFixed(1)}px) rotate(${(rot * 0.3).toFixed(1)}deg)`, opacity: 1, offset: 0.25 },
{ transform: `translate(${(dirX * fly).toFixed(1)}px, ${(dirY * fly + fall).toFixed(1)}px) rotate(${rot.toFixed(1)}deg)`, opacity: 0 }
],
{
duration: 800 + Math.random() * 500,
delay: s.ring * 55 + Math.random() * 90,
easing: 'cubic-bezier(.4, .05, .8, .6)',
fill: 'forwards'
}
).onfinish = () => {
el.remove();
if (--pending === 0) cleanup();
};
}
}, 300);
function cleanup() {
if (pane) {
pane.remove();
pane = null;
}
breaking = false;
}
}
// Replay button: rebuild the pane, then break it from a random spot.
const btn = document.createElement('button');
btn.id = 'shatter-btn';
btn.title = 'Shatter the screen';
btn.setAttribute('aria-label', 'Shatter the screen');
btn.textContent = '🔨';
document.body.appendChild(btn);
btn.addEventListener('click', () => {
if (breaking) return;
if (pane) {
shatterAt(window.innerWidth / 2, window.innerHeight / 2);
return;
}
spawnPane(false);
setTimeout(() => {
shatterAt(
window.innerWidth * (0.3 + Math.random() * 0.4),
window.innerHeight * (0.25 + Math.random() * 0.4)
);
}, 550);
});
// First visit: the app loads behind a pane of glass — break through it.
spawnPane(true);
})();
</script>
</body>
</html>