feat: redesign UI with themes, animations, procedural ASCII engine
Build and Deploy / Build Image (push) Successful in 31s
Build and Deploy / Deploy to Portainer (push) Successful in 6s

- 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>
This commit is contained in:
2026-07-11 22:18:14 -07:00
co-authored by Claude Fable 5
parent fdd7f9d751
commit 5e33a38dbb
8 changed files with 1230 additions and 198 deletions
+227
View File
@@ -0,0 +1,227 @@
/* 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();
})();