align-items: center;<!DOCTYPE html>
<div class="actions">
<div class="search-wrap">
<input id="search" type="search" placeholder="Search titles, tags…" aria-label="Search"/>
<button id="clearSearch" class="ghost" title="Clear search" aria-label="Clear search">✕</button>
</div>
<select id="sortBy" aria-label="Sort reviews">
<option value="featured">Sort: Featured</option>
<option value="rating">Sort: Rating</option>
<option value="newest">Sort: Newest</option>
<option value="title">Sort: Title</option>
</select>
<button id="themeToggle" class="chip" title="Toggle theme" aria-pressed="false">Theme</button>
<button id="sfxToggle" class="chip" title="Toggle sounds" aria-pressed="false">SFX</button>
<a id="addBtn" class="chip primary" href="#/submit" title="Add Review">+ Add</a>
</div>
${radialItem("manhua","📚","Manhua")}
${radialItem("donghua","🎥","Donghua")}
${radialItem("cosplay","🧝","Cosplay")}
${radialItem("game","🕹️","Games")}
${radialItem("novel","📖","Novels")}
${esc(meta)}${fmtStars(rating)} (${rating.toFixed(1)})
${r.tags.slice(0,4).map(t=>`#${esc(t)}`).join("")}
`}
function renderAll(){const list=getAllReviews();
app.innerHTML=html`${filtersBar()}
${TYPES.map(t=>{const label=t[0].toUpperCase()+t.slice(1);const active=activeType===t?"active":"";return `${label}`}).join("")}
AllTip: Use the search and sort controls in the top bar.
`}
function applySearchSort(list){const q=(searchInput.value||"").trim().toLowerCase();
if(q){list=list.filter(r=>r.title.toLowerCase().includes(q)||(r.author||"").toLowerCase().includes(q)||r.tags.some(t=>t.toLowerCase().includes(q)));}
switch(sortSel.value){
case "rating": list.sort((a,b)=>avgRating(b.rating,store.get().ratings[b.id])-avgRating(a.rating,store.get().ratings[a.id]));break;
case "newest": list.sort((a,b)=>b.createdAt-a.createdAt);break;
case "title": list.sort((a,b)=>a.title.localeCompare(b.title));break;
default: { const score=r=>avgRating(r.rating,store.get().ratings[r.id])*2 + r.createdAt/1e11; list.sort((a,b)=>score(b)-score(a));}
} return list}
function bindCommonListHandlers(currType){$$(".card").forEach(el=>{const go=()=>{playChime(640);location.hash=`#/review?id=${el.dataset.id}`};
el.addEventListener("click",go); el.addEventListener("keydown",e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();go();}})});
if(currType){$$(".toggle").forEach(t=>t.classList.toggle("active",t.textContent.toLowerCase().includes(currType)));}}
function renderDetail(){const{params}=parseHash();const id=params.get("id");const r=getById(id);
if(!r){app.innerHTML=`Not found. Back home
`;return} const user=store.get().ratings[r.id];const rating=avgRating(r.rating,user); app.innerHTML=html`← Back to ${r.type}Title
Type
Choose…
Manhua
Donghua
Cosplay
Game
Novel
Author / Creator
Year
Cover Image URL
Use a direct image link (JPG/PNG). Optional; a placeholder is used if empty.
Summary
Full Review<textarea name="body" placeholder="Write your thoughts…" required></textarea>
<div class="form-row"><label>Tags (comma‑separated)</label><input name="tags" placeholder="cultivation, action, sci‑fi"></div>
<div class="form-row cols">
<div><label>Initial Rating</label><input name="rating" type="number" min="1" max="5" step="0.1" value="4.0"></div>
<div><label> </label><button class="chip primary" type="submit">Publish</button></div>
</div>
<p class="small">Stored locally in your browser (no server). You can export later.</p>
</form>`;
$("#submitForm").addEventListener("submit",e=>{
e.preventDefault(); const fd=new FormData(e.currentTarget);
const title=(fd.get("title")+"").trim(), type=fd.get("type")+"", author=(fd.get("author")+"").trim(),
year=Number(fd.get("year")||""), coverUrl=(fd.get("coverUrl")+"").trim()||"https://placehold.co/1200x675/0c1725/9ab0c6?text=Dao+of+Reviews",
summary=(fd.get("summary")+"").trim(), body=(fd.get("body")+"").trim(),
tags=(fd.get("tags")+"").split(",").map(t=>t.trim()).filter(Boolean),
rating=Math.max(1,Math.min(5,Number(fd.get("rating")||4)));
if(!title||!type||!summary||!body){alert("Please fill required fields.");return}
const id=slugify(title); if(getById(id)){alert("A review with that title already exists. Try a different title.");return}
store.get().reviews.unshift({id,type,title,author,year,coverUrl,rating,tags,summary,body,createdAt:Date.now()});
store.save(); playChime(880,.12); location.hash=`#/review?id=${id}`;
});
}
function renderAbout(){app.innerHTML=html`<h2>About</h2>
<p><strong>Dao of Reviews</strong> is a stylized review hub for <strong>manhua</strong>, <strong>donghua</strong>, <strong>cosplayers</strong>, <strong>video games</strong>, and <strong>novels</strong> — presented as a sci‑fi/Xianxia hybrid HUD.</p>
<ul><li>No backend; everything runs in your browser.</li><li>Add reviews; they persist via <code>localStorage</code>.</li><li>Rate with stars; share deep links.</li></ul>
<p class="small">Tip: Toggle <em>Theme</em> to switch Jade ↔ Crimson. Toggle <em>SFX</em> for chimes.</p>`}
// Topbar binds
const updateClearBtn=()=>{clearSearchBtn.style.display=searchInput.value?"inline-block":"none"}; updateClearBtn();
searchInput.addEventListener("input",()=>{updateClearBtn(); const {path}=parseHash(); if(path==="#/list")renderList(); else if(path==="#/all")renderAll()});
clearSearchBtn.addEventListener("click",()=>{searchInput.value=""; updateClearBtn(); const {path}=parseHash(); if(path==="#/list")renderList(); else if(path==="#/all")renderAll()});
sortSel.addEventListener("change",()=>{const {path}=parseHash(); if(path==="#/list")renderList(); else if(path==="#/all")renderAll()});
const themeApply=()=>{const s=store.get(); document.body.classList.toggle("theme-crimson",s.theme==="crimson");
document.body.classList.toggle("theme-jade",s.theme!=="crimson"); themeToggle.setAttribute("aria-pressed",String(s.theme==="crimson"))};
themeToggle.addEventListener("click",()=>{const s=store.get(); s.theme=s.theme==="crimson"?"jade":"crimson"; store.save(); themeApply(); playChime(600)}); themeApply();
sfxToggle.addEventListener("click",()=>{const s=store.get(); s.sfx=!s.sfx; store.save(); sfxToggle.classList.toggle("active",s.sfx); sfxToggle.setAttribute("aria-pressed",String(s.sfx)); s.sfx&&playChime(720)});
if(store.get().sfx){sfxToggle.classList.add("active"); sfxToggle.setAttribute("aria-pressed","true")}
// Qi particles
const canvas=document.getElementById("qi-canvas"), ctx=canvas.getContext("2d",{alpha:true}); let W,H,DPR,particles=[];
const P_BASE=180; function resize(){DPR=Math.min(2,devicePixelRatio||1); W=canvas.width=Math.floor(innerWidth*DPR);
H=canvas.height=Math.floor(innerHeight*DPR); canvas.style.width=innerWidth+"px"; canvas.style.height=innerHeight+"px"; initParticles()}
addEventListener("resize",resize);
function initParticles(){const count=Math.round(P_BASE*Math.min(1.4,(innerWidth*innerHeight)/(1200*800)));
particles=Array.from({length:count},()=>({r:.6+Math.random()*1.8,ang:Math.random()*Math.PI*2,rad:Math.random()*Math.min(W,H)/2,speed:(.0008+Math.random()*.0016)*(Math.random()<.5?-1:1)}))}
function draw(){ctx.clearRect(0,0,W,H); const cx=W/2,cy=H/2; const accent=getComputedStyle(document.body).getPropertyValue('--accent').trim()||"#00f0ff";
for(const p of particles){p.ang+=p.speed; p.rad+=Math.sin((performance.now()/1400)+p.ang)*.04; const x=cx+Math.cos(p.ang)*p.rad, y=cy+Math.sin(p.ang)*p.rad*.62;
const g=ctx.createRadialGradient(x,y,0,x,y,16*DPR); g.addColorStop(0,accent+"cc"); g.addColorStop(1,"#0000"); ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,p.r*DPR*2.2,0,Math.PI*2); ctx.fill();}
requestAnimationFrame(draw)}
resize(); draw();
(routes[parseHash().path]||renderHome)();
})();
This app requires JavaScript.
gap: 1rem;
padding: .75rem 1rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
backdrop-filter: blur(6px);
position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: baseline; gap: .5rem; }
.brand-title {
font-family: "Unbounded", system-ui; letter-spacing: .5px; margin: 0;
}
.brand-sub { opacity: .7; font-size: .9rem; }
.brand-logo { filter: drop-shadow(0 0 6px var(--accent)); }
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.search-wrap {
display: flex; align-items: stretch; border: 1px solid var(--border);
border-radius: 10px; overflow: hidden; background: var(--panel);
}
#search {
padding: .6rem .75rem; background: transparent; color: var(--text); border: 0; width: min(36vw, 380px);
}
#clearSearch { padding: .6rem .7rem; color: var(--muted); display: none; }
select, .chip, .ghost, .primary {
font: inherit; color: var(--text); background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; padding: .55rem .75rem;
cursor: pointer;
}
.chip.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2));
color: #001018; border: 1px solid transparent; font-weight: 700; }
.chip.primary:hover { filter: brightness(.95); }
.ghost { background: transparent; }
select { min-width: 150px; }
/* Footer */
.footer {
margin-top: 2rem; border-top: 1px solid var(--border);
display: flex; justify-content: space-between; align-items: center;
padding: 1rem; color: var(--muted);
background: linear-gradient(0deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
}
.footer a { color: var(--muted); text-decoration: none; }
.footer a:hover { color: var(--accent); }
/* Main layout */
#app { padding: 1.25rem 1rem 2rem; }
/* ===== HOME: Ring Menu ===== */
.ring-wrap {
position: relative;
margin: 4vh auto 2rem;
width: min(560px, 90vw);
aspect-ratio: 1/1;
display: grid; place-items: center;
filter: drop-shadow(0 0 20px rgba(0,0,0,.6));
}
.ring {
position: absolute; inset: 0; border-radius: 50%;
border: 2px dashed rgba(255,255,255,0.2);
border-color: color-mix(in srgb, var(--accent) 60%, #ffffff 20%);
animation: ringSpin 32s linear infinite;
}
.ring::after {
content: ""; position: absolute; inset: 10%; border-radius: 50%;
border: 1px solid rgba(255,255,255,0.12);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
box-shadow: inset 0 0 40px color-mix(in srgb, var(--accent) 18%, transparent);
}
@keyframes ringSpin { to { transform: rotate(360deg); } }
.ring-center {
z-index: 2; text-align: center;
font-family: "Unbounded", system-ui; color: var(--accent);
text-shadow: 0 0 12px color-mix(in srgb, var(--accent) 60%, transparent);
}
.ring-center h2 { margin: 0; font-weight: 700; }
.ring-center p { margin: .25rem 0 0; color: var(--muted); }
.radial-menu { position: absolute; inset: 0; }
.radial-item {
position: absolute; width: 88px; height: 88px; border-radius: 50%;
display: grid; place-items: center; font-size: 1.6rem;
color: var(--accent); background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
box-shadow: inset 0 0 22px rgba(0,0,0,.35);
transition: transform .25s ease, box-shadow .25s ease, background .25s ease;
user-select: none;
}
.radial-item:hover {
transform: scale(1.12);
box-shadow: 0 0 22px color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 10%, rgba(255,255,255,0.06));
cursor: pointer;
}
.radial-item[data-type="manhua"] { --deg: 335deg; }
.radial-item[data-type="donghua"]{ --deg: 65deg; }
.radial-item[data-type="cosplay"]{ --deg: 155deg; }
.radial-item[data-type="game"] { --deg: 245deg; }
.radial-item {
/* Position items around circle */
top: calc(50% - 44px); left: calc(50% - 44px);
transform: rotate(var(--deg)) translateX(calc(min(560px, 90vw)/2.25)) rotate(calc(-1 * var(--deg)));
}
/* Preview strip under ring */
.preview-strip {
margin: 1rem auto 0; max-width: 1100px;
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: .9rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: .9rem;
box-shadow: var(--shadow);
transition: transform .2s ease, border-color .2s ease;
}
.card:hover { transform: translateY(-4px); border-color: var(--accent); }
.card .thumb {
width: 100%; aspect-ratio: 16/9; border-radius: 8px; overflow: hidden; margin-bottom: .6rem;
background: #0c1725;
}
.card .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.card h3 { margin: .25rem 0 .35rem; font-family: "Unbounded"; font-size: 1.05rem; }
.meta { font-size: .85rem; color: var(--muted); display: flex; gap: .6rem; align-items: center; }
.tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.tag { font-size: .75rem; padding: .25rem .45rem; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
/* ===== LIST VIEW ===== */
.controls {
margin: .5rem 0 1rem;
display: flex; gap: .5rem; flex-wrap: wrap; align-items: center;
}
.toggle {
padding: .45rem .6rem; border-radius: 999px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); cursor: pointer; text-decoration: none;
}
.toggle.active { border-color: var(--accent); color: var(--accent); }
.grid {
display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
/* ===== DETAIL VIEW ===== */
.detail {
display: grid; gap: 1.2rem; grid-template-columns: 360px 1fr;
}
@media (max-width: 900px) { .detail { grid-template-columns: 1fr; } }
.detail .cover {
border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: #0c1725;
box-shadow: var(--shadow);
}
.detail .cover img { width: 100%; height: auto; display: block; }
.detail h2 { margin: 0; font-family: "Unbounded"; }
.rating {
display: inline-flex; align-items: center; gap: .45rem; line-height: 1;
padding: .35rem .55rem; border: 1px solid var(--border); border-radius: 999px; background: var(--panel);
}
.stars { display: inline-flex; gap: .15rem; cursor: pointer; }
.star { filter: drop-shadow(0 0 6px transparent); transition: transform .15s; }
.star:hover { transform: translateY(-1px) scale(1.05); }
.star.filled { color: var(--gold); filter: drop-shadow(0 0 6px rgba(247,198,107,.5)); }
.detail .chips { display: flex; gap: .35rem; flex-wrap: wrap; }
.detail .chip {
background: var(--panel); border: 1px solid var(--border); border-radius: 999px; padding: .35rem .6rem;
}
.detail .actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.detail p { color: #d7e6ff; opacity: .9; }
/* ===== SUBMIT FORM ===== */
.form {
display: grid; gap: .75rem; max-width: 820px;
}
.form-row { display: grid; gap: .5rem; }
.form-row.cols { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 720px) { .form-row.cols { grid-template-columns: 1fr; } }
.form input, .form textarea, .form select {
width: 100%; border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); padding: .7rem .75rem;
font: inherit;
}
.form textarea { min-height: 160px; resize: vertical; }
.form .help { color: var(--muted); font-size: .85rem; }
/* Utility */
.center { text-align: center; }
.hidden { display: none !important; }
.small { font-size: .85rem; color: var(--muted); }
.hr { height: 1px; background: var(--border); margin: .75rem 0; }
/* Focus outlines */
:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
<div class="actions">
<div class="search-wrap">
<input id="search" type="search" placeholder="Search titles, tags…" aria-label="Search"/>
<button id="clearSearch" class="ghost" title="Clear search" aria-label="Clear search">✕</button>
</div>
<select id="sortBy" aria-label="Sort reviews">
<option value="featured">Sort: Featured</option>
<option value="rating">Sort: Rating</option>
<option value="newest">Sort: Newest</option>
<option value="title">Sort: Title</option>
</select>
<button id="themeToggle" class="chip" title="Toggle theme" aria-pressed="false">Theme</button>
<button id="sfxToggle" class="chip" title="Toggle sounds" aria-pressed="false">SFX</button>
<a id="addBtn" class="chip primary" href="#/submit" title="Add Review">+ Add</a>
</div>
${radialItem("manhua","📚","Manhua")}
${radialItem("donghua","🎥","Donghua")}
${radialItem("cosplay","🧝","Cosplay")}
${radialItem("game","🕹️","Games")}
${esc(meta)}
${fmtStars(rating)} (${rating.toFixed(1)})
${r.tags.slice(0,4).map(t => `#${esc(t)}`).join("")}
`;
}
function renderAll() {
const list = getAllReviews();
app.innerHTML = html`
${filtersBar()}
${["manhua","donghua","cosplay","game"].map(t => {
const label = t[0].toUpperCase() + t.slice(1);
const active = activeType === t ? "active" : "";
return `${label}`;
}).join("")}
All
Tip: Use the search and sort controls in the top bar.
`;
}
function applySearchSort(list) {
const q = (searchInput.value || "").trim().toLowerCase();
if (q) {
list = list.filter(r =>
r.title.toLowerCase().includes(q) ||
(r.author||"").toLowerCase().includes(q) ||
r.tags.some(tag => tag.toLowerCase().includes(q))
);
}
switch (sortSel.value) {
case "rating":
list.sort((a,b) => avgRating(b.rating,store.get().ratings[b.id]) - avgRating(a.rating,store.get().ratings[a.id]));
break;
case "newest":
list.sort((a,b) => b.createdAt - a.createdAt);
break;
case "title":
list.sort((a,b) => a.title.localeCompare(b.title));
break;
default: {
const score = r => (avgRating(r.rating,store.get().ratings[r.id])*2 + r.createdAt/1e11);
list.sort((a,b) => score(b) - score(a));
}
}
return list;
}
function bindCommonListHandlers(currType) {
$$(".card").forEach(el => {
const go = () => { playChime(640); location.hash = `#/review?id=${el.dataset.id}`; };
el.addEventListener("click", go);
el.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } });
});
if (currType) {
$$(".toggle").forEach(t => t.classList.toggle("active", t.textContent.toLowerCase().includes(currType)));
}
}
function renderDetail() {
const { params } = parseHash();
const id = params.get("id");
const r = getById(id);
if (!r) {
app.innerHTML = `Not found. Back home
`; return; } const user = store.get().ratings[r.id]; const rating = avgRating(r.rating, user); app.innerHTML = html` ← Back to ${r.type}
Title
Type
Choose…
Manhua
Donghua
Cosplay
Game
Author / Creator
Year
Cover Image URL
Use a direct image link (JPG/PNG). Optional; a placeholder is used if empty.
Summary
Full Review
<textarea name="body" placeholder="Write your thoughts…" required></textarea>
</script>
</div>
<div class="form-row">
<label>Tags (comma‑separated)</label>
<input name="tags" placeholder="cultivation, action, sci‑fi">
</div>
<div class="form-row cols">
<div>
<label>Initial Rating</label>
<input name="rating" type="number" min="1" max="5" step="0.1" value="4.0">
</div>
<div>
<label> </label>
<button class="chip primary" type="submit">Publish</button>
</div>
</div>
<p class="small">By submitting, you agree your review will be stored locally in your browser (no server). You can export your data later.</p>
</form>
`;
$("#submitForm").addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const title = (fd.get("title")+"").trim();
const type = fd.get("type")+"";
const author = (fd.get("author")+"").trim();
const year = Number(fd.get("year")||"");
const coverUrl = (fd.get("coverUrl")+"").trim() || "https://placehold.co/1200x675/0c1725/9ab0c6?text=Immersion+Realms";
const summary = (fd.get("summary")+"").trim();
const body = (fd.get("body")+"").trim();
const tags = (fd.get("tags")+"").split(",").map(t => t.trim()).filter(Boolean);
const rating = Math.max(1, Math.min(5, Number(fd.get("rating")||4)));
if (!title || !type || !summary || !body) { alert("Please fill required fields."); return; }
const id = slugify(title);
const exists = getById(id);
if (exists) { alert("A review with that title/slug already exists. Try a different title."); return; }
store.get().reviews.unshift({
id, type, title, author, year,
coverUrl, rating: rating, tags,
summary, body,
createdAt: Date.now()
});
store.save();
playChime(880, .12);
location.hash = `#/review?id=${id}`;
});
}
function renderAbout() {
app.innerHTML = html`
<h2>About</h2>
<p>Immersion Realms is a stylized review hub for <strong>manhua</strong>, <strong>donghua</strong>, <strong>cosplayers</strong>, and <strong>video games</strong> — presented as a sci‑fi/Xianxia hybrid HUD.</p>
<ul>
<li>No backend; everything runs in your browser.</li>
<li>Use <em>Add</em> to publish reviews; they persist via <code>localStorage</code>.</li>
<li>Rate with stars; share deep links to detail pages.</li>
</ul>
<p class="small">Tip: Toggle <em>Theme</em> to switch Jade ↔ Crimson. Toggle <em>SFX</em> for chimes.</p>
`;
}
/* ---------- Topbar binds ---------- */
// live search UI (show/hide clear button)
const updateClearBtn = () => { clearSearchBtn.style.display = searchInput.value ? "inline-block" : "none"; };
updateClearBtn();
searchInput.addEventListener("input", () => {
updateClearBtn();
const { path } = parseHash();
if (path === "#/list") renderList();
else if (path === "#/all") renderAll();
});
clearSearchBtn.addEventListener("click", () => {
searchInput.value = "";
updateClearBtn();
const { path } = parseHash();
if (path === "#/list") renderList();
else if (path === "#/all") renderAll();
});
sortSel.addEventListener("change", () => {
const { path } = parseHash();
if (path === "#/list") renderList();
else if (path === "#/all") renderAll();
});
const themeApply = () => {
const s = store.get();
document.body.classList.toggle("theme-crimson", s.theme === "crimson");
document.body.classList.toggle("theme-jade", s.theme !== "crimson");
themeToggle.setAttribute("aria-pressed", String(s.theme === "crimson"));
};
themeToggle.addEventListener("click", () => {
const s = store.get();
s.theme = s.theme === "crimson" ? "jade" : "crimson";
store.save();
themeApply();
playChime(600);
});
themeApply();
sfxToggle.addEventListener("click", () => {
const s = store.get();
s.sfx = !s.sfx; store.save();
sfxToggle.classList.toggle("active", s.sfx);
sfxToggle.setAttribute("aria-pressed", String(s.sfx));
s.sfx && playChime(720);
});
if (store.get().sfx) {
sfxToggle.classList.add("active");
sfxToggle.setAttribute("aria-pressed", "true");
}
/* ---------- Qi Particle Canvas ---------- */
const canvas = document.getElementById("qi-canvas");
const ctx = canvas.getContext("2d", { alpha: true });
let W, H, DPR;
let particles = [];
const P_COUNT_BASE = 180;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
W = canvas.width = Math.floor(innerWidth * DPR);
H = canvas.height = Math.floor((innerHeight) * DPR);
canvas.style.width = innerWidth + "px";
canvas.style.height = innerHeight + "px";
initParticles();
}
window.addEventListener("resize", resize);
function initParticles() {
const count = Math.round(P_COUNT_BASE * Math.min(1.4, (innerWidth * innerHeight) / (1200*800)));
particles = Array.from({ length: count }, () => ({
r: 0.6 + Math.random()*1.8,
ang: Math.random() * Math.PI*2,
rad: Math.random() * Math.min(W,H)/2,
speed: (0.0008 + Math.random()*0.0016) * (Math.random()<.5?-1:1),
hue: Math.random()*30 - 15
}));
}
function draw() {
ctx.clearRect(0,0,W,H);
const cx = W/2, cy = H/2;
const accent = getComputedStyle(document.body).getPropertyValue('--accent').trim() || "#00f0ff";
for (const p of particles) {
p.ang += p.speed;
p.rad += Math.sin((performance.now()/1400) + p.ang)*0.04;
const x = cx + Math.cos(p.ang)*p.rad;
const y = cy + Math.sin(p.ang)*p.rad*0.62; // slight ellipse
const g = ctx.createRadialGradient(x,y,0, x,y, 16*DPR);
g.addColorStop(0, accent + "cc");
g.addColorStop(1, "#00000000");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x,y, p.r*DPR*2.2, 0, Math.PI*2);
ctx.fill();
}
requestAnimationFrame(draw);
}
resize(); draw();
/* ---------- Boot ---------- */
(routes[parseHash().path] || renderHome)();
})();
This app requires JavaScript to run. Please enable JS and reload.