- Split app.py into routes + ascii_engine.py (stdlib-only procedural generation: figlet banners, creatures, patterns, scenes, classics) - API: /api/random?category=..., /api/categories, richer JSON payload - New frontend: templates/index.html + static/ (vanilla JS/CSS) - 4 themes (graphite/paper/phosphor/amber), localStorage persist, no-flash boot, prefers-color-scheme default - Scramble-resolve art transition, reduced-motion support - Responsive: rail collapses to chips <760px, art auto-fits width - Fix grid blowout on mobile (min-width: 0 on stage children) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
8.3 KiB
JavaScript
228 lines
8.3 KiB
JavaScript
/* asciigen frontend — vanilla JS, no dependencies. */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var artEl = document.getElementById("art");
|
||
var scrollEl = document.getElementById("artScroll");
|
||
var stTitle = document.getElementById("stTitle");
|
||
var stDims = document.getElementById("stDims");
|
||
var stMsg = document.getElementById("stMsg");
|
||
var genBtn = document.getElementById("genBtn");
|
||
var copyBtn = document.getElementById("copyBtn");
|
||
var copyLabel = document.getElementById("copyLabel");
|
||
|
||
var current = window.__initial || { art: artEl.textContent, title: "", cols: 0, rows: 0 };
|
||
var activeCat = "any";
|
||
var busy = false;
|
||
var msgTimer = null;
|
||
|
||
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Fit-to-width: scale art font down so the widest line fits the frame,
|
||
flooring at a readable minimum (horizontal scroll takes over below).
|
||
--------------------------------------------------------------------- */
|
||
var ART_BASE = 15;
|
||
var ART_MIN = 7;
|
||
|
||
function fitArt() {
|
||
artEl.style.fontSize = ART_BASE + "px";
|
||
var avail = scrollEl.clientWidth;
|
||
var need = artEl.scrollWidth;
|
||
if (need > avail && need > 0) {
|
||
var size = Math.max(ART_MIN, Math.floor(ART_BASE * (avail / need) * 10) / 10);
|
||
artEl.style.fontSize = size + "px";
|
||
}
|
||
}
|
||
|
||
var resizeTimer = null;
|
||
window.addEventListener("resize", function () {
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(fitArt, 120);
|
||
});
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Scramble-resolve transition. Whitespace stays fixed so the silhouette
|
||
appears instantly; visible characters resolve at staggered times.
|
||
--------------------------------------------------------------------- */
|
||
var GLYPHS = "#@%&$?!<>[]{}/\\|=+*~^;:";
|
||
|
||
function setArt(text) {
|
||
artEl.textContent = text;
|
||
fitArt();
|
||
if (reduceMotion.matches) {
|
||
return Promise.resolve();
|
||
}
|
||
var chars = Array.from(text);
|
||
var resolveAt = chars.map(function (c) {
|
||
return c === "\n" || c === " " ? 0 : 120 + Math.random() * 480;
|
||
});
|
||
var t0 = performance.now();
|
||
return new Promise(function (done) {
|
||
function frame(t) {
|
||
var dt = t - t0;
|
||
var out = "";
|
||
var pending = false;
|
||
for (var i = 0; i < chars.length; i++) {
|
||
var c = chars[i];
|
||
if (c === "\n" || c === " " || dt >= resolveAt[i]) {
|
||
out += c;
|
||
} else {
|
||
pending = true;
|
||
out += GLYPHS[(Math.random() * GLYPHS.length) | 0];
|
||
}
|
||
}
|
||
artEl.textContent = out;
|
||
if (pending) {
|
||
requestAnimationFrame(frame);
|
||
} else {
|
||
artEl.textContent = text;
|
||
done();
|
||
}
|
||
}
|
||
requestAnimationFrame(frame);
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Status readout
|
||
--------------------------------------------------------------------- */
|
||
function msg(text, hot) {
|
||
clearTimeout(msgTimer);
|
||
stMsg.textContent = text;
|
||
stMsg.classList.toggle("is-hot", !!hot);
|
||
if (hot) {
|
||
msgTimer = setTimeout(function () {
|
||
stMsg.textContent = "ready";
|
||
stMsg.classList.remove("is-hot");
|
||
}, 1600);
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Generate
|
||
--------------------------------------------------------------------- */
|
||
function generate() {
|
||
if (busy) return;
|
||
busy = true;
|
||
genBtn.disabled = true;
|
||
msg("tuning…", true);
|
||
var q = activeCat === "any" ? "" : "?category=" + encodeURIComponent(activeCat);
|
||
fetch("/api/random" + q)
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error("http " + r.status);
|
||
return r.json();
|
||
})
|
||
.then(function (d) {
|
||
current = d;
|
||
stTitle.textContent = d.title;
|
||
stDims.textContent = d.cols + "×" + d.rows;
|
||
return setArt(d.art);
|
||
})
|
||
.then(function () {
|
||
msg("ready");
|
||
})
|
||
.catch(function () {
|
||
msg("signal lost", true);
|
||
})
|
||
.then(function () {
|
||
busy = false;
|
||
genBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Copy to clipboard (with execCommand fallback for non-secure contexts)
|
||
--------------------------------------------------------------------- */
|
||
function copyArt() {
|
||
var text = current.art || artEl.textContent;
|
||
var ok = function () {
|
||
copyLabel.textContent = "copied ✓";
|
||
msg("copied", true);
|
||
setTimeout(function () { copyLabel.textContent = "copy"; }, 1400);
|
||
};
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
navigator.clipboard.writeText(text).then(ok, function () { fallbackCopy(text, ok); });
|
||
} else {
|
||
fallbackCopy(text, ok);
|
||
}
|
||
}
|
||
|
||
function fallbackCopy(text, ok) {
|
||
var ta = document.createElement("textarea");
|
||
ta.value = text;
|
||
ta.style.position = "fixed";
|
||
ta.style.opacity = "0";
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try {
|
||
document.execCommand("copy");
|
||
ok();
|
||
} catch (e) {
|
||
msg("copy failed", true);
|
||
}
|
||
document.body.removeChild(ta);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Categories
|
||
--------------------------------------------------------------------- */
|
||
var catBtns = Array.prototype.slice.call(document.querySelectorAll(".cat"));
|
||
catBtns.forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
activeCat = btn.dataset.cat;
|
||
catBtns.forEach(function (b) { b.classList.toggle("is-active", b === btn); });
|
||
generate();
|
||
});
|
||
});
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Themes
|
||
--------------------------------------------------------------------- */
|
||
var THEMES = ["graphite", "paper", "phosphor", "amber"];
|
||
var themeBtns = Array.prototype.slice.call(document.querySelectorAll(".theme-btn"));
|
||
|
||
function applyTheme(name) {
|
||
document.documentElement.dataset.theme = name;
|
||
try { localStorage.setItem("asciigen.theme", name); } catch (e) {}
|
||
themeBtns.forEach(function (b) {
|
||
b.classList.toggle("is-active", b.dataset.setTheme === name);
|
||
});
|
||
}
|
||
|
||
themeBtns.forEach(function (btn) {
|
||
btn.addEventListener("click", function () { applyTheme(btn.dataset.setTheme); });
|
||
});
|
||
|
||
function cycleTheme() {
|
||
var cur = document.documentElement.dataset.theme;
|
||
var idx = THEMES.indexOf(cur);
|
||
applyTheme(THEMES[(idx + 1) % THEMES.length]);
|
||
}
|
||
|
||
/* mark the boot theme's button as active */
|
||
applyTheme(document.documentElement.dataset.theme || "graphite");
|
||
|
||
/* ---------------------------------------------------------------------
|
||
Keyboard: space = generate, c = copy, t = cycle theme
|
||
--------------------------------------------------------------------- */
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
|
||
var tag = (e.target.tagName || "").toLowerCase();
|
||
var interactive = tag === "button" || tag === "input" || tag === "textarea" || tag === "select";
|
||
if (e.code === "Space" && !interactive) {
|
||
e.preventDefault();
|
||
generate();
|
||
} else if (e.key === "c" && !interactive) {
|
||
copyArt();
|
||
} else if (e.key === "t" && !interactive) {
|
||
cycleTheme();
|
||
}
|
||
});
|
||
|
||
genBtn.addEventListener("click", generate);
|
||
copyBtn.addEventListener("click", copyArt);
|
||
|
||
fitArt();
|
||
})();
|