/* =====================================================================
* terminal.js β the homepage's interactive terminal.
*
* Flow: a short boot log streams in, the side chrome fades in alongside
* it, then the banner + a pinned prompt appear. You type a command (or a
* social's name) and the output is appended to the scrollback BELOW the
* input β the input itself never moves.
* ===================================================================== */
(function terminal() {
const root = document.getElementById("terminal");
if (!root) return;
// ---- socials (keyword -> destination) ----------------------------------
const SOCIALS = {
gitgay: { label: "Git.Gay", sub: "@doughmination", url: "https://git.gay/doughmination", aliases: ["git.gay", "gitea", "github", "git"] },
twitter: { label: "Twitter", sub: "@DoughminCEO", url: "https://x.com/DoughminCEO", aliases: ["x"] },
bluesky: { label: "Bluesky", sub: "@doughmination.win", url: "https://bsky.app/profile/doughmination.win", aliases: ["bsky"] },
linkedin: { label: "LinkedIn", sub: "Clove Twilight", url: "https://www.linkedin.com/in/estrogen/" },
spotify: { label: "Spotify", sub: "doughmination", url: "https://open.spotify.com/user/x060f5w4ftwv8zc8fi9662t70" },
discord: { label: "Discord", sub: "Doughmination", url: "https://discord.com/users/1464890289922641993" },
twitch: { label: "Twitch", sub: "@doughminationgaming", url: "https://www.twitch.tv/doughminationgaming" },
reddit: { label: "Reddit", sub: "u/XerinDotZero", url: "https://www.reddit.com/user/XerinDotZero/" },
youtube: { label: "YouTube", sub: "@CloveTwiGaming", url: "https://www.youtube.com/@CloveTwiGaming", aliases: ["yt"] },
mastodon: { label: "Mastodon", sub: "@doughmination@mastodon.social", url: "https://mastodon.social/@doughmination" },
email: { label: "Email", sub: "admin@doughmination.win", url: "mailto:admin@doughmination.win", aliases: ["mail"] },
portfolio: { label: "Portfolio", sub: "doughmination.co.uk", url: "https://doughmination.co.uk/", aliases: ["website", "site"] }
};
const ALIASES = {};
Object.keys(SOCIALS).forEach((k) => {
(SOCIALS[k].aliases || []).forEach((a) => { ALIASES[a] = k; });
});
// keyword -> svg filename in /assets/socials
const SOCIAL_ICON = {
github: "github", gitgay: "git-gay", twitter: "twitter", bluesky: "bluesky",
linkedin: "linkedin", spotify: "spotify", discord: "discord", twitch: "twitch",
reddit: "reddit", youtube: "youtube", mastodon: "mastodon", email: "email",
company: "site", portfolio: "site"
};
function iconImg(key) {
return ' ';
}
// ---- friends (keyword -> who they are) ---------------------------------
const FRIENDS = {
ari: { name: "Ari", desc: "π©΅ My wifey π©΅ the best π©΅ Her corner of the web:", url: "https://ariare.es", urlLabel: "ariare.es π©΅" },
saphie: { name: "Saphie", desc: "π©· Cammy's partner, loves linguisticsπ©·", url: "" },
camilla: { name: "Camillia (Cammy)", desc: "π€ Close friend who shares a passion for coding π€", url: "https://cammy-the-cat.com", urlLabel: "cammy-the-cat.com π€" },
ria: { name: "Ria", desc: "π€ Close friend/platonic daughter who means a lot to me π€", url: "" },
lilly: { name: "Lilly (Lils)", desc: "π Pookie, really cool person π", url: "" },
primrose: { name: "Nimnose", desc: "π Lil's partner π", url: "" },
fin: { name: "Fin", desc: "π Ari's friend who is really nice and who I can be unfilered with π", url: "" }
};
let cache = null;
async function checkDomain(subdomain) {
if (!cache) {
const response = await fetch("https://raw.is-a.dev/v2.json");
cache = await response.json();
}
return cache.some((d) => d.subdomain === subdomain);
}
// arch.ascii (hyfetch format) is fetched once at startup for `hyfetch`.
let archLines = null;
function loadArt() {
fetch("/arch.ascii").then(function (r) { return r.ok ? r.text() : ""; }).then(function (t) {
if (!t) return;
var lines = t.replace(/\r/g, "").split("\n");
if (lines[0] && lines[0].trim().charAt(0) === "{") lines.shift();
lines = lines.map(function (l) { return l.replace(/\$\{c\d\}/g, ""); });
while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
archLines = lines;
}).catch(function () { });
}
// ---- ascii banner -------------------------------------------------------
const BANNER = [
" ββββββββββ βββββββ βββ βββββββββββ",
"βββββββββββ ββββββββββββ βββββββββββ",
"βββ βββ βββ ββββββ βββββββββ ",
"βββ βββ βββ βββββββ ββββββββββ ",
"βββββββββββββββββββββββββ βββββββ ββββββββ",
" βββββββββββββββ βββββββ βββββ ββββββββ"
].join("\n");
// ---- boot log -----------------------------------------------------------
const BOOT = [
["info", "starting clovesh..."],
["info", "mounting /dev/estrogen..."],
["ok", "estrogen levels nominal"],
["info", "loading kernel modules (catppuccin)..."],
["ok", "modules loaded"],
["info", "summoning cats..."],
["ok", "oneko ready"],
["info", "connecting to discord via lanyard..."],
["ok", "presence online"],
["info", "mounting button wall..."],
["ok", "88x31 buttons hung"],
["info", "starting terminal..."],
["ok", "ready β type 'help'"]
];
// ---- build DOM ----------------------------------------------------------
root.innerHTML =
'
' +
'' +
'
' + esc(BANNER) + " " +
'
Type help for commands, or socials to browse.
' +
'
' +
'arch@arch:[~]$ ' +
' ' +
"
" +
'
' +
"
";
const bootEl = root.querySelector("#t-boot");
const mainEl = root.querySelector("#t-main");
const input = root.querySelector("#t-input");
const output = root.querySelector("#t-output");
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}
function stamp() {
return new Date().toLocaleTimeString("en-GB", { hour12: false });
}
// ---- command handlers ---------------------------------------------------
const COMMANDS = {
help() {
const rows = [
["help", "show this list"],
["socials", "list all socials"],
[" [-open]", "show a social & ask to open it (append -open to do directly)"],
["system [person]", "open my system website (append a person's name to open their page)"],
["about", "a little about me"],
["hyfetch", "system info, with flair"]
];
let out = "Available commands:\n";
out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n");
out += "\n\nTip: type a social's name (try 'socials') to open it.";
return { text: out };
},
ls() {
const rows = [
["help", "show this list"],
["socials", "list all socials"],
["", "show a social & ask to open it (append -open to do directly)"],
["system [person]", "open my system website (append a person's name to open their page)"],
["about", "a little about me"],
["hyfetch", "system info, with flair"]
];
let out = "Available commands:\n";
out += rows.map((r) => " " + r[0].padEnd(12) + r[1]).join("\n");
out += "\n\nTip: type a social's name (try 'socials') to open it.";
return { text: out };
},
async system(args) {
const who = (args[0] || "").toLowerCase();
if (!who) {
window.open("https://system.doughmination.co.uk/", "_blank");
return { text: "Opening system site..." };
}
try {
const response = await fetch(
`https://system.doughmination.co.uk/api/member/${encodeURIComponent(who)}`
);
if (response.status === 200) {
window.open(`https://system.doughmination.co.uk/member/${encodeURIComponent(who)}`, "_blank");
return { text: `Opening ${who}'s profile...` };
}
if (response.status === 404) {
return { text: "That person doesn't exist." };
}
if (response.status === 502) {
return { text: "The server is currently having issues." };
}
return { text: `Unexpected response (${response.status}).` };
} catch (error) {
return { text: "Failed to contact the server." };
}
},
socials() {
const items = Object.keys(SOCIALS)
.map((k) => '' + esc(k) + " ").join("");
return {
html: '' + items + "
" +
''
};
},
about() {
return {
text:
"Clove Twilight β fae/faer\n" +
"Transfem developer from Southampton, UK. I make Discord bots,\n" +
"personal-site nonsense, and run a small corner of the internet\n" +
"under the trade mark 'doughmination'. Big on Linux, Catppuccin, and cats.\n\n" +
"This site is the beta playground for clove.is-a.dev β expect things\n" +
"to break in funny ways. Type 'socials' to find me elsewhere."
};
},
hyfetch() {
const info = [
'arch @arch ',
"-----------------------",
"OS........ Arch Linux x86_64",
"GPU....... AMD ATI SPEEDSTER MERC 310 RX 7900 XTX",
"CPU....... AMD Ryzen 9 9950X3D (8) @ 5.7GHz",
"Host...... B850M AORUS ELITE WIFI6E ICE -CF-WCP-ADO",
"Kernel.... 7.0.11-arch1-1",
"Shell..... bash 5.3.12",
"Theme..... Breeze-Dark [GTK2/3]",
"Pronouns.. fae/faer",
"Uptime.... " + uptime(),
].join("\n");
if (!archLines || !archLines.length) {
return { html: '' + info + " " };
}
const colors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
const n = archLines.length;
const logo = archLines.map(function (ln, i) {
const c = colors[Math.min(colors.length - 1, Math.floor((i / n) * colors.length))];
return '' + esc(ln) + " ";
}).join("\n");
return {
html: '' + logo + " " +
'
' + info + " "
};
},
async isadotdev(parts) {
const arg = (parts[0] || "").toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, "");
if (!arg || !arg.endsWith(".is-a.dev")) {
return { text: "usage: isadotdev .is-a.dev", error: true };
}
const sub = arg.replace(/\.is-a\.dev$/, "");
if (["clove", "doughmination"].indexOf(sub) >= 0) {
return { text: "nice try π", error: true };
}
showResult({ text: "checking " + arg + "β¦" });
let found;
try { found = await checkDomain(sub); }
catch (e) { return { text: "couldn't reach the is-a.dev registry β try again later.", error: true }; }
if (!found) return { text: arg + " isn't registered on is-a.dev.", error: true };
window.open("https://" + arg, "_blank", "noopener");
return { html: 'opening ' + esc(arg) + " β¦" };
},
};
// ---- runtime ------------------------------------------------------------
const startedAt = Date.now();
function uptime() {
let s = Math.floor((Date.now() - startedAt) / 1000);
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts = [];
if (h) parts.push(h + "h");
if (m) parts.push(m + "m");
parts.push(s + "s");
return parts.join(" ");
}
const history = [];
let histIdx = -1;
let pendingSocial = null;
function showResult(result) {
output.innerHTML = "";
if (!result) return;
const box = document.createElement("div");
box.className = "t-result";
if (result.error) box.classList.add("is-error");
if (result.html != null) box.innerHTML = result.html;
else if (result.text != null) box.textContent = result.text;
output.appendChild(box);
output.scrollTop = 0;
}
function runCommand(fn, args) {
let r;
try { r = fn(args); }
catch (e) { showResult({ text: "error running that command.", error: true }); return; }
if (r && typeof r.then === "function") {
r.then(showResult).catch(function () { showResult({ text: "something went wrong.", error: true }); });
} else {
showResult(r);
}
}
function run(raw) {
const cmd = raw.trim();
output.innerHTML = "";
if (!cmd) { pendingSocial = null; return; }
history.push(cmd); histIdx = history.length;
const parts = cmd.split(/\s+/);
const name = parts[0].toLowerCase();
const flags = parts.slice(1).map((p) => p.toLowerCase());
const wantsOpen = flags.indexOf("-open") >= 0 || flags.indexOf("--open") >= 0 || flags.indexOf("-o") >= 0;
if (pendingSocial) {
if (["y", "yes", "open", "o"].indexOf(name) >= 0) { openSocial(pendingSocial); return; }
if (["n", "no"].indexOf(name) >= 0) { pendingSocial = null; showResult({ text: "okay, leaving it closed." }); return; }
pendingSocial = null;
}
const socialKey = resolveSocial(name === "open" ? flags[0] : name);
if (socialKey) {
if (wantsOpen || name === "open") openSocial(socialKey);
else promptSocial(socialKey);
return;
}
if (name.endsWith(".is-a.dev")) {
runCommand(COMMANDS.isadotdev, [name]);
return;
}
if (COMMANDS[name]) { runCommand(COMMANDS[name], parts.slice(1)); return; }
showResult({ text: "clovesh: command not found: " + name + "\nType 'help' for a list, or 'socials' to browse.", error: true });
}
function promptSocial(key) {
const s = SOCIALS[key];
pendingSocial = key;
showResult({
html:
'' +
'
' + iconImg(key) +
'' + esc(s.label) + " " +
'' + esc(s.sub) + "
" +
'
" + esc(s.url) + " " +
'
open it? type y Β· or run ' + esc(key) + " -open Β· n to cancel
" +
"
"
});
}
function resolveSocial(key) {
if (!key) return null;
if (SOCIALS[key]) return key;
if (ALIASES[key]) return ALIASES[key];
return null;
}
function openSocial(key) {
pendingSocial = null;
const s = SOCIALS[key];
showResult({
html: '" +
iconImg(key) + "opening " + esc(s.label) + " " +
'' + esc(s.url) + " β¦ "
});
if (s.url.startsWith("mailto:")) { window.location.href = s.url; }
else { window.open(s.url, "_blank", "noopener"); }
}
// ---- tab-complete + history --------------------------------------------
const COMPLETIONS = Object.keys(COMMANDS).concat(["open", "socials", "isadotdev"], Object.keys(SOCIALS), Object.keys(ALIASES));
function complete(prefix) {
if (!prefix) return null;
const hits = COMPLETIONS.filter((c) => c.indexOf(prefix) === 0);
if (hits.length === 1) return hits[0];
return null;
}
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const v = input.value;
input.value = "";
run(v);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (histIdx > 0) { histIdx--; input.value = history[histIdx] || ""; moveCaretEnd(); }
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (histIdx < history.length - 1) { histIdx++; input.value = history[histIdx] || ""; }
else { histIdx = history.length; input.value = ""; }
moveCaretEnd();
} else if (e.key === "Tab") {
e.preventDefault();
const c = complete(input.value.trim().toLowerCase());
if (c) input.value = c;
}
});
function moveCaretEnd() {
requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
}
root.addEventListener("click", () => {
if ((window.getSelection() + "") === "") input.focus();
});
// ---- boot then reveal ---------------------------------------------------
document.body.classList.add("term-booting");
let booted = false;
function finishBoot() {
if (booted) return;
booted = true;
bootEl.hidden = true;
mainEl.hidden = false;
document.body.classList.remove("term-booting");
document.body.classList.add("term-ready");
input.focus();
}
function streamBoot(i) {
if (booted) return;
if (i >= BOOT.length) { setTimeout(finishBoot, 350); return; }
const [kind, msg] = BOOT[i];
const tag = kind === "ok"
? ' OK '
: ' INFO ';
bootEl.insertAdjacentHTML("beforeend",
'[' + stamp() + " ] [" + tag + "] " + esc(msg) + " \n");
bootEl.scrollTop = bootEl.scrollHeight;
setTimeout(() => streamBoot(i + 1), 120 + Math.random() * 120);
}
function skipHandler(e) {
if (e.type === "keydown" || e.type === "click") finishBoot();
}
document.addEventListener("keydown", skipHandler, { once: false });
loadArt();
requestAnimationFrame(() => document.body.classList.add("term-chrome-in"));
streamBoot(0);
})();