feat: redesign UI with themes, animations, procedural ASCII engine
- 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:
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user