diff --git a/public/data.json b/public/data.json
index d10aa79..9ac9f0f 100644
--- a/public/data.json
+++ b/public/data.json
@@ -1,4 +1,12 @@
{
+ "clones": [
+ {
+ "name": "Cyan",
+ "banner": "Saberchub~",
+ "image": "vixi/overlays/Tirga Belly (Colored).png",
+ "url": "https://booru.snowsune.net/posts/17840?q=tirga"
+ }
+ ],
"liquids": [
{
"name": "Coyote Cum",
diff --git a/public/vixi/overlays/Tirga Belly (Colored).png b/public/vixi/overlays/Tirga Belly (Colored).png
new file mode 100644
index 0000000..d0ac7c1
Binary files /dev/null and b/public/vixi/overlays/Tirga Belly (Colored).png differ
diff --git a/src/base.css b/src/base.css
new file mode 100644
index 0000000..2a4a6de
--- /dev/null
+++ b/src/base.css
@@ -0,0 +1,38 @@
+:root {
+ color-scheme: dark;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: #23232b;
+ font-family: "Inter", Arial, sans-serif;
+ min-height: 100vh;
+ color: #e6e6ff;
+}
+
+#app {
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+footer {
+ font-size: 0.75rem;
+ text-align: center;
+ color: #b3b3c3;
+}
+
+footer a {
+ color: inherit;
+}
+
+@media (max-width: 900px) {
+ #app {
+ padding: 24px 16px;
+ }
+}
diff --git a/src/main.js b/src/main.js
index 2ed196b..5a2c666 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,166 +1,12 @@
-import "./style.css";
+import "./base.css";
+import { renderTank } from "./tank";
+import { renderClones } from "./vixiclones";
const app = document.querySelector("#app");
+const path = window.location.pathname.replace(/\/$/, "");
-app.innerHTML = `
-
-
-
-

-

-
-
-
-
-
-`;
-
-const stage = app.querySelector(".stage");
-const logList = app.querySelector("[data-role='log']");
-let tankData;
-
-async function loadData() {
- try {
- const response = await fetch("/data.json");
- tankData = await response.json();
- renderTank();
- } catch (error) {
- console.error("Failed to load tank data", error);
- }
+if (path === "/vixiclones") {
+ renderClones(app);
+} else {
+ renderTank(app);
}
-
-function renderTank() {
- if (!tankData) return;
-
- clearLayers();
- drawLayers();
- drawLogbook();
-}
-
-function clearLayers() {
- stage.querySelectorAll(".liquid-layer").forEach((node) => node.remove());
-}
-
-function drawLayers() {
- const { settings, liquids } = tankData;
- const height = stage.offsetHeight;
- const tankHeight = Math.max(
- 0,
- height - settings.tankTopOffset - settings.tankBottomOffset
- );
-
- let offset = 0;
- let cumulative = 0;
-
- liquids.forEach((liquid) => {
- const layer = document.createElement("div");
- layer.className = "liquid-layer";
- layer.style.bottom = `${settings.tankBottomOffset + offset}px`;
-
- const layerHeight = (liquid.volume / 100) * tankHeight;
- layer.style.height = `${layerHeight}px`;
- offset += layerHeight;
-
- if (liquid.image) {
- layer.style.backgroundImage = `url(${liquid.image})`;
- layer.style.backgroundSize = "800px";
- layer.style.backgroundRepeat = "no-repeat";
- layer.style.backgroundPosition = "right 50px top 0";
- layer.style.backgroundColor = liquid.color ?? "transparent";
- } else {
- layer.style.backgroundColor = liquid.color ?? "#fff";
- }
-
- const label = liquid.url
- ? document.createElement("a")
- : document.createElement("span");
- label.className = "liquid-label";
- label.textContent = `${liquid.name} (${liquid.volume}%)`;
- if (liquid.url) {
- label.href = liquid.url;
- label.target = "_blank";
- label.rel = "noreferrer noopener";
- }
-
- cumulative += liquid.volume;
- if (cumulative < 20) {
- label.style.left = "18%";
- label.style.transform = "translate(60%, -50%)";
- }
-
- layer.appendChild(label);
- stage.appendChild(layer);
- });
-}
-
-function drawLogbook() {
- const now = Date.now();
- logList.innerHTML = "";
-
- tankData.logs?.forEach((log) => {
- const li = document.createElement("li");
- const epochMs =
- typeof log.date === "number" ? log.date * 1000 : Date.parse(log.date);
- const ageHours = (now - epochMs) / (1000 * 60 * 60);
-
- li.textContent = `${formatRelative(epochMs)} — ${log.text}`;
- li.style.color = mixColor(ageHours);
- if (ageHours < 8) li.classList.add("log-new");
-
- logList.appendChild(li);
- });
-
- const container = logList.parentElement;
- container.scrollTo({ top: container.scrollHeight });
-}
-
-function formatRelative(epochMs) {
- const diffSeconds = Math.round((epochMs - Date.now()) / 1000);
- const divisions = [
- { amount: 60, unit: "second" },
- { amount: 60, unit: "minute" },
- { amount: 24, unit: "hour" },
- { amount: 7, unit: "day" },
- { amount: 4.34524, unit: "week" },
- { amount: 12, unit: "month" },
- { amount: Number.POSITIVE_INFINITY, unit: "year" }
- ];
-
- let duration = diffSeconds;
- for (const division of divisions) {
- if (Math.abs(duration) < division.amount) {
- return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
- Math.round(duration),
- division.unit
- );
- }
- duration /= division.amount;
- }
-}
-
-function mixColor(hours) {
- if (hours <= 0) return "#ffffff";
- if (hours >= 8) return "#888888";
- const t = hours / 8;
- const channel = Math.round(255 + (136 - 255) * t);
- return `rgb(${channel},${channel},${channel})`;
-}
-
-window.addEventListener("resize", () => {
- if (!tankData) return;
- requestAnimationFrame(renderTank);
-});
-
-loadData();
diff --git a/src/style.css b/src/tank.css
similarity index 86%
rename from src/style.css
rename to src/tank.css
index b724e64..9fed74a 100644
--- a/src/style.css
+++ b/src/tank.css
@@ -1,26 +1,3 @@
-:root {
- color-scheme: dark;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- background: #23232b;
- font-family: "Inter", Arial, sans-serif;
- min-height: 100vh;
- color: #e6e6ff;
-}
-
-#app {
- padding: 32px;
- display: flex;
- flex-direction: column;
- gap: 24px;
-}
-
.layout {
display: flex;
gap: 24px;
@@ -159,16 +136,6 @@ body {
filter: drop-shadow(0 0 3px #fff6);
}
-footer {
- font-size: 0.75rem;
- text-align: center;
- color: #b3b3c3;
-}
-
-footer a {
- color: inherit;
-}
-
@media (max-width: 900px) {
.layout {
flex-direction: column;
diff --git a/src/tank.js b/src/tank.js
new file mode 100644
index 0000000..b7b1eec
--- /dev/null
+++ b/src/tank.js
@@ -0,0 +1,163 @@
+import "./tank.css";
+
+export function renderTank(app) {
+ document.title = "cumtanks.snowsune.net";
+ app.classList.remove("page-clones");
+ app.innerHTML = `
+
+
+
+

+

+
+
+
+
+
+ `;
+
+ const stage = app.querySelector(".stage");
+ const logList = app.querySelector("[data-role='log']");
+ let tankData;
+
+ async function loadData() {
+ try {
+ const response = await fetch("/data.json");
+ tankData = await response.json();
+ update();
+ } catch (error) {
+ console.error("Failed to load tank data", error);
+ }
+ }
+
+ function update() {
+ if (!tankData) return;
+ drawLayers();
+ drawLogbook();
+ }
+
+ function drawLayers() {
+ stage.querySelectorAll(".liquid-layer").forEach((node) => node.remove());
+
+ const { settings, liquids } = tankData;
+ const height = stage.offsetHeight;
+ const tankHeight = Math.max(
+ 0,
+ height - settings.tankTopOffset - settings.tankBottomOffset
+ );
+
+ let offset = 0;
+ let cumulative = 0;
+
+ liquids.forEach((liquid) => {
+ const layer = document.createElement("div");
+ layer.className = "liquid-layer";
+ layer.style.bottom = `${settings.tankBottomOffset + offset}px`;
+
+ const layerHeight = (liquid.volume / 100) * tankHeight;
+ layer.style.height = `${layerHeight}px`;
+ offset += layerHeight;
+
+ if (liquid.image) {
+ layer.style.backgroundImage = `url(${liquid.image})`;
+ layer.style.backgroundSize = "800px";
+ layer.style.backgroundRepeat = "no-repeat";
+ layer.style.backgroundPosition = "right 50px top 0";
+ layer.style.backgroundColor = liquid.color ?? "transparent";
+ } else {
+ layer.style.backgroundColor = liquid.color ?? "#fff";
+ }
+
+ const label = liquid.url
+ ? document.createElement("a")
+ : document.createElement("span");
+ label.className = "liquid-label";
+ label.textContent = `${liquid.name} (${liquid.volume}%)`;
+ if (liquid.url) {
+ label.href = liquid.url;
+ label.target = "_blank";
+ label.rel = "noreferrer noopener";
+ }
+
+ cumulative += liquid.volume;
+ if (cumulative < 20) {
+ label.style.left = "18%";
+ label.style.transform = "translate(60%, -50%)";
+ }
+
+ layer.appendChild(label);
+ stage.appendChild(layer);
+ });
+ }
+
+ function drawLogbook() {
+ const now = Date.now();
+ logList.innerHTML = "";
+
+ tankData.logs?.forEach((log) => {
+ const li = document.createElement("li");
+ const epochMs =
+ typeof log.date === "number" ? log.date * 1000 : Date.parse(log.date);
+ const ageHours = (now - epochMs) / (1000 * 60 * 60);
+
+ li.textContent = `${formatRelative(epochMs)} — ${log.text}`;
+ li.style.color = mixColor(ageHours);
+ if (ageHours < 8) li.classList.add("log-new");
+
+ logList.appendChild(li);
+ });
+
+ logList.parentElement.scrollTo({ top: logList.parentElement.scrollHeight });
+ }
+
+ window.addEventListener("resize", () => {
+ if (!tankData) return;
+ requestAnimationFrame(drawLayers);
+ });
+
+ loadData();
+}
+
+function formatRelative(epochMs) {
+ const diffSeconds = Math.round((epochMs - Date.now()) / 1000);
+ const divisions = [
+ { amount: 60, unit: "second" },
+ { amount: 60, unit: "minute" },
+ { amount: 24, unit: "hour" },
+ { amount: 7, unit: "day" },
+ { amount: 4.34524, unit: "week" },
+ { amount: 12, unit: "month" },
+ { amount: Number.POSITIVE_INFINITY, unit: "year" }
+ ];
+
+ let duration = diffSeconds;
+ for (const division of divisions) {
+ if (Math.abs(duration) < division.amount) {
+ return new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(
+ Math.round(duration),
+ division.unit
+ );
+ }
+ duration /= division.amount;
+ }
+}
+
+function mixColor(hours) {
+ if (hours <= 0) return "#ffffff";
+ if (hours >= 8) return "#888888";
+ const t = hours / 8;
+ const channel = Math.round(255 + (136 - 255) * t);
+ return `rgb(${channel},${channel},${channel})`;
+}
diff --git a/src/vixiclones.css b/src/vixiclones.css
new file mode 100644
index 0000000..6676960
--- /dev/null
+++ b/src/vixiclones.css
@@ -0,0 +1,97 @@
+.page-clones {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.clones-header {
+ text-align: center;
+}
+
+.clones-header h1 {
+ margin: 0 0 8px;
+ font-size: clamp(2rem, 3vw, 3rem);
+}
+
+.clones-header p {
+ margin: 0;
+ color: #b8b8cc;
+}
+
+.clones-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ align-items: center;
+}
+
+.clones-row {
+ display: grid;
+ gap: 24px;
+ grid-template-columns: repeat(var(--row-count), minmax(160px, 1fr));
+ justify-content: center;
+}
+
+.clones-row figure {
+ margin: 0;
+ padding: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ background: rgba(12, 12, 18, 0.75);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ align-items: center;
+}
+
+.portrait-link {
+ display: inline-flex;
+}
+
+.portrait {
+ position: relative;
+ width: 180px;
+ aspect-ratio: 3 / 4;
+ border-radius: 8px;
+ background: #101018;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+}
+
+.portrait img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.clone-banner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translateX(-50%) rotate(-45deg);
+ background: rgba(20, 20, 24, 0.9);
+ color: #4df9ff;
+ padding: 6px 32px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+ text-align: center;
+ width: 300%;
+}
+
+.clones-row figcaption {
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+@media (max-width: 900px) {
+ .clones-row {
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ }
+
+ .portrait {
+ width: 100%;
+ }
+}
diff --git a/src/vixiclones.js b/src/vixiclones.js
new file mode 100644
index 0000000..68be1ec
--- /dev/null
+++ b/src/vixiclones.js
@@ -0,0 +1,113 @@
+import "./vixiclones.css";
+
+const DEFAULT_CLONES = [
+ { slug: "azure" },
+ { slug: "cyan" },
+ { slug: "indigo" },
+ { slug: "lapis" },
+ { slug: "midnight" },
+ { slug: "navy2" },
+ { slug: "royal" },
+ { slug: "sapphire" },
+ { slug: "sky" }
+];
+
+export async function renderClones(app) {
+ document.title = "Vixi Clones";
+ app.classList.add("page-clones");
+ app.innerHTML = `
+
+
+ `;
+
+ const rowsRoot = app.querySelector("[data-role='rows']");
+ const clones = await loadClones();
+ const firstRow = clones.slice(0, 4);
+ const secondRow = clones.slice(4);
+
+ rowsRoot.innerHTML = [firstRow, secondRow]
+ .filter((row) => row.length)
+ .map(renderRow)
+ .join("");
+}
+
+async function loadClones() {
+ let overrides = [];
+ try {
+ const response = await fetch("/data.json");
+ const data = await response.json();
+ if (Array.isArray(data?.clones)) overrides = data.clones;
+ } catch (error) {
+ console.error("Failed to load clone data", error);
+ }
+
+ return DEFAULT_CLONES.map((base) => {
+ const override = overrides.find((item) => matchClone(item, base.slug));
+ return normalizeClone({ ...base, ...override });
+ });
+}
+
+function matchClone(item, slug) {
+ if (!item) return false;
+ if (item.slug) return item.slug === slug;
+ if (item.name) return toSlug(item.name) === slug;
+ return false;
+}
+
+function normalizeClone(clone) {
+ const slug = clone.slug ?? toSlug(clone.name ?? "");
+ const name = clone.name ?? formatName(slug);
+ return {
+ slug,
+ name,
+ banner: clone.banner ?? null,
+ url: clone.url ?? null,
+ image: clone.image ?? null
+ };
+}
+
+function renderRow(row) {
+ return `
+
+ ${row.map(renderClone).join("")}
+
+ `;
+}
+
+function renderClone(clone) {
+ const portraitContent = `
+
+

+ ${clone.banner ? `
${clone.banner}` : ""}
+
`;
+
+ const clickable = clone.url
+ ? `${portraitContent}`
+ : portraitContent;
+
+ return `
+
+ ${clickable}
+ ${clone.name}
+
+ `;
+}
+
+function formatName(slug) {
+ if (!slug) return "";
+ return slug
+ .replace(/[-_]/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function toSlug(value) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-{2,}/g, "-")
+ || "clone";
+}